@aparte/core 0.19.2 → 0.20.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.
- package/README.md +6 -1
- package/dist/client/aparte-client.d.ts +76 -97
- package/dist/client/aparte-client.d.ts.map +1 -1
- package/dist/client/stream-adapter.d.ts +7 -13
- package/dist/client/stream-adapter.d.ts.map +1 -1
- package/dist/client/wire-history.d.ts +4 -5
- package/dist/client/wire-history.d.ts.map +1 -1
- package/dist/components/bubble/aparte-chat-bubble.d.ts +89 -58
- package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
- package/dist/components/bubble/bubble-sync.d.ts +1 -1
- package/dist/components/chat/aparte-chat.d.ts +15 -13
- package/dist/components/chat/aparte-chat.d.ts.map +1 -1
- package/dist/components/chat/glide-gate.d.ts +13 -0
- package/dist/components/chat/glide-gate.d.ts.map +1 -0
- package/dist/components/composer/aparte-composer-action.d.ts +3 -3
- package/dist/components/composer/aparte-composer-add-attachment.d.ts +2 -2
- package/dist/components/composer/aparte-composer-cancel.d.ts +1 -1
- package/dist/components/composer/aparte-composer-input.d.ts +4 -7
- package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-send.d.ts +9 -11
- package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer.d.ts +50 -80
- package/dist/components/composer/aparte-composer.d.ts.map +1 -1
- package/dist/components/context/aparte-context.d.ts +13 -7
- package/dist/components/context/aparte-context.d.ts.map +1 -1
- package/dist/components/conversation-list/aparte-conversation-list.d.ts +25 -13
- package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
- package/dist/components/elicitation/aparte-elicitation.d.ts +15 -22
- package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
- package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +10 -11
- package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
- package/dist/components/sidebar/aparte-sidebar.d.ts +16 -3
- package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
- package/dist/components/split/aparte-split.d.ts +12 -3
- package/dist/components/split/aparte-split.d.ts.map +1 -1
- package/dist/components/status/aparte-chat-status.d.ts +5 -3
- package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
- package/dist/components/suggestions/aparte-suggestions.d.ts +6 -9
- package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
- package/dist/components/viewport/aparte-chat-viewport.d.ts +76 -69
- package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
- package/dist/config/aparte-config.d.ts +169 -141
- package/dist/config/aparte-config.d.ts.map +1 -1
- package/dist/config/icon-provider.d.ts +19 -2
- package/dist/config/icon-provider.d.ts.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/locale.d.ts +61 -89
- package/dist/config/locale.d.ts.map +1 -1
- package/dist/config/sanitize.d.ts.map +1 -1
- package/dist/conversations/conversation-controller.d.ts +18 -3
- package/dist/conversations/conversation-controller.d.ts.map +1 -1
- package/dist/conversations/conversation-manager.d.ts +6 -6
- package/dist/conversations/conversation-manager.d.ts.map +1 -1
- package/dist/conversations/index.d.ts +1 -1
- package/dist/conversations/index.d.ts.map +1 -1
- package/dist/custom-elements.json +5656 -4759
- package/dist/{dialog-triggers-CEazY76J.js → dialog-triggers-3Rk7PuZa.js} +555 -375
- package/dist/dialog-triggers-3Rk7PuZa.js.map +1 -0
- package/dist/elicitation/approval-panel.d.ts.map +1 -1
- package/dist/elicitation/index.d.ts +6 -1
- package/dist/elicitation/index.d.ts.map +1 -1
- package/dist/elicitation/panel.d.ts +11 -21
- package/dist/elicitation/panel.d.ts.map +1 -1
- package/dist/elicitation/types.d.ts +19 -27
- package/dist/elicitation/types.d.ts.map +1 -1
- package/dist/generated/element-attributes.d.ts +16 -10
- package/dist/generated/element-attributes.d.ts.map +1 -1
- package/dist/host/aparte-chat-host.d.ts +17 -32
- package/dist/host/aparte-chat-host.d.ts.map +1 -1
- package/dist/icons/extended.d.ts +1 -2
- package/dist/icons/extended.d.ts.map +1 -1
- package/dist/icons.d.ts +6 -8
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +13 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +775 -337
- package/dist/index.js.map +1 -1
- package/dist/index.node.d.ts +7 -4
- package/dist/index.node.d.ts.map +1 -1
- package/dist/index.node.js +2 -2
- package/dist/index.node.js.map +1 -1
- package/dist/interop/dialog-triggers.d.ts +2 -2
- package/dist/interop/element-props.d.ts +16 -27
- package/dist/interop/element-props.d.ts.map +1 -1
- package/dist/parsers/aparte-stream-parser.d.ts.map +1 -1
- package/dist/primitives/icon/aparte-icon.d.ts.map +1 -1
- package/dist/primitives/select/aparte-option.d.ts +3 -2
- package/dist/primitives/select/aparte-option.d.ts.map +1 -1
- package/dist/primitives/select/aparte-select.d.ts +24 -21
- package/dist/primitives/select/aparte-select.d.ts.map +1 -1
- package/dist/renderers/markdown-stream.d.ts.map +1 -1
- package/dist/renderers/segment-renderers.d.ts +11 -17
- package/dist/renderers/segment-renderers.d.ts.map +1 -1
- package/dist/renderers/segments/code.d.ts.map +1 -1
- package/dist/renderers/segments/error.d.ts.map +1 -1
- package/dist/renderers/segments/text.d.ts.map +1 -1
- package/dist/renderers/segments/thinking.d.ts.map +1 -1
- package/dist/renderers/segments/tool-call.d.ts.map +1 -1
- package/dist/runtime/message-repository.d.ts +8 -15
- package/dist/runtime/message-repository.d.ts.map +1 -1
- package/dist/transport/agent-handler.d.ts +2 -0
- package/dist/transport/agent-handler.d.ts.map +1 -1
- package/dist/transport/backend-handler.d.ts +4 -6
- package/dist/transport/backend-handler.d.ts.map +1 -1
- package/dist/transport/backend-transport.d.ts +15 -1
- package/dist/transport/backend-transport.d.ts.map +1 -1
- package/dist/transport/types.d.ts +7 -10
- package/dist/transport/types.d.ts.map +1 -1
- package/dist/types/chat.d.ts +10 -18
- package/dist/types/chat.d.ts.map +1 -1
- package/dist/types/element-attributes.d.ts +6 -12
- package/dist/types/element-attributes.d.ts.map +1 -1
- package/dist/types/element-map.d.ts +6 -19
- package/dist/types/element-map.d.ts.map +1 -1
- package/dist/types/event-map.d.ts +11 -17
- package/dist/types/event-map.d.ts.map +1 -1
- package/dist/types/events.d.ts +5 -14
- package/dist/types/events.d.ts.map +1 -1
- package/dist/types/imperative-api.d.ts +3 -1
- package/dist/types/imperative-api.d.ts.map +1 -1
- package/dist/types/index.d.ts +4 -4
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/model-provider.d.ts +7 -9
- package/dist/types/model-provider.d.ts.map +1 -1
- package/dist/types/segments.d.ts +29 -13
- package/dist/types/segments.d.ts.map +1 -1
- package/dist/types/tools.d.ts +21 -23
- package/dist/types/tools.d.ts.map +1 -1
- package/dist/utils/copy-text.d.ts +1 -2
- package/dist/utils/copy-text.d.ts.map +1 -1
- package/dist/utils/css-escape.d.ts +1 -1
- package/dist/utils/escape.d.ts +4 -19
- package/dist/utils/escape.d.ts.map +1 -1
- package/dist/utils/files-to-attachments.d.ts +2 -2
- package/dist/utils/fold-text.d.ts +13 -0
- package/dist/utils/fold-text.d.ts.map +1 -0
- package/dist/utils/is-awaiting-reply.d.ts +2 -5
- package/dist/utils/is-awaiting-reply.d.ts.map +1 -1
- package/dist/utils/presence.d.ts +5 -5
- package/dist/utils/segments.d.ts +54 -78
- package/dist/utils/segments.d.ts.map +1 -1
- package/dist/utils/tool-input.d.ts +1 -1
- package/dist/utils/tool-input.d.ts.map +1 -1
- package/dist/utils/transcript.d.ts +2 -2
- package/dist/utils/uuid.d.ts +0 -11
- package/dist/utils/uuid.d.ts.map +1 -1
- package/package.json +30 -4
- package/dist/dialog-triggers-CEazY76J.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a1 as resolveConfig, a9 as eventOrigin, aa as activeElementIn, a6 as subscribeConfigChange, a3 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, L as escapeAttr, H as cssEscape,
|
|
2
|
-
import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, F, G, I, J, K, N, O, T, V, W, Z, _, $, a0, a4, a5, a7 } from "./dialog-triggers-
|
|
1
|
+
import { a1 as resolveConfig, a9 as eventOrigin, aa as activeElementIn, a6 as subscribeConfigChange, a3 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, ab as gateGlide, L as escapeAttr, ac as renderingVersionOf, H as cssEscape, ad as mergeSegmentUpdate, M as escapeHtml, ae as writeStreamedMarkdown, U as isSafeUrl, C as copyText, P as getSegmentRenderer, Q as installDefaultRenderersOnce, r as AparteMessageRepository, af as stampSegmentActivity, ag as stampSegmentOnInsert, ah as stampSegmentOnUpdate, ai as renumberSegments, aj as openSegmentIds, ak as isTerminalStatus, al as adoptMessageSegments, S as isAwaitingReply, Y as populateBubbleFromMessage, a8 as uuid, a2 as revokeAttachmentUrls, X as parseMarkdownToSegments, am as APARTE_DERIVED_SEGMENTS, e as APARTE_DEFAULT_LOCALE, an as chatBoundaryOf, ao as rootOf, R as installDialogTriggersOnce } from "./dialog-triggers-3Rk7PuZa.js";
|
|
2
|
+
import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, F, G, I, J, K, N, O, T, V, W, Z, _, $, a0, a4, a5, a7 } from "./dialog-triggers-3Rk7PuZa.js";
|
|
3
3
|
function presenceOn(value) {
|
|
4
4
|
return value === "" ? true : Boolean(value);
|
|
5
5
|
}
|
|
@@ -194,24 +194,19 @@ class AparteSelect extends HTMLElement {
|
|
|
194
194
|
*
|
|
195
195
|
* `_setupEventListeners()` runs on EVERY `connectedCallback`, and `_render()`'s
|
|
196
196
|
* idempotency guard means the host element survives a re-connect. So each move
|
|
197
|
-
* of the element (a portal, a Vue teleport, any framework re-parent)
|
|
198
|
-
* another option-click listener
|
|
199
|
-
*
|
|
200
|
-
* document-level handlers and could not touch this one.
|
|
201
|
-
*
|
|
202
|
-
* This is verbatim the bug class `aparte-chat-viewport` documents having fixed
|
|
203
|
-
* for its own listeners.
|
|
197
|
+
* of the element (a portal, a Vue teleport, any framework re-parent) would add
|
|
198
|
+
* another option-click listener and one click would fire the change handler once
|
|
199
|
+
* per re-connect, unless the same bound reference is removed and re-added.
|
|
204
200
|
*/
|
|
205
201
|
_boundHandleOptionClick = this._handleOptionClick.bind(this);
|
|
206
202
|
_boundHandleDocumentClick = this._handleDocumentClick.bind(this);
|
|
207
203
|
_boundHandleKeydown = this._handleKeydown.bind(this);
|
|
208
204
|
/**
|
|
209
|
-
* The trigger's own three, bound for the same reason
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* fixed for the option click and not for its neighbours.
|
|
205
|
+
* The trigger's own three, bound for the same reason as the option click: the
|
|
206
|
+
* trigger element survives a re-connect, so listeners that cannot be removed would
|
|
207
|
+
* stack. One re-parent would make a click toggle twice — open and shut inside the
|
|
208
|
+
* same click, i.e. a control that looks dead — and announce `aparte-select-open`
|
|
209
|
+
* twice.
|
|
215
210
|
*/
|
|
216
211
|
_boundHandleTriggerClick = () => {
|
|
217
212
|
this._toggle();
|
|
@@ -237,6 +232,7 @@ class AparteSelect extends HTMLElement {
|
|
|
237
232
|
this._value = this.getAttribute("value") || "";
|
|
238
233
|
const wantsOpen = this.hasAttribute("open") && !this._isOpen;
|
|
239
234
|
this._render();
|
|
235
|
+
this._applySearchable();
|
|
240
236
|
this._applyDisabled();
|
|
241
237
|
this._setupEventListeners();
|
|
242
238
|
this._setupMutationObserver();
|
|
@@ -264,6 +260,7 @@ class AparteSelect extends HTMLElement {
|
|
|
264
260
|
if (name === "placeholder") {
|
|
265
261
|
this._updateTriggerLabel();
|
|
266
262
|
}
|
|
263
|
+
if (name === "searchable") this._applySearchable();
|
|
267
264
|
if (name === "disabled") {
|
|
268
265
|
this._applyDisabled();
|
|
269
266
|
if (!this.hasAttribute("disabled") && this.hasAttribute("open")) this._openDropdown();
|
|
@@ -292,6 +289,45 @@ class AparteSelect extends HTMLElement {
|
|
|
292
289
|
// ─────────────────────────────────────────────────────────────────────────
|
|
293
290
|
// Rendering
|
|
294
291
|
// ─────────────────────────────────────────────────────────────────────────
|
|
292
|
+
/** The filter field: the combobox of a `searchable` select, since it takes the focus. */
|
|
293
|
+
_buildSearchInput(cfg) {
|
|
294
|
+
const searchInput = document.createElement("input");
|
|
295
|
+
searchInput.type = "text";
|
|
296
|
+
searchInput.className = "aparte-select-search";
|
|
297
|
+
searchInput.placeholder = cfg.t("selectSearchPlaceholder") || "Search…";
|
|
298
|
+
searchInput.setAttribute("aria-label", cfg.t("selectSearchLabel") || "Search options");
|
|
299
|
+
searchInput.setAttribute("role", "combobox");
|
|
300
|
+
searchInput.setAttribute("aria-haspopup", "listbox");
|
|
301
|
+
searchInput.setAttribute("aria-autocomplete", "list");
|
|
302
|
+
searchInput.setAttribute("aria-expanded", String(this._isOpen));
|
|
303
|
+
return searchInput;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* `searchable` set or removed after the first render: the field comes or goes, and the
|
|
307
|
+
* combobox moves with it, so a framework that
|
|
308
|
+
* binds it a beat late — Angular's `[searchable]` inside an `@if` — still gets a field.
|
|
309
|
+
*/
|
|
310
|
+
_applySearchable() {
|
|
311
|
+
if (!this._dropdown || !this._trigger) return;
|
|
312
|
+
const wanted = this.hasAttribute("searchable");
|
|
313
|
+
if (wanted === Boolean(this._searchInput)) return;
|
|
314
|
+
if (this._isOpen) this._closeDropdown();
|
|
315
|
+
const listboxId = this._trigger.getAttribute("aria-controls");
|
|
316
|
+
if (wanted) {
|
|
317
|
+
const input = this._buildSearchInput(resolveConfig(this));
|
|
318
|
+
if (listboxId) input.setAttribute("aria-controls", listboxId);
|
|
319
|
+
this._dropdown.prepend(input);
|
|
320
|
+
this._searchInput = input;
|
|
321
|
+
input.addEventListener("input", this._boundHandleSearchInput);
|
|
322
|
+
this._trigger.setAttribute("role", "button");
|
|
323
|
+
} else {
|
|
324
|
+
this._searchInput.removeEventListener("input", this._boundHandleSearchInput);
|
|
325
|
+
this._searchInput.remove();
|
|
326
|
+
this._searchInput = null;
|
|
327
|
+
this._trigger.setAttribute("role", "combobox");
|
|
328
|
+
}
|
|
329
|
+
this._updateNames();
|
|
330
|
+
}
|
|
295
331
|
_render() {
|
|
296
332
|
const placeholder = this.getAttribute("placeholder") || "Select...";
|
|
297
333
|
const searchable = this.hasAttribute("searchable");
|
|
@@ -324,19 +360,8 @@ class AparteSelect extends HTMLElement {
|
|
|
324
360
|
const dropdown = document.createElement("div");
|
|
325
361
|
dropdown.className = "aparte-select-dropdown";
|
|
326
362
|
dropdown.hidden = !this._isOpen;
|
|
327
|
-
|
|
328
|
-
if (
|
|
329
|
-
searchInput = document.createElement("input");
|
|
330
|
-
searchInput.type = "text";
|
|
331
|
-
searchInput.className = "aparte-select-search";
|
|
332
|
-
searchInput.placeholder = cfg.t("selectSearchPlaceholder") || "Search…";
|
|
333
|
-
searchInput.setAttribute("aria-label", cfg.t("selectSearchLabel") || "Search options");
|
|
334
|
-
searchInput.setAttribute("role", "combobox");
|
|
335
|
-
searchInput.setAttribute("aria-haspopup", "listbox");
|
|
336
|
-
searchInput.setAttribute("aria-autocomplete", "list");
|
|
337
|
-
searchInput.setAttribute("aria-expanded", String(this._isOpen));
|
|
338
|
-
dropdown.appendChild(searchInput);
|
|
339
|
-
}
|
|
363
|
+
const searchInput = searchable ? this._buildSearchInput(cfg) : null;
|
|
364
|
+
if (searchInput) dropdown.appendChild(searchInput);
|
|
340
365
|
const optionsContainer = document.createElement("div");
|
|
341
366
|
optionsContainer.className = "aparte-select-options";
|
|
342
367
|
optionsContainer.setAttribute("role", "listbox");
|
|
@@ -375,9 +400,9 @@ class AparteSelect extends HTMLElement {
|
|
|
375
400
|
* Put the roving highlight back after the options changed underneath it.
|
|
376
401
|
*
|
|
377
402
|
* `data-active` lives on an option ELEMENT, so replacing the list throws it
|
|
378
|
-
* away while `_activeIndex` still claims a position: the highlight
|
|
379
|
-
* `aria-activedescendant`
|
|
380
|
-
* the next ArrowDown
|
|
403
|
+
* away while `_activeIndex` still claims a position: without this the highlight
|
|
404
|
+
* disappears, `aria-activedescendant` points at an id no longer in the document, and
|
|
405
|
+
* the next ArrowDown moves from the stale index — skipping an option. Only when
|
|
381
406
|
* open and only when a position was held, so a refresh never invents one.
|
|
382
407
|
*/
|
|
383
408
|
_restoreActive() {
|
|
@@ -603,8 +628,8 @@ class AparteSelect extends HTMLElement {
|
|
|
603
628
|
* A `combobox` is NAMED by that attribute and VALUED by its visible label span, so
|
|
604
629
|
* the name is the control and nothing else. A `button` — the shape a `searchable`
|
|
605
630
|
* trigger takes, since the filter field is then the combobox — is named by its
|
|
606
|
-
* CONTENT, which this very attribute overrides:
|
|
607
|
-
* "Pick a model, button"
|
|
631
|
+
* CONTENT, which this very attribute overrides: a name of just the placeholder
|
|
632
|
+
* ("Pick a model, button") would never say which model is selected. So the button's
|
|
608
633
|
* name carries both halves, and drops the second when it would only repeat the
|
|
609
634
|
* first (nothing selected: the value IS the placeholder).
|
|
610
635
|
*/
|
|
@@ -644,7 +669,7 @@ class AparteSelect extends HTMLElement {
|
|
|
644
669
|
}
|
|
645
670
|
const sizer = this._trigger?.querySelector(".aparte-select-label-sizer");
|
|
646
671
|
if (sizer) {
|
|
647
|
-
const labels = [placeholder, ...Array.from(this.querySelectorAll("aparte-option"), (o) => o
|
|
672
|
+
const labels = [placeholder, ...Array.from(this.querySelectorAll("aparte-option"), (o) => optionLabelText(o))];
|
|
648
673
|
const current = Array.from(sizer.children, (s2) => s2.textContent ?? "");
|
|
649
674
|
if (labels.length !== current.length || labels.some((l2, i2) => l2 !== current[i2])) {
|
|
650
675
|
sizer.replaceChildren(...labels.map((text) => {
|
|
@@ -667,7 +692,7 @@ class AparteSelect extends HTMLElement {
|
|
|
667
692
|
}
|
|
668
693
|
_getSelectedLabel() {
|
|
669
694
|
for (const opt of this.querySelectorAll("aparte-option")) {
|
|
670
|
-
if (opt.getAttribute("value") === this._value) return opt
|
|
695
|
+
if (opt.getAttribute("value") === this._value) return optionLabelText(opt);
|
|
671
696
|
}
|
|
672
697
|
return "";
|
|
673
698
|
}
|
|
@@ -693,6 +718,14 @@ class AparteSelect extends HTMLElement {
|
|
|
693
718
|
}));
|
|
694
719
|
}
|
|
695
720
|
}
|
|
721
|
+
function optionLabelText(option) {
|
|
722
|
+
let text = "";
|
|
723
|
+
for (const node of option.childNodes) {
|
|
724
|
+
if (node.nodeType === Node.ELEMENT_NODE && node.matches(".aparte-option__meta, .aparte-status-dot")) continue;
|
|
725
|
+
text += node.textContent ?? "";
|
|
726
|
+
}
|
|
727
|
+
return text.trim();
|
|
728
|
+
}
|
|
696
729
|
if (!customElements.get("aparte-select")) {
|
|
697
730
|
customElements.define("aparte-select", AparteSelect);
|
|
698
731
|
}
|
|
@@ -748,7 +781,7 @@ class AparteIcon extends HTMLElement {
|
|
|
748
781
|
}
|
|
749
782
|
_render() {
|
|
750
783
|
const name = this.getAttribute("name");
|
|
751
|
-
const glyph = name ? resolveConfig(this).
|
|
784
|
+
const glyph = name ? resolveConfig(this).getIcon(name) : null;
|
|
752
785
|
this.innerHTML = glyph ?? "";
|
|
753
786
|
this.firstElementChild?.setAttribute("aria-hidden", "true");
|
|
754
787
|
}
|
|
@@ -756,17 +789,59 @@ class AparteIcon extends HTMLElement {
|
|
|
756
789
|
if (!customElements.get("aparte-icon")) {
|
|
757
790
|
customElements.define("aparte-icon", AparteIcon);
|
|
758
791
|
}
|
|
792
|
+
function deepActiveElement(root) {
|
|
793
|
+
let active = root.activeElement;
|
|
794
|
+
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
|
795
|
+
return active;
|
|
796
|
+
}
|
|
797
|
+
function isWithin(node, ancestor) {
|
|
798
|
+
for (let n2 = node; n2; n2 = n2.parentNode ?? (n2 instanceof ShadowRoot ? n2.host : null)) {
|
|
799
|
+
if (n2 === ancestor) return true;
|
|
800
|
+
}
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
function addRetract(panelEl, corner, cfg, reopen, onToggle) {
|
|
804
|
+
const hide = document.createElement("button");
|
|
805
|
+
hide.type = "button";
|
|
806
|
+
hide.className = "aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--ghost aparte-elic-hide";
|
|
807
|
+
hide.setAttribute("aria-expanded", "true");
|
|
808
|
+
hide.innerHTML = cfg.getIcon("expand");
|
|
809
|
+
const reveal = document.createElement("button");
|
|
810
|
+
reveal.type = "button";
|
|
811
|
+
reveal.className = "aparte-btn aparte-btn--ghost aparte-elic-reveal";
|
|
812
|
+
reveal.setAttribute("aria-expanded", "false");
|
|
813
|
+
const revealText = document.createElement("span");
|
|
814
|
+
reveal.append(revealText);
|
|
815
|
+
reveal.insertAdjacentHTML("beforeend", cfg.getIcon("collapse"));
|
|
816
|
+
const relabel = (next) => {
|
|
817
|
+
const label = next.t("elicitationHide");
|
|
818
|
+
hide.setAttribute("aria-label", label);
|
|
819
|
+
hide.title = label;
|
|
820
|
+
revealText.textContent = next.t("elicitationWaiting");
|
|
821
|
+
};
|
|
822
|
+
relabel(cfg);
|
|
823
|
+
hide.addEventListener("click", () => {
|
|
824
|
+
panelEl.setAttribute("data-collapsed", "");
|
|
825
|
+
onToggle();
|
|
826
|
+
reveal.focus();
|
|
827
|
+
});
|
|
828
|
+
reveal.addEventListener("click", () => {
|
|
829
|
+
panelEl.removeAttribute("data-collapsed");
|
|
830
|
+
onToggle();
|
|
831
|
+
reopen();
|
|
832
|
+
});
|
|
833
|
+
corner.prepend(hide);
|
|
834
|
+
panelEl.append(reveal);
|
|
835
|
+
return { relabel };
|
|
836
|
+
}
|
|
759
837
|
class AparteElicitation extends HTMLElement {
|
|
760
838
|
_pending = null;
|
|
761
839
|
/**
|
|
762
840
|
* A turn ended — cancel the open question only if it was OUR turn.
|
|
763
841
|
*
|
|
764
|
-
* These two listeners sit on `window
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
* the user was still looking at. Same defect as the `compact()` handler that
|
|
768
|
-
* emptied both chats, which its four sibling handlers in `AparteClient` already
|
|
769
|
-
* guarded against.
|
|
842
|
+
* These two listeners sit on `window`, so they filter by instance: a Stop (or an
|
|
843
|
+
* error) in one chat must not cancel the question a DIFFERENT chat is waiting on,
|
|
844
|
+
* whose model would be told the user refused a question they were still looking at.
|
|
770
845
|
*
|
|
771
846
|
* The leniency rule is the composer's, deliberately: an event with no
|
|
772
847
|
* `targetId` is for everyone (a single-chat app never sets one), and a chat we
|
|
@@ -830,16 +905,60 @@ class AparteElicitation extends HTMLElement {
|
|
|
830
905
|
return new Promise((resolve, reject) => {
|
|
831
906
|
let done = false;
|
|
832
907
|
const slot = {};
|
|
833
|
-
const focusedBefore = document
|
|
908
|
+
const focusedBefore = deepActiveElement(document);
|
|
909
|
+
const messageText = () => {
|
|
910
|
+
const m2 = request.message;
|
|
911
|
+
return typeof m2 === "function" ? String(m2()) : String(m2 ?? "");
|
|
912
|
+
};
|
|
913
|
+
const CHAT = "aparte-chat, [data-aparte-chat], [data-aparte-host]";
|
|
914
|
+
const EDITABLE = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
|
|
915
|
+
const chatRoot = composer.closest(CHAT) ?? composer.parentElement ?? composer;
|
|
916
|
+
const fromNobody = !(focusedBefore instanceof HTMLElement) || focusedBefore === document.body;
|
|
917
|
+
const before = fromNobody ? null : focusedBefore;
|
|
918
|
+
const inChat = before !== null && isWithin(before, chatRoot);
|
|
919
|
+
const typing = before !== null && before.matches(EDITABLE);
|
|
920
|
+
const busyElsewhere = before !== null && !inChat && (typing || before.tagName === "IFRAME" || before.closest(CHAT) !== null);
|
|
921
|
+
const status = (text) => {
|
|
922
|
+
let region = composer.querySelector(":scope > .aparte-elic-status");
|
|
923
|
+
if (!region) {
|
|
924
|
+
region = document.createElement("span");
|
|
925
|
+
region.className = "aparte-sr-only aparte-elic-status";
|
|
926
|
+
region.setAttribute("role", "status");
|
|
927
|
+
composer.appendChild(region);
|
|
928
|
+
}
|
|
929
|
+
const live = region;
|
|
930
|
+
live.textContent = "";
|
|
931
|
+
if (text) setTimeout(() => {
|
|
932
|
+
if (live.isConnected) live.textContent = text;
|
|
933
|
+
}, 60);
|
|
934
|
+
};
|
|
935
|
+
const focusPanel = (panel2, message) => {
|
|
936
|
+
if (busyElsewhere) {
|
|
937
|
+
status(message);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const x = window.scrollX, y2 = window.scrollY;
|
|
941
|
+
if (typing && inChat) {
|
|
942
|
+
if (!panel2.el.hasAttribute("tabindex")) panel2.el.setAttribute("tabindex", "-1");
|
|
943
|
+
if (!panel2.el.hasAttribute("role")) panel2.el.setAttribute("role", "group");
|
|
944
|
+
if (message && !panel2.el.hasAttribute("aria-label")) panel2.el.setAttribute("aria-label", message);
|
|
945
|
+
panel2.el.focus();
|
|
946
|
+
} else {
|
|
947
|
+
panel2.focus();
|
|
948
|
+
}
|
|
949
|
+
if (fromNobody && (window.scrollX !== x || window.scrollY !== y2)) window.scrollTo(x, y2);
|
|
950
|
+
};
|
|
834
951
|
const close = () => {
|
|
835
952
|
if (done) return false;
|
|
836
953
|
done = true;
|
|
837
954
|
this._pending = null;
|
|
838
|
-
const active = document
|
|
839
|
-
const focusWasInPanel = active instanceof Node &&
|
|
955
|
+
const active = deepActiveElement(document);
|
|
956
|
+
const focusWasInPanel = active instanceof Node && isWithin(active, composer);
|
|
840
957
|
composer.hidePanel(slot.token);
|
|
841
|
-
|
|
842
|
-
|
|
958
|
+
status("");
|
|
959
|
+
if (focusWasInPanel) {
|
|
960
|
+
const back = !busyElsewhere && before !== null && before.isConnected && !isWithin(before, composer) ? before : null;
|
|
961
|
+
(back ?? composer).focus();
|
|
843
962
|
}
|
|
844
963
|
return true;
|
|
845
964
|
};
|
|
@@ -858,12 +977,20 @@ class AparteElicitation extends HTMLElement {
|
|
|
858
977
|
}
|
|
859
978
|
const cfg = resolveConfig(this);
|
|
860
979
|
if (request.kind === "approval") {
|
|
861
|
-
const
|
|
862
|
-
const
|
|
863
|
-
|
|
864
|
-
|
|
980
|
+
const sendable = () => panel2.isComplete() && !panel2.el.hasAttribute("data-collapsed");
|
|
981
|
+
const approvalMode = () => sendable() ? "submit" : "none";
|
|
982
|
+
const syncSubmit2 = () => composer.setPanelSubmitEnabled(sendable(), approvalMode());
|
|
983
|
+
const panel2 = runWithConfig(cfg, () => buildApprovalPanel(request.message, request.options ?? [], syncSubmit2, request.details));
|
|
865
984
|
panel2.onSettle((answer) => settle({ action: "accept", content: answer }));
|
|
866
|
-
|
|
985
|
+
const retract2 = addRetract(panel2.el, panel2.dismiss, cfg, () => panel2.focus(), syncSubmit2);
|
|
986
|
+
this._pending = {
|
|
987
|
+
abort: () => fail(),
|
|
988
|
+
composer,
|
|
989
|
+
relabel: () => {
|
|
990
|
+
panel2.relabel();
|
|
991
|
+
retract2.relabel(resolveConfig(this));
|
|
992
|
+
}
|
|
993
|
+
};
|
|
867
994
|
slot.token = composer.showPanel(panel2.el, {
|
|
868
995
|
submitEnabled: panel2.isComplete(),
|
|
869
996
|
mode: approvalMode(),
|
|
@@ -872,26 +999,31 @@ class AparteElicitation extends HTMLElement {
|
|
|
872
999
|
},
|
|
873
1000
|
onEvict: () => fail()
|
|
874
1001
|
});
|
|
875
|
-
panel2
|
|
1002
|
+
focusPanel(panel2, messageText());
|
|
876
1003
|
return;
|
|
877
1004
|
}
|
|
878
1005
|
const schema = request.schema ?? { type: "string" };
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
1006
|
+
const syncSubmit = () => {
|
|
1007
|
+
const aside = panel.el.hasAttribute("data-collapsed");
|
|
1008
|
+
composer.setPanelSubmitEnabled(!aside && panel.canProceed(), aside ? "none" : panel.mode());
|
|
1009
|
+
};
|
|
1010
|
+
const panel = runWithConfig(cfg, () => buildElicitationPanel(request.message, schema, syncSubmit));
|
|
882
1011
|
const skip = document.createElement("button");
|
|
883
1012
|
skip.type = "button";
|
|
884
1013
|
skip.className = "aparte-btn aparte-elic-skip";
|
|
885
1014
|
skip.textContent = cfg.t("elicitationSkip");
|
|
886
1015
|
skip.addEventListener("click", () => settle({ action: "decline" }));
|
|
887
1016
|
panel.dismiss.appendChild(skip);
|
|
1017
|
+
const retract = addRetract(panel.el, panel.dismiss, cfg, () => panel.focus(), syncSubmit);
|
|
888
1018
|
panel.onSettle((content) => settle({ action: "accept", content }));
|
|
889
1019
|
this._pending = {
|
|
890
1020
|
abort: () => fail(),
|
|
891
1021
|
composer,
|
|
892
1022
|
relabel: () => {
|
|
893
1023
|
panel.relabel();
|
|
894
|
-
|
|
1024
|
+
const now = resolveConfig(this);
|
|
1025
|
+
skip.textContent = now.t("elicitationSkip");
|
|
1026
|
+
retract.relabel(now);
|
|
895
1027
|
}
|
|
896
1028
|
};
|
|
897
1029
|
slot.token = composer.showPanel(panel.el, {
|
|
@@ -902,15 +1034,15 @@ class AparteElicitation extends HTMLElement {
|
|
|
902
1034
|
* or a turn ending. The composer tears the panel down either way; only
|
|
903
1035
|
* this callback can settle the promise, and without it the request hung
|
|
904
1036
|
* AND `_pending` stayed set, so every later question was short-circuited
|
|
905
|
-
* for the life of the page. `
|
|
906
|
-
* anything, the question was taken away.
|
|
1037
|
+
* for the life of the page. A rejection (`aborted`), not `decline`:
|
|
1038
|
+
* nobody declined anything, the question was taken away.
|
|
907
1039
|
*/
|
|
908
1040
|
onEvict: () => fail(),
|
|
909
1041
|
onSubmit: () => {
|
|
910
1042
|
if (panel.isComplete()) settle({ action: "accept", content: panel.getContent() });
|
|
911
1043
|
}
|
|
912
1044
|
});
|
|
913
|
-
panel
|
|
1045
|
+
focusPanel(panel, messageText());
|
|
914
1046
|
});
|
|
915
1047
|
};
|
|
916
1048
|
_cancelPending() {
|
|
@@ -940,12 +1072,11 @@ class AparteElicitation extends HTMLElement {
|
|
|
940
1072
|
* The composer to present in: the nearest one in an ancestor subtree, and
|
|
941
1073
|
* nothing else.
|
|
942
1074
|
*
|
|
943
|
-
* There
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
* belonging to a chat the user was not looking at.
|
|
1075
|
+
* There is no `document.querySelector('aparte-composer')` fallback: on a page with
|
|
1076
|
+
* two chats, an elicitation that could not find its own composer would mount its
|
|
1077
|
+
* panel in the OTHER chat's, so one conversation's question would appear under the
|
|
1078
|
+
* other conversation, and answering it would resolve a tool call belonging to a
|
|
1079
|
+
* chat the user was not looking at.
|
|
949
1080
|
*
|
|
950
1081
|
* Returning `null` instead REJECTS the request, which is honest: nothing was
|
|
951
1082
|
* shown, so nothing was answered. The warning names the fix, because this is a
|
|
@@ -980,6 +1111,7 @@ class AparteChat extends HTMLElement {
|
|
|
980
1111
|
}
|
|
981
1112
|
_observer = null;
|
|
982
1113
|
_loadingObserver = null;
|
|
1114
|
+
_offGlide = null;
|
|
983
1115
|
/** Waits for a `<aparte-chat-viewport>` child to appear — see `_watchForViewport`. */
|
|
984
1116
|
_childObserver = null;
|
|
985
1117
|
/** The viewport is loading and the gate is on — see `_applyLoadingGate`. */
|
|
@@ -1000,8 +1132,12 @@ class AparteChat extends HTMLElement {
|
|
|
1000
1132
|
this._syncEmptyWatch();
|
|
1001
1133
|
this._syncLoadingWatch();
|
|
1002
1134
|
this._watchForViewport();
|
|
1135
|
+
this._offGlide?.();
|
|
1136
|
+
this._offGlide = gateGlide(this);
|
|
1003
1137
|
}
|
|
1004
1138
|
disconnectedCallback() {
|
|
1139
|
+
this._offGlide?.();
|
|
1140
|
+
this._offGlide = null;
|
|
1005
1141
|
this._observer?.disconnect();
|
|
1006
1142
|
this._observer = null;
|
|
1007
1143
|
this._loadingObserver?.disconnect();
|
|
@@ -1084,11 +1220,10 @@ class AparteChat extends HTMLElement {
|
|
|
1084
1220
|
/**
|
|
1085
1221
|
* Watch the viewport so `data-empty` follows the conversation.
|
|
1086
1222
|
*
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
* it everywhere moves no layout.
|
|
1223
|
+
* Runs whether or not `center-empty` is set, so an app that wants starters on an
|
|
1224
|
+
* empty thread, or a welcome of its own, can read `data-empty` instead of counting
|
|
1225
|
+
* messages itself. Every selector core writes against it requires `[center-empty]`,
|
|
1226
|
+
* so reflecting it everywhere moves no layout.
|
|
1092
1227
|
*/
|
|
1093
1228
|
_syncEmptyWatch() {
|
|
1094
1229
|
this._observer?.disconnect();
|
|
@@ -1115,12 +1250,11 @@ class AparteChat extends HTMLElement {
|
|
|
1115
1250
|
this._loadingObserver.observe(viewport, { attributes: true, attributeFilter: ["loading"] });
|
|
1116
1251
|
}
|
|
1117
1252
|
/**
|
|
1118
|
-
* Both watches above need a viewport to observe, and
|
|
1119
|
-
* connect
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
* transcript is fetched. This waits for the child to arrive, installs them, and stops.
|
|
1253
|
+
* Both watches above need a viewport to observe, and there might not be one at
|
|
1254
|
+
* connect: `<aparte-chat framework-managed>` IS Angular's host element — upgraded on
|
|
1255
|
+
* insert, filled by the template afterwards — and hand-written markup upgrades before
|
|
1256
|
+
* its children are parsed. This waits for the child to arrive, installs them, and
|
|
1257
|
+
* stops, so every chat gates, including those whose transcript is fetched.
|
|
1124
1258
|
*/
|
|
1125
1259
|
_watchForViewport() {
|
|
1126
1260
|
this._childObserver?.disconnect();
|
|
@@ -1155,8 +1289,8 @@ class AparteChat extends HTMLElement {
|
|
|
1155
1289
|
}
|
|
1156
1290
|
/**
|
|
1157
1291
|
* Empty means no message AND none on its way. A viewport that says `loading` holds
|
|
1158
|
-
* no bubble yet
|
|
1159
|
-
*
|
|
1292
|
+
* no bubble yet but does not count as empty, so the welcome and the centred composer
|
|
1293
|
+
* stay hidden while a conversation is being fetched.
|
|
1160
1294
|
*/
|
|
1161
1295
|
_updateEmpty() {
|
|
1162
1296
|
const viewport = this.querySelector("aparte-chat-viewport");
|
|
@@ -1198,6 +1332,16 @@ function segmentRenderResultToElement(result, segment) {
|
|
|
1198
1332
|
if (el2 && segment && !el2.hasAttribute("data-segment-id")) el2.setAttribute("data-segment-id", segment.id);
|
|
1199
1333
|
return el2;
|
|
1200
1334
|
}
|
|
1335
|
+
const pendingAvatarMarks = /* @__PURE__ */ new Map();
|
|
1336
|
+
function flushAvatarMarks() {
|
|
1337
|
+
const batch = [...pendingAvatarMarks].filter(([avatar]) => !avatar.firstChild);
|
|
1338
|
+
pendingAvatarMarks.clear();
|
|
1339
|
+
const shown = batch.map(([, { bubble, role }]) => {
|
|
1340
|
+
const image = getComputedStyle(bubble).getPropertyValue(`--aparte-avatar-image-${role}`).trim();
|
|
1341
|
+
return image !== "" && image !== "none";
|
|
1342
|
+
});
|
|
1343
|
+
batch.forEach(([avatar], i2) => avatar.toggleAttribute("data-image", shown[i2]));
|
|
1344
|
+
}
|
|
1201
1345
|
class AparteChatBubble extends HTMLElement {
|
|
1202
1346
|
_contentEl = null;
|
|
1203
1347
|
_segmentsEl = null;
|
|
@@ -1213,8 +1357,20 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1213
1357
|
_usage = null;
|
|
1214
1358
|
/** Cleanup returned by the avatar provider — called on disconnect/re-render. */
|
|
1215
1359
|
_avatarCleanup = null;
|
|
1360
|
+
/** Whether the avatar slot holds what a provider drew, which goes when the provider does. */
|
|
1361
|
+
_avatarFromProvider = false;
|
|
1216
1362
|
/** Sibling count for tree-based branch navigation (set by setSiblings()) */
|
|
1217
1363
|
_siblingCount = 1;
|
|
1364
|
+
/**
|
|
1365
|
+
* Which accessible names the bubble wrote itself, and so may refresh on a language switch
|
|
1366
|
+
* or a name change. A custom shell's own are its author's: refreshed, a root's or a bar's
|
|
1367
|
+
* label of their own became "Assistant response" or "Message actions" at the first notify.
|
|
1368
|
+
*/
|
|
1369
|
+
_wroteRootLabel = false;
|
|
1370
|
+
_wroteBarLabel = false;
|
|
1371
|
+
_wroteArrowLabels = false;
|
|
1372
|
+
/** The shell renderer the bubble was last drawn with (`null`: the default frame). */
|
|
1373
|
+
_drawnShell = null;
|
|
1218
1374
|
/** Sibling index for tree-based branch navigation (set by setSiblings()) */
|
|
1219
1375
|
_siblingIndex = 0;
|
|
1220
1376
|
/** True while the user-message inline editor is open. */
|
|
@@ -1246,7 +1402,29 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1246
1402
|
this._renderAvatar();
|
|
1247
1403
|
this._relabelSegments();
|
|
1248
1404
|
this._updateTimestamp(this.getAttribute("timestamp"));
|
|
1405
|
+
if (!this._redrawQueued) {
|
|
1406
|
+
this._redrawQueued = true;
|
|
1407
|
+
queueMicrotask(() => {
|
|
1408
|
+
this._redrawQueued = false;
|
|
1409
|
+
if (this.isConnected) this._redrawIfStale();
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1249
1412
|
};
|
|
1413
|
+
/** A {@link _redrawIfStale} is queued for the end of the current task. */
|
|
1414
|
+
_redrawQueued = false;
|
|
1415
|
+
/** The {@link renderingVersionOf} count the content was last drawn with. */
|
|
1416
|
+
_drawnWithVersion = 0;
|
|
1417
|
+
/**
|
|
1418
|
+
* A Markdown or highlight provider set after this message was drawn: draw it again.
|
|
1419
|
+
* Not mid-stream — the stream reads the providers as it goes, and its end redraws — and
|
|
1420
|
+
* not mid-edit, where the drawn message is hidden behind the editor and drawing it would
|
|
1421
|
+
* show it again beside the editor; leaving the edit calls this.
|
|
1422
|
+
*/
|
|
1423
|
+
_redrawIfStale() {
|
|
1424
|
+
if (this._streaming || this._editing || renderingVersionOf(this._cfg) === this._drawnWithVersion) return;
|
|
1425
|
+
if (this._segments.length > 0) this._renderSegments();
|
|
1426
|
+
else this._updateContent();
|
|
1427
|
+
}
|
|
1250
1428
|
/**
|
|
1251
1429
|
* Ask every rendered segment to re-read its config-derived text.
|
|
1252
1430
|
*
|
|
@@ -1284,9 +1462,11 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1284
1462
|
const set = (selector, label) => {
|
|
1285
1463
|
this.querySelector(selector)?.setAttribute("aria-label", label);
|
|
1286
1464
|
};
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1465
|
+
if (this._wroteArrowLabels) {
|
|
1466
|
+
set(".aparte-branch-prev", locale.previousResponse ?? "Previous response");
|
|
1467
|
+
set(".aparte-branch-next", locale.nextResponse ?? "Next response");
|
|
1468
|
+
}
|
|
1469
|
+
if (this._wroteBarLabel) set(".aparte-action-bar", locale.messageActions ?? "Message actions");
|
|
1290
1470
|
}
|
|
1291
1471
|
/**
|
|
1292
1472
|
* Config governing this bubble: the instance config of the nearest
|
|
@@ -1306,6 +1486,40 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1306
1486
|
this.addEventListener("click", this._onBranchPickerClick);
|
|
1307
1487
|
this.addEventListener("click", this._onLinkClick);
|
|
1308
1488
|
}
|
|
1489
|
+
/**
|
|
1490
|
+
* The boundary above this bubble changed — under every wrapper the chat's own config is
|
|
1491
|
+
* attached AFTER the first messages are drawn. When that brings another frame
|
|
1492
|
+
* (`setBubbleShellRenderer`), the bubble is drawn again in it and its content put back,
|
|
1493
|
+
* so every message wears the chat's frame.
|
|
1494
|
+
*
|
|
1495
|
+
* Deferred to a microtask: a wrapper detaches the boundary as it unmounts (Vue's
|
|
1496
|
+
* `onBeforeUnmount`, Svelte's `onDestroy`), while the bubbles are still in the page, and
|
|
1497
|
+
* redrawing every one of them just before their removal would cost a frame per message.
|
|
1498
|
+
* After the microtask a removed bubble is no longer connected, and repeated notices fold
|
|
1499
|
+
* into one.
|
|
1500
|
+
*/
|
|
1501
|
+
aparteConfigChanged() {
|
|
1502
|
+
queueMicrotask(() => this._redrawFrameIfChanged());
|
|
1503
|
+
}
|
|
1504
|
+
_redrawFrameIfChanged() {
|
|
1505
|
+
if (!this.isConnected || this._editing) return;
|
|
1506
|
+
if ((this._cfg.getBubbleShellRenderer?.() ?? null) === this._drawnShell) return;
|
|
1507
|
+
if (this._avatarCleanup) {
|
|
1508
|
+
try {
|
|
1509
|
+
this._avatarCleanup();
|
|
1510
|
+
} catch {
|
|
1511
|
+
}
|
|
1512
|
+
this._avatarCleanup = null;
|
|
1513
|
+
}
|
|
1514
|
+
this._resetMarkdownStream();
|
|
1515
|
+
this.replaceChildren();
|
|
1516
|
+
this._render();
|
|
1517
|
+
this._updateContent();
|
|
1518
|
+
if (this._segments.length > 0) this._renderSegments();
|
|
1519
|
+
this._updateAttachments();
|
|
1520
|
+
this._updateTimestamp(this.getAttribute("timestamp"));
|
|
1521
|
+
this._updateBranchPicker();
|
|
1522
|
+
}
|
|
1309
1523
|
disconnectedCallback() {
|
|
1310
1524
|
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
1311
1525
|
this.removeEventListener("click", this._onBranchPickerClick);
|
|
@@ -1418,11 +1632,11 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1418
1632
|
* query returns the first match in document order — and sanitized model
|
|
1419
1633
|
* markdown renders inside that same container, with `data-*` attributes
|
|
1420
1634
|
* deliberately preserved (they are inert). So a decoy `data-segment-id` planted
|
|
1421
|
-
* in an earlier segment's prose
|
|
1635
|
+
* in an earlier segment's prose would win over the real segment element.
|
|
1422
1636
|
*
|
|
1423
1637
|
* Parser ids are unguessable UUIDs, but a tool segment is `tool-${toolCallId}`
|
|
1424
|
-
* and the MODEL chooses that id — so
|
|
1425
|
-
*
|
|
1638
|
+
* and the MODEL chooses that id — so a decoy could point an update at the wrong
|
|
1639
|
+
* element and leave a rejected tool rendering as still-running: a spoof against
|
|
1426
1640
|
* the human-in-the-loop control.
|
|
1427
1641
|
*/
|
|
1428
1642
|
removeSegment(segmentId) {
|
|
@@ -1502,6 +1716,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1502
1716
|
console.warn(`[AparteChatBubble] _appendSegmentEl ABORT: _segmentsEl is null`);
|
|
1503
1717
|
return;
|
|
1504
1718
|
}
|
|
1719
|
+
if (this._segments.length === 1) this._drawnWithVersion = renderingVersionOf(this._cfg);
|
|
1505
1720
|
const renderer = resolveSegmentRenderer(segment.type, this._cfg);
|
|
1506
1721
|
if (renderer) {
|
|
1507
1722
|
const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
|
|
@@ -1564,6 +1779,9 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1564
1779
|
const displayName = this._getDisplayName();
|
|
1565
1780
|
const initial = this._getAvatarInitial();
|
|
1566
1781
|
const shell = this._cfg.getBubbleShellRenderer?.();
|
|
1782
|
+
this._drawnShell = shell ?? null;
|
|
1783
|
+
this._avatarFromProvider = false;
|
|
1784
|
+
this._wroteRootLabel = this._wroteBarLabel = this._wroteArrowLabels = !shell;
|
|
1567
1785
|
if (shell) {
|
|
1568
1786
|
const out = runWithConfig(this._cfg, () => shell({ role: this._role, name: displayName, avatarInitial: initial }));
|
|
1569
1787
|
if (out instanceof HTMLElement) this.replaceChildren(out);
|
|
@@ -1609,11 +1827,39 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1609
1827
|
if (messageEl && !messageEl.hasAttribute("role")) {
|
|
1610
1828
|
messageEl.setAttribute("role", "article");
|
|
1611
1829
|
messageEl.setAttribute("aria-label", this._getAriaLabel());
|
|
1830
|
+
this._wroteRootLabel = true;
|
|
1612
1831
|
}
|
|
1613
1832
|
this._contentEl = this.querySelector(".aparte-content");
|
|
1614
1833
|
this._segmentsEl = this.querySelector(".aparte-segments");
|
|
1615
1834
|
this._attachmentsEl = this.querySelector(".aparte-attachments");
|
|
1616
1835
|
this._actionBarEl = this.querySelector(".aparte-action-bar");
|
|
1836
|
+
if (this._actionBarEl && !this._actionBarEl.hasAttribute("role")) {
|
|
1837
|
+
this._actionBarEl.setAttribute("role", "toolbar");
|
|
1838
|
+
if (!this._actionBarEl.hasAttribute("aria-label")) {
|
|
1839
|
+
this._actionBarEl.setAttribute("aria-label", this._cfg.getLocale().messageActions ?? "Message actions");
|
|
1840
|
+
this._wroteBarLabel = true;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (shell) {
|
|
1844
|
+
const prev = this.querySelector(".aparte-branch-prev");
|
|
1845
|
+
const next = this.querySelector(".aparte-branch-next");
|
|
1846
|
+
const audible = (arrow) => {
|
|
1847
|
+
let text = "";
|
|
1848
|
+
const walker = arrow.ownerDocument.createTreeWalker(arrow, NodeFilter.SHOW_TEXT);
|
|
1849
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
1850
|
+
const hidden = node.parentElement?.closest('[aria-hidden="true"], [hidden]');
|
|
1851
|
+
if (!hidden || !arrow.contains(hidden)) text += node.textContent ?? "";
|
|
1852
|
+
}
|
|
1853
|
+
return text;
|
|
1854
|
+
};
|
|
1855
|
+
const named = (arrow) => !!arrow && (arrow.hasAttribute("aria-label") || arrow.hasAttribute("aria-labelledby") || arrow.hasAttribute("title") || /\p{L}/u.test(audible(arrow)));
|
|
1856
|
+
if ((prev || next) && !named(prev) && !named(next)) {
|
|
1857
|
+
const locale = this._cfg.getLocale();
|
|
1858
|
+
prev?.setAttribute("aria-label", locale.previousResponse ?? "Previous response");
|
|
1859
|
+
next?.setAttribute("aria-label", locale.nextResponse ?? "Next response");
|
|
1860
|
+
this._wroteArrowLabels = true;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1617
1863
|
this._actionBarEl?.addEventListener("keydown", this._onActionBarKeydown);
|
|
1618
1864
|
this._branchPickerEl = this.querySelector(".aparte-branch-picker");
|
|
1619
1865
|
this._footerEl = this.querySelector(".aparte-footer");
|
|
@@ -1625,8 +1871,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1625
1871
|
}
|
|
1626
1872
|
/**
|
|
1627
1873
|
* Show the built-in waiting indicator while this bubble is in flight and has
|
|
1628
|
-
* nothing to show yet — the gap between "user sends" and the first token
|
|
1629
|
-
* used to be a bubble with a name and an empty body.
|
|
1874
|
+
* nothing to show yet — the gap between "user sends" and the first token.
|
|
1630
1875
|
*
|
|
1631
1876
|
* The dots are CSS (no per-token work, themable, honours reduced-motion); the
|
|
1632
1877
|
* accessible name is `locale.typing`, next to the `aria-busy` the streaming state
|
|
@@ -1653,11 +1898,10 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1653
1898
|
/**
|
|
1654
1899
|
* Hand the avatar host element off to the registered AvatarProvider, if any.
|
|
1655
1900
|
*
|
|
1656
|
-
* With no provider the slot
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
* one.
|
|
1901
|
+
* With no provider the slot keeps what the shell rendered — for the default shell,
|
|
1902
|
+
* nothing, hidden while empty — and loses what a provider drew there before. An empty
|
|
1903
|
+
* slot still shows when the stylesheet names a picture for its role
|
|
1904
|
+
* (`--aparte-avatar-image-user` / `-assistant`): see `_markAvatarImage`.
|
|
1661
1905
|
*/
|
|
1662
1906
|
_renderAvatar() {
|
|
1663
1907
|
const avatar = this.querySelector(".aparte-avatar");
|
|
@@ -1670,10 +1914,34 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1670
1914
|
this._avatarCleanup = null;
|
|
1671
1915
|
}
|
|
1672
1916
|
const provider = this._cfg.getAvatarProvider();
|
|
1673
|
-
if (!provider)
|
|
1917
|
+
if (!provider) {
|
|
1918
|
+
if (this._avatarFromProvider) {
|
|
1919
|
+
avatar.replaceChildren();
|
|
1920
|
+
this._avatarFromProvider = false;
|
|
1921
|
+
}
|
|
1922
|
+
this._markAvatarImage(avatar);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1674
1925
|
avatar.textContent = "";
|
|
1675
1926
|
const cleanup = provider.render(this._role, avatar);
|
|
1927
|
+
this._avatarFromProvider = true;
|
|
1676
1928
|
if (typeof cleanup === "function") this._avatarCleanup = cleanup;
|
|
1929
|
+
this._markAvatarImage(avatar);
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* A picture from the stylesheet alone: an empty slot whose role's `--aparte-avatar-image-*`
|
|
1933
|
+
* names an image gets `data-image`, which lifts the rule that hides an empty slot. Hidden
|
|
1934
|
+
* while empty, the token painted nothing. Read when the message is drawn and on every
|
|
1935
|
+
* config change, in a batch with the other bubbles' (see `pendingAvatarMarks`).
|
|
1936
|
+
*/
|
|
1937
|
+
_markAvatarImage(avatar) {
|
|
1938
|
+
if (avatar.firstChild) {
|
|
1939
|
+
pendingAvatarMarks.delete(avatar);
|
|
1940
|
+
avatar.removeAttribute("data-image");
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
if (pendingAvatarMarks.size === 0) queueMicrotask(flushAvatarMarks);
|
|
1944
|
+
pendingAvatarMarks.set(avatar, { bubble: this, role: this._role });
|
|
1677
1945
|
}
|
|
1678
1946
|
_updateRole() {
|
|
1679
1947
|
const message = this.querySelector(".aparte-message");
|
|
@@ -1681,13 +1949,13 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1681
1949
|
const nameEl = this.querySelector(".aparte-name");
|
|
1682
1950
|
if (message) {
|
|
1683
1951
|
message.setAttribute("data-role", this._role);
|
|
1684
|
-
if (message.getAttribute("role") === "article") {
|
|
1952
|
+
if (this._wroteRootLabel && message.getAttribute("role") === "article") {
|
|
1685
1953
|
message.setAttribute("aria-label", this._getAriaLabel());
|
|
1686
1954
|
}
|
|
1687
1955
|
}
|
|
1688
1956
|
if (avatar) {
|
|
1689
1957
|
avatar.setAttribute("data-role", this._role);
|
|
1690
|
-
if (avatar.textContent) avatar.textContent = this._getAvatarInitial();
|
|
1958
|
+
if (avatar.textContent && !this._avatarFromProvider) avatar.textContent = this._getAvatarInitial();
|
|
1691
1959
|
}
|
|
1692
1960
|
if (nameEl) {
|
|
1693
1961
|
nameEl.textContent = this._getDisplayName();
|
|
@@ -1698,10 +1966,12 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1698
1966
|
_updateName() {
|
|
1699
1967
|
const avatar = this.querySelector(".aparte-avatar");
|
|
1700
1968
|
const nameEl = this.querySelector(".aparte-name");
|
|
1701
|
-
if (avatar && avatar.textContent && !this._cfg.getAvatarProvider()) {
|
|
1969
|
+
if (avatar && avatar.textContent && !this._cfg.getAvatarProvider() && !this._avatarFromProvider) {
|
|
1702
1970
|
avatar.textContent = this._getAvatarInitial();
|
|
1703
1971
|
}
|
|
1704
1972
|
if (nameEl) nameEl.textContent = this._getDisplayName();
|
|
1973
|
+
const message = this.querySelector(".aparte-message");
|
|
1974
|
+
if (this._wroteRootLabel && message?.getAttribute("role") === "article") message.setAttribute("aria-label", this._getAriaLabel());
|
|
1705
1975
|
}
|
|
1706
1976
|
_updateContent() {
|
|
1707
1977
|
if (!this._contentEl) return;
|
|
@@ -1710,6 +1980,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1710
1980
|
this._updateWaiting();
|
|
1711
1981
|
return;
|
|
1712
1982
|
}
|
|
1983
|
+
this._drawnWithVersion = renderingVersionOf(this._cfg);
|
|
1713
1984
|
this._contentEl.style.display = "";
|
|
1714
1985
|
runWithConfig(
|
|
1715
1986
|
this._cfg,
|
|
@@ -1747,6 +2018,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1747
2018
|
}
|
|
1748
2019
|
_renderSegments() {
|
|
1749
2020
|
if (!this._segmentsEl) return;
|
|
2021
|
+
this._drawnWithVersion = renderingVersionOf(this._cfg);
|
|
1750
2022
|
this._segmentsEl.innerHTML = "";
|
|
1751
2023
|
for (const segment of this._segments) {
|
|
1752
2024
|
const renderer = resolveSegmentRenderer(segment.type, this._cfg);
|
|
@@ -1793,8 +2065,16 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1793
2065
|
timestampEl.textContent = "";
|
|
1794
2066
|
}
|
|
1795
2067
|
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Named after its author when it has one: a `name` on the bubble is who wrote it (a human
|
|
2070
|
+
* agent answering on the assistant's side, a second assistant), and naming every one of
|
|
2071
|
+
* them "Assistant response" told a screen-reader user the opposite of what the page shows.
|
|
2072
|
+
* Without a name, the role names it.
|
|
2073
|
+
*/
|
|
1796
2074
|
_getAriaLabel() {
|
|
1797
2075
|
const locale = this._cfg.getLocale();
|
|
2076
|
+
const name = this.getAttribute("name");
|
|
2077
|
+
if (name) return (locale.messageFrom || "Message from {name}").replace("{name}", () => name);
|
|
1798
2078
|
return this._role === "user" ? locale.yourMessage ?? "Your message" : locale.assistantResponse ?? "Assistant response";
|
|
1799
2079
|
}
|
|
1800
2080
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -1852,9 +2132,9 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1852
2132
|
*
|
|
1853
2133
|
* An attachment's `url` is documented as "URL or data URI" and a storage adapter
|
|
1854
2134
|
* re-mints it when a conversation is restored, so it is the app's value, not the
|
|
1855
|
-
* composer's —
|
|
1856
|
-
*
|
|
1857
|
-
*
|
|
2135
|
+
* composer's — and, like every other URL core emits, it is asked whether its SCHEME
|
|
2136
|
+
* is allowed at all. A picture whose URL is refused is not half-rendered: it falls
|
|
2137
|
+
* back to the file chip, which still names the file.
|
|
1858
2138
|
*
|
|
1859
2139
|
* `blob:` is added because it is the shape core itself mints — `filesToAttachments`
|
|
1860
2140
|
* calls `URL.createObjectURL`, and `AparteMessage.attachments` documents the same
|
|
@@ -1875,21 +2155,12 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1875
2155
|
/**
|
|
1876
2156
|
* The branch arrows are handled by DELEGATION, on this element, bound once.
|
|
1877
2157
|
*
|
|
1878
|
-
*
|
|
1879
|
-
*
|
|
1880
|
-
*
|
|
1881
|
-
* all — not late, nothing. Invisible on a fast machine; reproducible on
|
|
1882
|
-
* WebKit-Linux in CI, where `‹` left the picker on "2 / 2" and a 20-second
|
|
1883
|
-
* assertion watched it stay there.
|
|
1884
|
-
*
|
|
1885
|
-
* Delegation makes `_render()` irrelevant to it: the listener lives on the host,
|
|
1886
|
-
* which is never replaced, and `closest()` finds whichever button exists at the
|
|
1887
|
-
* moment of the click. It is also less work — one listener per bubble instead of
|
|
1888
|
-
* two per bubble per render.
|
|
2158
|
+
* The listener lives on the host, which is never replaced, and `closest()` finds
|
|
2159
|
+
* whichever button exists at the moment of the click — so a click that lands while
|
|
2160
|
+
* a re-render is swapping the buttons is not lost.
|
|
1889
2161
|
*
|
|
1890
2162
|
* Bound in `connectedCallback` and removed in `disconnectedCallback` as a stable
|
|
1891
|
-
* field,
|
|
1892
|
-
* stacked listeners twice before (the viewport, and `aparte-select`).
|
|
2163
|
+
* field, so a re-connect never stacks listeners.
|
|
1893
2164
|
*/
|
|
1894
2165
|
_onBranchPickerClick = (event) => {
|
|
1895
2166
|
const target = event.target;
|
|
@@ -1908,7 +2179,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1908
2179
|
/**
|
|
1909
2180
|
* A link the MODEL wrote is about to be followed. Announced as a cancelable event
|
|
1910
2181
|
* before the browser acts, so a host can route it — an external browser, a
|
|
1911
|
-
* confirmation, an embedded view — without intercepting the DOM
|
|
2182
|
+
* confirmation, an embedded view — without intercepting the DOM. With
|
|
1912
2183
|
* no listener the browser follows it, which the sanitizer has already made open in
|
|
1913
2184
|
* a new tab for an external URL; `preventDefault()` on the event cancels the click.
|
|
1914
2185
|
*
|
|
@@ -2037,7 +2308,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2037
2308
|
}
|
|
2038
2309
|
this._actionBarEl.innerHTML = buttons.join("");
|
|
2039
2310
|
this._syncCopyAction();
|
|
2040
|
-
this._appendCustomActions(
|
|
2311
|
+
this._appendCustomActions();
|
|
2041
2312
|
this._applyTranscriptBusy();
|
|
2042
2313
|
this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
|
|
2043
2314
|
btn.addEventListener("click", (e) => this._handleActionClick(e));
|
|
@@ -2047,18 +2318,14 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2047
2318
|
/**
|
|
2048
2319
|
* `role="toolbar"` promises a keyboard model. This is it.
|
|
2049
2320
|
*
|
|
2050
|
-
*
|
|
2051
|
-
*
|
|
2052
|
-
*
|
|
2053
|
-
* stops per message, so the role described a behaviour that did not exist: a reader
|
|
2054
|
-
* told to press Right heard nothing move, and tabbing through a long transcript
|
|
2055
|
-
* walked every button of every message.
|
|
2321
|
+
* A toolbar is ONE tab stop whose members are reached with the arrow keys — that is
|
|
2322
|
+
* the whole of what the role means to a screen-reader user. Without it, tabbing
|
|
2323
|
+
* through a long transcript would walk every button of every message.
|
|
2056
2324
|
*
|
|
2057
2325
|
* The roving index is re-applied from `_syncFooterVisibility`, which is the single
|
|
2058
|
-
* funnel all three build paths
|
|
2059
|
-
*
|
|
2060
|
-
*
|
|
2061
|
-
* rebuild — the drift this pattern usually dies of.
|
|
2326
|
+
* funnel all three build paths run through (the flag-driven bar, the edit bar, and
|
|
2327
|
+
* the branch picker's own visibility pass). Re-deriving it there rather than in each
|
|
2328
|
+
* builder keeps the invariant true after a `setBubbleActions` rebuild.
|
|
2062
2329
|
*
|
|
2063
2330
|
* DISABLED buttons are skipped, not merely un-tabbable: while the transcript is busy
|
|
2064
2331
|
* retry and edit are disabled, and a toolbar whose arrows stop on a dead control
|
|
@@ -2106,9 +2373,9 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2106
2373
|
buttons[next]?.focus();
|
|
2107
2374
|
};
|
|
2108
2375
|
/**
|
|
2109
|
-
* An empty action bar is not a bar: with every action off it
|
|
2110
|
-
* `role="toolbar"` with nothing in it (announced as such)
|
|
2111
|
-
*
|
|
2376
|
+
* An empty action bar is not a bar: with every action off it would be a
|
|
2377
|
+
* `role="toolbar"` with nothing in it (announced as such) that still reserves its
|
|
2378
|
+
* fixed height plus the footer's under every bubble. So both follow their
|
|
2112
2379
|
* contents — the footer stays as long as the branch picker or the bar has
|
|
2113
2380
|
* something to show.
|
|
2114
2381
|
*/
|
|
@@ -2122,7 +2389,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2122
2389
|
this.querySelector(".aparte-message")?.toggleAttribute("data-branches", !pickerHidden);
|
|
2123
2390
|
}
|
|
2124
2391
|
/** Append the registered custom action buttons for this bubble's role. */
|
|
2125
|
-
_appendCustomActions(
|
|
2392
|
+
_appendCustomActions() {
|
|
2126
2393
|
if (!this._actionBarEl) return;
|
|
2127
2394
|
for (const a2 of this._cfg.getActions("bubble")) {
|
|
2128
2395
|
const roles = a2.bubble?.roles ?? ["user", "assistant"];
|
|
@@ -2133,8 +2400,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2133
2400
|
btn.dataset["action"] = `custom:${a2.id}`;
|
|
2134
2401
|
btn.setAttribute("aria-label", a2.label);
|
|
2135
2402
|
btn.setAttribute("title", a2.label);
|
|
2136
|
-
|
|
2137
|
-
btn.innerHTML = a2.icon.startsWith("<") ? a2.icon : typeof fromProvider === "function" ? fromProvider() : a2.iconFallback ?? "";
|
|
2403
|
+
btn.innerHTML = a2.icon.startsWith("<") ? a2.icon : this._cfg.getIcon(a2.icon) ?? a2.iconFallback ?? "";
|
|
2138
2404
|
this._actionBarEl.appendChild(btn);
|
|
2139
2405
|
}
|
|
2140
2406
|
}
|
|
@@ -2323,6 +2589,8 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2323
2589
|
if (this._contentEl) this._contentEl.style.display = "";
|
|
2324
2590
|
this.querySelector(".aparte-message")?.removeAttribute("data-editing");
|
|
2325
2591
|
this._editing = false;
|
|
2592
|
+
this._redrawFrameIfChanged();
|
|
2593
|
+
this._redrawIfStale();
|
|
2326
2594
|
this._updateActionBar();
|
|
2327
2595
|
const returnTo = this._editReturnAction;
|
|
2328
2596
|
this._editReturnAction = null;
|
|
@@ -2379,6 +2647,9 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2379
2647
|
}
|
|
2380
2648
|
this._updateWaiting();
|
|
2381
2649
|
if (wasStreaming && !streaming) this._updateContent();
|
|
2650
|
+
if (wasStreaming && !streaming && this._segments.length > 0 && renderingVersionOf(this._cfg) !== this._drawnWithVersion) {
|
|
2651
|
+
this._renderSegments();
|
|
2652
|
+
}
|
|
2382
2653
|
}
|
|
2383
2654
|
}
|
|
2384
2655
|
if (!customElements.get("aparte-chat-bubble")) {
|
|
@@ -2398,6 +2669,10 @@ class AparteChatStatus extends HTMLElement {
|
|
|
2398
2669
|
get _cfg() {
|
|
2399
2670
|
return resolveConfig(this);
|
|
2400
2671
|
}
|
|
2672
|
+
/** The word said when no `text` is set: the locale's `typing`, never an English literal. */
|
|
2673
|
+
_word() {
|
|
2674
|
+
return this._cfg.t("typing");
|
|
2675
|
+
}
|
|
2401
2676
|
constructor() {
|
|
2402
2677
|
super();
|
|
2403
2678
|
}
|
|
@@ -2454,7 +2729,7 @@ class AparteChatStatus extends HTMLElement {
|
|
|
2454
2729
|
return this.hasAttribute("visible");
|
|
2455
2730
|
}
|
|
2456
2731
|
_render() {
|
|
2457
|
-
const text = this.getAttribute("text") ||
|
|
2732
|
+
const text = this.getAttribute("text") || this._word();
|
|
2458
2733
|
const visible = this.hasAttribute("visible");
|
|
2459
2734
|
if (this.querySelector(".aparte-status-container")) return;
|
|
2460
2735
|
const custom = this._cfg?.getStatusRenderer?.();
|
|
@@ -2525,7 +2800,7 @@ class AparteChatStatus extends HTMLElement {
|
|
|
2525
2800
|
const srEl = this.querySelector(".aparte-status-sr");
|
|
2526
2801
|
if (!srEl) return;
|
|
2527
2802
|
const dotsOnly = !this.getAttribute("text");
|
|
2528
|
-
srEl.textContent = dotsOnly && this.hasAttribute("visible") ?
|
|
2803
|
+
srEl.textContent = dotsOnly && this.hasAttribute("visible") ? this._word() : "";
|
|
2529
2804
|
}
|
|
2530
2805
|
_updateVisibility(visible) {
|
|
2531
2806
|
const container = this.querySelector(".aparte-status-container");
|
|
@@ -2537,8 +2812,17 @@ class AparteChatStatus extends HTMLElement {
|
|
|
2537
2812
|
_updateText(text) {
|
|
2538
2813
|
const container = this.querySelector(".aparte-status-container");
|
|
2539
2814
|
if (!container) return;
|
|
2815
|
+
const custom = this._cfg?.getStatusRenderer?.();
|
|
2816
|
+
if (custom) {
|
|
2817
|
+
const next = text || this._word();
|
|
2818
|
+
container.setAttribute("aria-label", next);
|
|
2819
|
+
const result = runWithConfig(this._cfg, () => custom(next));
|
|
2820
|
+
if (result instanceof HTMLElement) container.replaceChildren(result);
|
|
2821
|
+
else container.innerHTML = result;
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2540
2824
|
this._writeText(text);
|
|
2541
|
-
container.setAttribute("aria-label", text ||
|
|
2825
|
+
container.setAttribute("aria-label", text || this._word());
|
|
2542
2826
|
}
|
|
2543
2827
|
}
|
|
2544
2828
|
if (!customElements.get("aparte-chat-status")) {
|
|
@@ -2596,17 +2880,14 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2596
2880
|
* Not `readonly`: a test shortens it to run past the window's end in a few frames. */
|
|
2597
2881
|
_settleWindowMs = 400;
|
|
2598
2882
|
/**
|
|
2599
|
-
* Until when a smooth scroll of ours is in flight
|
|
2600
|
-
* bottom with `scrollTo({ behavior: 'smooth' })
|
|
2601
|
-
* first tokens
|
|
2602
|
-
*
|
|
2603
|
-
*
|
|
2604
|
-
*
|
|
2605
|
-
*
|
|
2606
|
-
*
|
|
2607
|
-
* (a second smooth `scrollTo` is continuous) instead of writing `scrollTop`; the
|
|
2608
|
-
* browser's `scrollend` closes it early where supported. Streaming is instant again
|
|
2609
|
-
* after that, as it must be to keep up with token bursts.
|
|
2883
|
+
* Until when a smooth scroll of ours is in flight. A user send glides to the
|
|
2884
|
+
* bottom with `scrollTo({ behavior: 'smooth' })`, and the assistant placeholder and
|
|
2885
|
+
* first tokens land inside that glide. An instant `scrollTop = scrollHeight` write
|
|
2886
|
+
* cancels a running smooth scroll in every engine, so while this deadline is ahead
|
|
2887
|
+
* every bottom-pin RE-TARGETS the glide (a second smooth `scrollTo` is continuous)
|
|
2888
|
+
* instead of writing `scrollTop`; the browser's `scrollend` closes it early where
|
|
2889
|
+
* supported. Streaming is instant after that, as it must be to keep up with token
|
|
2890
|
+
* bursts.
|
|
2610
2891
|
*/
|
|
2611
2892
|
_glideUntil = 0;
|
|
2612
2893
|
/** The glide's budget when `scrollend` is not there to close it: Chromium's smooth scroll over a viewport takes ~300–500 ms. */
|
|
@@ -2630,6 +2911,26 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2630
2911
|
_maxRenderedBubbles = 1e3;
|
|
2631
2912
|
_resizeObserver = null;
|
|
2632
2913
|
_mutationObserver = null;
|
|
2914
|
+
/**
|
|
2915
|
+
* Watches the size of the bubbles the bottom spacer measures — the last question and
|
|
2916
|
+
* everything after it. A bubble that has never been on screen measures its
|
|
2917
|
+
* `content-visibility` fallback (320px) until it is shown, and taking its real height
|
|
2918
|
+
* is neither a mutation nor a resize of the scroll container: without this, a spacer
|
|
2919
|
+
* sized from two fallbacks stayed wrong once they came into view.
|
|
2920
|
+
*/
|
|
2921
|
+
_tailObserver = null;
|
|
2922
|
+
_tail = [];
|
|
2923
|
+
/** Each tail bubble's border-box height, as measured when it started being watched and as last reported. */
|
|
2924
|
+
_tailHeights = /* @__PURE__ */ new WeakMap();
|
|
2925
|
+
/**
|
|
2926
|
+
* Opening or closing a `<details>` — a tool row, a reasoning block, an artifact card, a host's
|
|
2927
|
+
* own — changes the transcript's height through an ATTRIBUTE, which neither observer sees: the
|
|
2928
|
+
* mutation observer watches children, and the resize observer leaves the wrapper out on
|
|
2929
|
+
* purpose. `toggle` does not bubble, so it is caught on the way down.
|
|
2930
|
+
*/
|
|
2931
|
+
_onDisclosureToggle = () => {
|
|
2932
|
+
this._scheduleSpacerUpdate();
|
|
2933
|
+
};
|
|
2633
2934
|
/** The `[overlay-composer]` shell this viewport floats in, if any. Read at observer setup, like `framework-managed`. */
|
|
2634
2935
|
_overlayRoot = null;
|
|
2635
2936
|
/** Watches the overlay shell's childList, so a stack child that mounts later (elicitation) gets observed too. */
|
|
@@ -2710,8 +3011,8 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2710
3011
|
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
2711
3012
|
}
|
|
2712
3013
|
/**
|
|
2713
|
-
* A locale switch changes the reading direction, and `dir`
|
|
2714
|
-
*
|
|
3014
|
+
* A locale switch changes the reading direction, and `dir` follows it, so a chat
|
|
3015
|
+
* already on screen flips to RTL without a reload.
|
|
2715
3016
|
* Only OUR config: an instance-scoped change elsewhere must not touch us.
|
|
2716
3017
|
*/
|
|
2717
3018
|
_onConfigChange = (e) => {
|
|
@@ -2728,15 +3029,14 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2728
3029
|
* A scrollable region that no element can hold focus in cannot be scrolled from
|
|
2729
3030
|
* the keyboard in WebKit: Chromium and Firefox hand an unfocusable overflow box a
|
|
2730
3031
|
* caret or a "scroller" tab stop of their own, Safari does not. So a plain-text
|
|
2731
|
-
* transcript — no links, no code blocks, nothing focusable inside —
|
|
2732
|
-
* past the first screen for a Safari keyboard user, with no error and
|
|
2733
|
-
* screen to say so.
|
|
3032
|
+
* transcript — no links, no code blocks, nothing focusable inside — would be
|
|
3033
|
+
* unreadable past the first screen for a Safari keyboard user, with no error and
|
|
3034
|
+
* nothing on screen to say so.
|
|
2734
3035
|
*
|
|
2735
|
-
* `tabindex="0"` on the surface itself, in BOTH DOM modes.
|
|
2736
|
-
*
|
|
2737
|
-
*
|
|
2738
|
-
*
|
|
2739
|
-
* to sit there is not an affordance, it is a coincidence.
|
|
3036
|
+
* `tabindex="0"` on the surface itself, in BOTH DOM modes. In framework mode the
|
|
3037
|
+
* scroll button is inside the host and stays tabbable while it is visually hidden,
|
|
3038
|
+
* but a tab stop that exists because a hidden button happens to sit there is a
|
|
3039
|
+
* coincidence, not an affordance.
|
|
2740
3040
|
*
|
|
2741
3041
|
* The name ships WITH the tab stop rather than after it: a focusable `role="log"`
|
|
2742
3042
|
* with no accessible name is announced as an unnamed region, which is a worse
|
|
@@ -2865,10 +3165,10 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2865
3165
|
* The id of the message currently streaming, if any — searched across the whole
|
|
2866
3166
|
* TREE, and answering with an OFF-HEAD one first when there are two.
|
|
2867
3167
|
*
|
|
2868
|
-
*
|
|
3168
|
+
* Not a scan of `getMessages()`, the active path: a retry or an edit on an
|
|
2869
3169
|
* earlier bubble calls `addSiblingOf`, which switches the branch to the new pending
|
|
2870
|
-
* reply
|
|
2871
|
-
* the rest of that turn
|
|
3170
|
+
* reply, so the turn still being written leaves the path and a path scan would find
|
|
3171
|
+
* nothing, appending the rest of that turn to the message that just replaced it.
|
|
2872
3172
|
*
|
|
2873
3173
|
* A new pending reply is itself marked streaming, so "the first one found" would
|
|
2874
3174
|
* answer the head and let the write through — the very case this exists to refuse.
|
|
@@ -2891,10 +3191,10 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2891
3191
|
/**
|
|
2892
3192
|
* While a reply streams, the transcript is read-only except for Stop.
|
|
2893
3193
|
*
|
|
2894
|
-
* The streaming message's own footer
|
|
2895
|
-
*
|
|
2896
|
-
* re-
|
|
2897
|
-
* reply off to start another
|
|
3194
|
+
* The streaming message's own footer is hidden, and the branch picker and the
|
|
3195
|
+
* retry/edit buttons of every OTHER message are disabled: swapping a branch mid-stream would
|
|
3196
|
+
* re-render the active path under the reply being written, and a retry would cut
|
|
3197
|
+
* that reply off to start another. The state that
|
|
2898
3198
|
* decides it is the whole transcript's, so it lives on the viewport (`data-busy`)
|
|
2899
3199
|
* and is pushed to the bubbles it holds; a bubble mounted later, under a
|
|
2900
3200
|
* framework's DOM, reads the attribute itself when it connects.
|
|
@@ -3349,10 +3649,13 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3349
3649
|
}
|
|
3350
3650
|
}
|
|
3351
3651
|
/**
|
|
3352
|
-
* Enable or disable auto-scroll
|
|
3652
|
+
* Enable or disable auto-scroll. Turned off, it stays off through the scroll that comes
|
|
3653
|
+
* next — a jump of the page's own, smooth or not — and comes back once the reader is at
|
|
3654
|
+
* the bottom again.
|
|
3353
3655
|
*/
|
|
3354
3656
|
setAutoScroll(enabled) {
|
|
3355
3657
|
this._isAutoScrollEnabled = enabled;
|
|
3658
|
+
if (!enabled) this._readerInputAt = performance.now();
|
|
3356
3659
|
}
|
|
3357
3660
|
/**
|
|
3358
3661
|
* Signal that a framework (e.g. Angular) manages the bubble DOM.
|
|
@@ -3701,7 +4004,20 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3701
4004
|
});
|
|
3702
4005
|
}
|
|
3703
4006
|
this._scheduleSpacerUpdate();
|
|
4007
|
+
const movesBubbles = (nodes) => Array.from(nodes).some((n2) => n2 instanceof Element && (n2.tagName === "APARTE-CHAT-BUBBLE" || n2.querySelector("aparte-chat-bubble") !== null));
|
|
4008
|
+
if (mutations.some((m2) => movesBubbles(m2.addedNodes) || movesBubbles(m2.removedNodes))) this._observeTail();
|
|
4009
|
+
});
|
|
4010
|
+
this._tailObserver = new ResizeObserver((entries) => {
|
|
4011
|
+
let changed = false;
|
|
4012
|
+
for (const entry of entries) {
|
|
4013
|
+
const height = Math.round(entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height);
|
|
4014
|
+
if (this._tailHeights.get(entry.target) !== height) changed = true;
|
|
4015
|
+
this._tailHeights.set(entry.target, height);
|
|
4016
|
+
}
|
|
4017
|
+
if (changed) this._scheduleSpacerUpdate();
|
|
3704
4018
|
});
|
|
4019
|
+
this._observeTail();
|
|
4020
|
+
this.addEventListener("toggle", this._onDisclosureToggle, true);
|
|
3705
4021
|
const observeTarget = this._frameworkManagedDOM ? this : wrapper;
|
|
3706
4022
|
if (observeTarget) {
|
|
3707
4023
|
this._mutationObserver.observe(observeTarget, {
|
|
@@ -3753,10 +4069,9 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3753
4069
|
* the transcript's padding plus the scrollbar gutter the scroller reserves on both
|
|
3754
4070
|
* edges, at whatever step its container query has taken — as
|
|
3755
4071
|
* `--aparte-transcript-inset`. The composer pads by it (composer.css), so its box
|
|
3756
|
-
* lands on the row's box at every width. The
|
|
3757
|
-
*
|
|
3758
|
-
*
|
|
3759
|
-
* showed three left edges 12px apart. Written only when the value changes, for the
|
|
4072
|
+
* lands on the row's box at every width. The composer cannot know the gutter
|
|
4073
|
+
* and a container query cannot reach it, so this is the one place both boxes read
|
|
4074
|
+
* it from. Written only when the value changes, for the
|
|
3760
4075
|
* same reason `_updateOverlayInset` is. On the HOST, not on this element: the
|
|
3761
4076
|
* composer is a sibling, and a custom property only travels down.
|
|
3762
4077
|
*/
|
|
@@ -3787,7 +4102,8 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3787
4102
|
const drop = this._lastScrollTop - top;
|
|
3788
4103
|
const churn = Math.abs(height - this._lastScrollHeight);
|
|
3789
4104
|
const now = performance.now();
|
|
3790
|
-
const
|
|
4105
|
+
const shrank = height < this._lastScrollHeight - 1;
|
|
4106
|
+
const afterMutation = now - this._lastMutationAt < this._settleShadowMs && (drop <= this._settleBoundPx || shrank);
|
|
3791
4107
|
const settlingOurs = now - this._ownScrollAt < this._ownScrollWindowMs && now - this._readerInputAt >= this._ownScrollWindowMs && !this._pointerHeld && (drop <= churn + 2 || afterMutation);
|
|
3792
4108
|
const readerWentUp = drop > 1 && !settlingOurs;
|
|
3793
4109
|
const readerHandOnIt = drop > 1 && now - this._readerInputAt < this._ownScrollWindowMs;
|
|
@@ -3830,9 +4146,8 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3830
4146
|
* The browser says a scroll came to rest — the glide is over ONLY if it rested at the
|
|
3831
4147
|
* bottom. WebKit fires `scrollend` when a smooth scroll is REPLACED by another (our own
|
|
3832
4148
|
* re-target), 45–60 ms into the glide and hundreds of pixels short; taken at its word,
|
|
3833
|
-
* that
|
|
3834
|
-
*
|
|
3835
|
-
* bounding the window.
|
|
4149
|
+
* that would close the window and the next pin would be the cut this guard exists
|
|
4150
|
+
* to prevent. Short of the bottom, the budget keeps bounding the window.
|
|
3836
4151
|
*/
|
|
3837
4152
|
_handleScrollEnd() {
|
|
3838
4153
|
if (!this._container) return;
|
|
@@ -3842,37 +4157,27 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3842
4157
|
/**
|
|
3843
4158
|
* Confirm over the next few frames that we actually reached the bottom.
|
|
3844
4159
|
*
|
|
3845
|
-
* One assignment is not enough
|
|
3846
|
-
*
|
|
3847
|
-
*
|
|
3848
|
-
*
|
|
3849
|
-
*
|
|
3850
|
-
*
|
|
3851
|
-
*
|
|
3852
|
-
* is the right rule for keeping auto-follow armed and the wrong one as a
|
|
3853
|
-
* definition of "anchored".
|
|
3854
|
-
*
|
|
3855
|
-
* Ruled out on the way here, so nobody pays for it twice: not a WebKit
|
|
3856
|
-
* padding-accounting difference (a probe writing `scrollTop = 1e7` reached
|
|
3857
|
-
* exactly `scrollHeight - clientHeight`), not a missing `characterData`
|
|
3858
|
-
* mutation, and not a child resize a ResizeObserver could see.
|
|
4160
|
+
* One assignment is not enough. Content can settle in several layout passes
|
|
4161
|
+
* (1118 → 1121 → 1152 px on Safari in framework mode), and `scrollTop =
|
|
4162
|
+
* scrollHeight` run against a middle pass clamps to that layout's max and leaves
|
|
4163
|
+
* the rest unclosed. Auto-follow stays armed the whole time, so the component is
|
|
4164
|
+
* not disarmed — it is SATISFIED: `_isAtBottom()` answers "yes" for any gap under
|
|
4165
|
+
* `_scrollThreshold` (50), which is the right rule for keeping auto-follow armed
|
|
4166
|
+
* and the wrong one as a definition of "anchored".
|
|
3859
4167
|
*
|
|
3860
4168
|
* A BOUNDED retry, not one corrective frame: a single frame lands on the same
|
|
3861
|
-
* stale layout and
|
|
3862
|
-
*
|
|
3863
|
-
*
|
|
4169
|
+
* stale layout and can leave a wider gap than doing nothing. Bounded so it always
|
|
4170
|
+
* terminates; re-reads `_isAutoScrollEnabled` every frame so a reader who scrolls
|
|
4171
|
+
* away mid-settle is left alone.
|
|
3864
4172
|
*
|
|
3865
|
-
* Bounded by TIME, and it does not stop at the first closed gap
|
|
3866
|
-
*
|
|
3867
|
-
*
|
|
3868
|
-
*
|
|
3869
|
-
*
|
|
3870
|
-
*
|
|
3871
|
-
*
|
|
3872
|
-
*
|
|
3873
|
-
* that fails exactly on the slow engine, so the budget is real milliseconds now,
|
|
3874
|
-
* and a closed gap only ends the chain when the window is over. One chain, not
|
|
3875
|
-
* one per call: during a stream every token used to start its own.
|
|
4173
|
+
* Bounded by TIME, and it does not stop at the first closed gap. A branch swap at
|
|
4174
|
+
* the bottom of a long transcript churns the scrollable max (891 -> 1091 -> 891):
|
|
4175
|
+
* the gap is closed for a frame against the tall layout, the height falls back with
|
|
4176
|
+
* WebKit holding `scrollTop`, and the transcript would stand short with auto-follow
|
|
4177
|
+
* still armed. A frame count is a proxy for time that fails exactly on the slow
|
|
4178
|
+
* engine, so the budget is real milliseconds, and a closed gap only ends the chain
|
|
4179
|
+
* when the window is over. One chain, not one per call: during a stream every token
|
|
4180
|
+
* would otherwise start its own.
|
|
3876
4181
|
*/
|
|
3877
4182
|
_settleAtBottom() {
|
|
3878
4183
|
this._settleUntil = performance.now() + this._settleWindowMs;
|
|
@@ -3904,6 +4209,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3904
4209
|
this._glideSettleTimer = null;
|
|
3905
4210
|
if (!this._container || !this._isAutoScrollEnabled || this._gliding()) return;
|
|
3906
4211
|
const max = this._container.scrollHeight - this._container.clientHeight;
|
|
4212
|
+
if (this._container.scrollTop < this._lastScrollTop - 1) return;
|
|
3907
4213
|
if (max - this._container.scrollTop > 1) this._scrollToBottom();
|
|
3908
4214
|
}, this._glideWindowMs + 16);
|
|
3909
4215
|
} else {
|
|
@@ -3919,10 +4225,10 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3919
4225
|
*
|
|
3920
4226
|
* The two answer different questions: the flag is intent ("should new content
|
|
3921
4227
|
* pull the view down"), the button is a fact ("is there anything below the
|
|
3922
|
-
* fold"). Mirroring the flag
|
|
4228
|
+
* fold"). Mirroring the flag would make the button lie whenever the two diverge —
|
|
3923
4229
|
* `navigateBranch` deliberately disarms auto-follow, so swapping a branch while
|
|
3924
|
-
* already at the bottom of a scrollable transcript
|
|
3925
|
-
* scroll nowhere
|
|
4230
|
+
* already at the bottom of a scrollable transcript would leave the button offering
|
|
4231
|
+
* to scroll nowhere. Re-derived on scroll, on the
|
|
3926
4232
|
* post-mutation frame and after a path swap, so it converges to the truth
|
|
3927
4233
|
* whatever a framework's render timing does in between.
|
|
3928
4234
|
*/
|
|
@@ -3975,13 +4281,29 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3975
4281
|
return;
|
|
3976
4282
|
}
|
|
3977
4283
|
const contentBelowUserTop = scrollHeightWithoutSpacer - userTopInContent;
|
|
3978
|
-
const
|
|
4284
|
+
const covered = parseFloat(getComputedStyle(this._container).scrollPaddingTop) || 0;
|
|
4285
|
+
const needed = this._container.clientHeight - covered - contentBelowUserTop;
|
|
3979
4286
|
const maxSpacer = this._container.clientHeight;
|
|
3980
4287
|
this._setSpacerHeight(Math.min(Math.max(0, needed), maxSpacer));
|
|
3981
4288
|
if (this._isAutoScrollEnabled) {
|
|
3982
4289
|
this._scrollToBottom();
|
|
3983
4290
|
}
|
|
3984
4291
|
}
|
|
4292
|
+
/** Point `_tailObserver` at the last question and the bubbles after it, and only those. */
|
|
4293
|
+
_observeTail() {
|
|
4294
|
+
if (!this._tailObserver) return;
|
|
4295
|
+
const bubbles = Array.from(this.querySelectorAll("aparte-chat-bubble"));
|
|
4296
|
+
let from = bubbles.length;
|
|
4297
|
+
while (from > 0 && bubbles[from - 1].getAttribute("data-role") !== "user") from--;
|
|
4298
|
+
const tail = from > 0 ? bubbles.slice(from - 1) : [];
|
|
4299
|
+
for (const el2 of this._tail) if (!tail.includes(el2)) this._tailObserver.unobserve(el2);
|
|
4300
|
+
for (const el2 of tail) {
|
|
4301
|
+
if (this._tail.includes(el2)) continue;
|
|
4302
|
+
this._tailHeights.set(el2, Math.round(el2.offsetHeight));
|
|
4303
|
+
this._tailObserver.observe(el2, { box: "border-box" });
|
|
4304
|
+
}
|
|
4305
|
+
this._tail = tail;
|
|
4306
|
+
}
|
|
3985
4307
|
/**
|
|
3986
4308
|
* Schedule a spacer recalculation on the next animation frame.
|
|
3987
4309
|
* Batches multiple rapid calls (e.g. during token streaming) into one.
|
|
@@ -4037,6 +4359,10 @@ class AparteChatViewport extends HTMLElement {
|
|
|
4037
4359
|
this.removeEventListener("aparte-branch-navigate", this._onBranchNavigate);
|
|
4038
4360
|
this._resizeObserver?.disconnect();
|
|
4039
4361
|
this._mutationObserver?.disconnect();
|
|
4362
|
+
this._tailObserver?.disconnect();
|
|
4363
|
+
this._tailObserver = null;
|
|
4364
|
+
this._tail = [];
|
|
4365
|
+
this.removeEventListener("toggle", this._onDisclosureToggle, true);
|
|
4040
4366
|
this._overlayObserver?.disconnect();
|
|
4041
4367
|
this._overlayObserver = null;
|
|
4042
4368
|
this._overlayRoot = null;
|
|
@@ -4071,14 +4397,11 @@ class AparteComposer extends HTMLElement {
|
|
|
4071
4397
|
/**
|
|
4072
4398
|
* Who owns the one panel slot, and how to tell them they lost it.
|
|
4073
4399
|
*
|
|
4074
|
-
* There is exactly one slot
|
|
4075
|
-
*
|
|
4076
|
-
*
|
|
4077
|
-
*
|
|
4078
|
-
*
|
|
4079
|
-
* turn finished lost its panel while `<aparte-elicitation>` kept `_pending` set,
|
|
4080
|
-
* so the request never settled AND every later request was short-circuited for the
|
|
4081
|
-
* life of the page.
|
|
4400
|
+
* There is exactly one slot and `showPanel` empties it unconditionally, so every
|
|
4401
|
+
* path that closes a panel whose owner is still awaiting an answer — a second
|
|
4402
|
+
* `showPanel`, `hidePanel` without the owner's token, `_handleMessageDone` (which
|
|
4403
|
+
* fires on EVERY turn end) — must tell the owner. An owner that is not told keeps
|
|
4404
|
+
* its request pending, and every later request is short-circuited behind it.
|
|
4082
4405
|
*/
|
|
4083
4406
|
_panelToken = null;
|
|
4084
4407
|
_panelOnEvict = null;
|
|
@@ -4101,10 +4424,10 @@ class AparteComposer extends HTMLElement {
|
|
|
4101
4424
|
*
|
|
4102
4425
|
* A wrapper runs `AparteChatHost.bind()` — which calls `attachConfig` — from its
|
|
4103
4426
|
* POST-mount hook, so this element connects BEFORE the boundary exists. Caching
|
|
4104
|
-
*
|
|
4105
|
-
* locale
|
|
4106
|
-
* `requireModelSelection` gate
|
|
4107
|
-
*
|
|
4427
|
+
* would latch the global config forever: an instance `config` carrying an RTL
|
|
4428
|
+
* locale would flip the transcript and not the composer, and the
|
|
4429
|
+
* `requireModelSelection` gate would be read off the wrong object.
|
|
4430
|
+
* `aparte-chat-bubble` resolves live too, and its JSDoc names this race.
|
|
4108
4431
|
*/
|
|
4109
4432
|
get _cfg() {
|
|
4110
4433
|
return resolveConfig(this);
|
|
@@ -4125,10 +4448,10 @@ class AparteComposer extends HTMLElement {
|
|
|
4125
4448
|
* Mirror `locale.direction` onto ourselves, so everything inside — input, buttons,
|
|
4126
4449
|
* the toolbar row and whatever the consumer put in it — inherits it.
|
|
4127
4450
|
*
|
|
4128
|
-
*
|
|
4129
|
-
*
|
|
4130
|
-
*
|
|
4131
|
-
*
|
|
4451
|
+
* Without it an RTL locale flips the conversation and leaves the composer
|
|
4452
|
+
* left-to-right. It is also what makes the toolbar's placement idiom real:
|
|
4453
|
+
* `margin-inline-start: auto` in a subtree that inherits no direction is just
|
|
4454
|
+
* `margin-left`.
|
|
4132
4455
|
*
|
|
4133
4456
|
* One attribute on the host rather than a stamp per child: inheritance is the
|
|
4134
4457
|
* mechanism, so nothing needs to know about the consumer's markup.
|
|
@@ -4182,9 +4505,9 @@ class AparteComposer extends HTMLElement {
|
|
|
4182
4505
|
/**
|
|
4183
4506
|
* The attribute is the state, so the property writes it. It has to exist: React 19
|
|
4184
4507
|
* and Svelte assign the PROPERTY whenever the element has one of that name, and a
|
|
4185
|
-
* getter alone
|
|
4186
|
-
* as valid — throw and take the render down. `presenceOn` because `''` is what
|
|
4187
|
-
* those templates hand a presence property
|
|
4508
|
+
* getter alone would make `<aparte-composer disabled="">` — a spelling the wrappers
|
|
4509
|
+
* type as valid — throw and take the render down. `presenceOn` because `''` is what
|
|
4510
|
+
* those templates hand a presence property.
|
|
4188
4511
|
*/
|
|
4189
4512
|
set disabled(value) {
|
|
4190
4513
|
this.toggleAttribute("disabled", presenceOn(value));
|
|
@@ -4270,27 +4593,23 @@ class AparteComposer extends HTMLElement {
|
|
|
4270
4593
|
*
|
|
4271
4594
|
* While a panel is up, the composer is answering a QUESTION, not composing a
|
|
4272
4595
|
* message — so the affordances that lead nowhere go away with the text input:
|
|
4273
|
-
* the attachment picker above all, which
|
|
4274
|
-
* being asked something
|
|
4275
|
-
* real session). Ratified decision #8: an affordance nothing can honour is not
|
|
4276
|
-
* rendered.
|
|
4596
|
+
* the attachment picker above all, which would otherwise stay clickable while the
|
|
4597
|
+
* user is being asked something. An affordance nothing can honour is not rendered.
|
|
4277
4598
|
*
|
|
4278
4599
|
* What STAYS, deliberately: the attachments strip, because pending attachments
|
|
4279
4600
|
* are the user's state and not an action to offer — hiding them would look like
|
|
4280
4601
|
* losing them; and the toolbar, because switching model still does something.
|
|
4281
4602
|
*
|
|
4282
4603
|
* The send button is the part the PANEL decides, through `mode`. `'submit'`
|
|
4283
|
-
* keeps it; `'none'` says this panel has no act for it and it is not drawn.
|
|
4284
|
-
*
|
|
4285
|
-
*
|
|
4286
|
-
*
|
|
4287
|
-
*
|
|
4288
|
-
* instruction) turns the button back on.
|
|
4604
|
+
* keeps it; `'none'` says this panel has no act for it and it is not drawn. `'none'`
|
|
4605
|
+
* is what a panel whose options settle on the first click needs — a single-choice
|
|
4606
|
+
* question, an approval. Flip between them at any time with
|
|
4607
|
+
* {@link setPanelSubmitEnabled}, which is how a panel that grows an act (an
|
|
4608
|
+
* "Other…" field, a written instruction) turns the button back on.
|
|
4289
4609
|
*
|
|
4290
|
-
*
|
|
4291
|
-
*
|
|
4292
|
-
*
|
|
4293
|
-
* wrote `''`, not the previous value).
|
|
4610
|
+
* The mode is declared with an attribute + CSS rather than inline `style.display`
|
|
4611
|
+
* on a child: an attribute is themeable, is visible to a consumer's own rules, and
|
|
4612
|
+
* does not clobber a `display` the consumer set.
|
|
4294
4613
|
*
|
|
4295
4614
|
* Returns the TOKEN for this panel. Pass it back to `hidePanel` so a presenter
|
|
4296
4615
|
* that has already lost the slot cannot close the panel that replaced it, and
|
|
@@ -4329,18 +4648,6 @@ class AparteComposer extends HTMLElement {
|
|
|
4329
4648
|
* no token it closes whatever is there, which is what a consumer driving the
|
|
4330
4649
|
* composer directly means.
|
|
4331
4650
|
*
|
|
4332
|
-
* BOTH forms notify. The no-token form used to call `_teardownPanel` directly,
|
|
4333
|
-
* which nulls `_panelOnEvict` without ever calling it — so the documented public
|
|
4334
|
-
* `hidePanel()` closed an open approval panel and left its request pending
|
|
4335
|
-
* forever. The turn hung on "waiting for you", and because
|
|
4336
|
-
* `AparteConfig.requestUserInput` chains each request on the previous one, NO
|
|
4337
|
-
* further question or approval on that config was ever presented again, for the
|
|
4338
|
-
* life of the page.
|
|
4339
|
-
*
|
|
4340
|
-
* The old JSDoc justified the silent branch as "what `reset()` needs". It was not:
|
|
4341
|
-
* `reset()` calls `_evictPanel()`, which notifies. The branch had no consumer and
|
|
4342
|
-
* one failure mode.
|
|
4343
|
-
*
|
|
4344
4651
|
* The two forms differ on purpose, and the difference is who already knows:
|
|
4345
4652
|
*
|
|
4346
4653
|
* - **With a matching token** the OWNER is closing its own panel, which is what
|
|
@@ -4400,10 +4707,10 @@ class AparteComposer extends HTMLElement {
|
|
|
4400
4707
|
/**
|
|
4401
4708
|
* Publish the panel mode as an attribute, so CSS can act on it.
|
|
4402
4709
|
*
|
|
4403
|
-
* The same reasoning `data-panel-active` is set for
|
|
4404
|
-
*
|
|
4405
|
-
*
|
|
4406
|
-
*
|
|
4710
|
+
* The same reasoning `data-panel-active` is set for: an attribute is themeable, is
|
|
4711
|
+
* visible to a consumer's own rules, and does not clobber what the consumer set. It
|
|
4712
|
+
* is what lets `'none'` remove the send button without this component reaching
|
|
4713
|
+
* into it.
|
|
4407
4714
|
*/
|
|
4408
4715
|
_reflectPanelMode() {
|
|
4409
4716
|
if (this._panelActive) this.setAttribute("data-panel-mode", this._panelMode);
|
|
@@ -4484,6 +4791,7 @@ class AparteComposer extends HTMLElement {
|
|
|
4484
4791
|
input?.focus();
|
|
4485
4792
|
}
|
|
4486
4793
|
// ── Internal pub/sub ────────────────────────────────────────────────────
|
|
4794
|
+
/** @internal The composer parts' own bus. From outside, listen for `aparte-composer-change`. */
|
|
4487
4795
|
_emit(event, payload) {
|
|
4488
4796
|
this._listeners.get(event)?.forEach((cb) => cb(payload));
|
|
4489
4797
|
if (AparteComposer._STATE_EVENTS.has(event)) {
|
|
@@ -4494,6 +4802,7 @@ class AparteComposer extends HTMLElement {
|
|
|
4494
4802
|
}));
|
|
4495
4803
|
}
|
|
4496
4804
|
}
|
|
4805
|
+
/** @internal The composer parts' own bus. From outside, listen for `aparte-composer-change`. */
|
|
4497
4806
|
_on(event, cb) {
|
|
4498
4807
|
if (!this._listeners.has(event)) this._listeners.set(event, /* @__PURE__ */ new Set());
|
|
4499
4808
|
this._listeners.get(event).add(cb);
|
|
@@ -4511,15 +4820,12 @@ class AparteComposer extends HTMLElement {
|
|
|
4511
4820
|
* Which chat this composer belongs to: its `target` attribute, or the id of the
|
|
4512
4821
|
* chat host above it.
|
|
4513
4822
|
*
|
|
4514
|
-
* All four wrappers set `target` themselves
|
|
4515
|
-
*
|
|
4516
|
-
*
|
|
4517
|
-
*
|
|
4518
|
-
*
|
|
4519
|
-
* i.e. the question vanished and the turn hung.
|
|
4823
|
+
* All four wrappers set `target` themselves. In RAW core — the documented quick
|
|
4824
|
+
* start, where the markup is hand-written — nothing sets it, so the chat host above
|
|
4825
|
+
* is what identifies the composer; without either, it would accept every chat's
|
|
4826
|
+
* lifecycle events and, on a two-chat page, one chat's Stop would tear down the
|
|
4827
|
+
* other's open elicitation panel.
|
|
4520
4828
|
*
|
|
4521
|
-
* Found by a two-chat test written for the elicitation presenter, which is the
|
|
4522
|
-
* only reason it surfaced: raw core with two chats is a shape nothing exercised.
|
|
4523
4829
|
* The hosts matched are the ones `aparte-chat-bubble._resolveTargetId()` matches,
|
|
4524
4830
|
* for the reason written there — Angular's wrapper root IS `<aparte-chat>`, the
|
|
4525
4831
|
* other three render a `[data-aparte-chat]` div.
|
|
@@ -4557,11 +4863,11 @@ class AparteComposer extends HTMLElement {
|
|
|
4557
4863
|
* Whether a terminal event ends the turn this composer is following.
|
|
4558
4864
|
*
|
|
4559
4865
|
* `_isForThisComposer` answers "is this chat's?", and both turns of a retry belong
|
|
4560
|
-
* to the same chat: the client aborts the superseded reply
|
|
4561
|
-
* `run-aborted` for it,
|
|
4562
|
-
*
|
|
4563
|
-
*
|
|
4564
|
-
*
|
|
4866
|
+
* to the same chat: the client aborts the superseded reply and the engine emits
|
|
4867
|
+
* `run-aborted` for it, which must not turn Stop back into Send while the new reply
|
|
4868
|
+
* is still streaming, nor evict the open panel (the approval question a tool is
|
|
4869
|
+
* still waiting on). An event with no `messageId` still ends the turn: a loop of
|
|
4870
|
+
* one's own saying "done" means this one.
|
|
4565
4871
|
*/
|
|
4566
4872
|
_isForThisTurn(e) {
|
|
4567
4873
|
const id = e.detail?.messageId;
|
|
@@ -4589,11 +4895,7 @@ class AparteComposerInput extends HTMLElement {
|
|
|
4589
4895
|
_maxHeight = 200;
|
|
4590
4896
|
_minHeight = 44;
|
|
4591
4897
|
/**
|
|
4592
|
-
* Re-measures the editor when its WIDTH changes
|
|
4593
|
-
* (connect, next frame, fonts.ready) all miss the same case: a box not yet laid
|
|
4594
|
-
* out — the placeholder wraps in a 0 px column, `scrollHeight` reads ~300 px, and
|
|
4595
|
-
* that became the inline height until the first keystroke. Width is the variable
|
|
4596
|
-
* none of them watched.
|
|
4898
|
+
* Re-measures the editor when its WIDTH changes.
|
|
4597
4899
|
*/
|
|
4598
4900
|
_resizeObserver = null;
|
|
4599
4901
|
_unsubscribes = [];
|
|
@@ -4778,9 +5080,10 @@ class AparteComposerInput extends HTMLElement {
|
|
|
4778
5080
|
e.preventDefault();
|
|
4779
5081
|
const cd = e.clipboardData;
|
|
4780
5082
|
if (!cd) return;
|
|
4781
|
-
const
|
|
5083
|
+
const root = this._getRoot();
|
|
5084
|
+
const imageFile = root?.querySelector("aparte-composer-add-attachment") ? Array.from(cd.items).find((i2) => i2.type.startsWith("image/"))?.getAsFile() : void 0;
|
|
4782
5085
|
if (imageFile) {
|
|
4783
|
-
|
|
5086
|
+
root?.addAttachments([imageFile]);
|
|
4784
5087
|
return;
|
|
4785
5088
|
}
|
|
4786
5089
|
const text = cd.getData("text/plain");
|
|
@@ -4842,9 +5145,8 @@ class AparteComposerSend extends HTMLElement {
|
|
|
4842
5145
|
*
|
|
4843
5146
|
* This button has four meanings — send, stop, submit an answer, advance to the
|
|
4844
5147
|
* next question — and three of them are decided by state it does not own: the
|
|
4845
|
-
* root's `streaming`, and this payload. It
|
|
4846
|
-
*
|
|
4847
|
-
* afterwards; a config change had no way to know which of the four to write.
|
|
5148
|
+
* root's `streaming`, and this payload. It is kept so the button's chrome can be
|
|
5149
|
+
* recomputed after the event, for example on a config change.
|
|
4848
5150
|
*/
|
|
4849
5151
|
_panel = null;
|
|
4850
5152
|
// Bound handler
|
|
@@ -4927,14 +5229,13 @@ class AparteComposerSend extends HTMLElement {
|
|
|
4927
5229
|
this._syncState();
|
|
4928
5230
|
}
|
|
4929
5231
|
/**
|
|
4930
|
-
* Panel open: this one button
|
|
5232
|
+
* Panel open: this one button means "submit the answer".
|
|
4931
5233
|
*
|
|
4932
|
-
* The icon
|
|
4933
|
-
*
|
|
4934
|
-
*
|
|
4935
|
-
*
|
|
4936
|
-
*
|
|
4937
|
-
* until every question has an answer, which is what the chips' marks explain.
|
|
5234
|
+
* The icon moves with the meaning: a paper plane would read as "send a message"
|
|
5235
|
+
* while the label says "Submit". The button never turns into a "Next" while a form
|
|
5236
|
+
* has questions ahead (the panel's chips do that, and a button whose meaning
|
|
5237
|
+
* changed under the pointer would be a second way to do it). It is disabled
|
|
5238
|
+
* instead until every question has an answer, which is what the chips' marks explain.
|
|
4938
5239
|
*/
|
|
4939
5240
|
_syncPanelState() {
|
|
4940
5241
|
const panel = this._panel;
|
|
@@ -5099,7 +5400,7 @@ class AparteComposerAttachments extends HTMLElement {
|
|
|
5099
5400
|
this.hidden = files.length === 0;
|
|
5100
5401
|
this._revokeUrls();
|
|
5101
5402
|
const cfg = resolveConfig(this);
|
|
5102
|
-
const removeLabel = (file) => (cfg.t("removeAttachment") || "Remove {name}").replace("{name}", file.name);
|
|
5403
|
+
const removeLabel = (file) => (cfg.t("removeAttachment") || "Remove {name}").replace("{name}", () => file.name);
|
|
5103
5404
|
this.innerHTML = files.map((file) => {
|
|
5104
5405
|
const name = escapeHtml(file.name);
|
|
5105
5406
|
const remove = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-thumb__remove" type="button" aria-label="${escapeAttr(removeLabel(file))}">${cfg.getIcon("close")}</button>`;
|
|
@@ -5396,6 +5697,21 @@ class AparteComposerToolbar extends HTMLElement {
|
|
|
5396
5697
|
if (!customElements.get("aparte-composer-toolbar")) {
|
|
5397
5698
|
customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
|
|
5398
5699
|
}
|
|
5700
|
+
function foldText(text) {
|
|
5701
|
+
return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
|
5702
|
+
}
|
|
5703
|
+
function filterConversationRows(root, needle) {
|
|
5704
|
+
let matches = 0;
|
|
5705
|
+
for (const row of root.querySelectorAll("[data-conv-id]")) {
|
|
5706
|
+
const title = row.querySelector(".aparte-conv-item__title")?.textContent ?? row.textContent ?? "";
|
|
5707
|
+
row.hidden = needle !== "" && !foldText(title).includes(needle);
|
|
5708
|
+
if (!row.hidden) matches++;
|
|
5709
|
+
}
|
|
5710
|
+
for (const group of root.querySelectorAll(".aparte-conv-group")) {
|
|
5711
|
+
group.hidden = needle !== "" && !Array.from(group.querySelectorAll("[data-conv-id]")).some((r) => !r.hidden);
|
|
5712
|
+
}
|
|
5713
|
+
return matches;
|
|
5714
|
+
}
|
|
5399
5715
|
function rowSelector(attr, id) {
|
|
5400
5716
|
return `[${attr}="${cssEscape(id)}"]`;
|
|
5401
5717
|
}
|
|
@@ -5427,11 +5743,10 @@ class AparteConversationList extends HTMLElement {
|
|
|
5427
5743
|
* Let the list finish the gesture through the registered conversation manager.
|
|
5428
5744
|
* Reflected as the `manage` attribute.
|
|
5429
5745
|
*
|
|
5430
|
-
* The property
|
|
5431
|
-
* and
|
|
5432
|
-
*
|
|
5433
|
-
* turning management off
|
|
5434
|
-
* was also done by the list. `presenceOn`, like `loading` above it.
|
|
5746
|
+
* The property exists because this is the attribute that authorises a WRITE, and Vue
|
|
5747
|
+
* and both Sveltes stringify a bound `false` onto an element that has no property of
|
|
5748
|
+
* that name — `manage="false"`, which `hasAttribute` reads as on. Without it,
|
|
5749
|
+
* turning management off would turn it on. `presenceOn`, like `loading` above it.
|
|
5435
5750
|
*/
|
|
5436
5751
|
get manage() {
|
|
5437
5752
|
return this.hasAttribute("manage");
|
|
@@ -5499,13 +5814,12 @@ class AparteConversationList extends HTMLElement {
|
|
|
5499
5814
|
/**
|
|
5500
5815
|
* A press holds every render until its click has landed. The host re-assigns
|
|
5501
5816
|
* `conversations` on every manager change (a save moves a row, a select marks one),
|
|
5502
|
-
* and a row replaced between `mousedown` and `mouseup`
|
|
5503
|
-
* it to the list, where the delegated handler
|
|
5504
|
-
*
|
|
5505
|
-
*
|
|
5506
|
-
*
|
|
5507
|
-
*
|
|
5508
|
-
* after the click.
|
|
5817
|
+
* and a row replaced between `mousedown` and `mouseup` loses its click: Chromium
|
|
5818
|
+
* sends it to the list, where the delegated handler matches nothing, and WebKit
|
|
5819
|
+
* sends none. Keeping the row node would not help: a row moved to its new place
|
|
5820
|
+
* leaves the document on the way, and the row that moves is often the one pressed
|
|
5821
|
+
* (the conversation just saved). So nothing changes under a pointer that is down,
|
|
5822
|
+
* and the render runs once, after the click.
|
|
5509
5823
|
*/
|
|
5510
5824
|
_pressed = false;
|
|
5511
5825
|
_renderAfterPress = false;
|
|
@@ -5525,7 +5839,14 @@ class AparteConversationList extends HTMLElement {
|
|
|
5525
5839
|
if (this.isConnected) this._render();
|
|
5526
5840
|
}, 0);
|
|
5527
5841
|
};
|
|
5842
|
+
/** The name this element last wrote, so one the app wrote — before or after connect — is left alone. */
|
|
5843
|
+
_ownLabel = null;
|
|
5528
5844
|
_render() {
|
|
5845
|
+
const current = this.getAttribute("aria-label");
|
|
5846
|
+
if (!this.hasAttribute("aria-labelledby") && (current === null || current === this._ownLabel)) {
|
|
5847
|
+
this._ownLabel = this._t("conversationList");
|
|
5848
|
+
this.setAttribute("aria-label", this._ownLabel);
|
|
5849
|
+
}
|
|
5529
5850
|
if (this._pressed) {
|
|
5530
5851
|
this._renderAfterPress = true;
|
|
5531
5852
|
return;
|
|
@@ -5536,10 +5857,12 @@ class AparteConversationList extends HTMLElement {
|
|
|
5536
5857
|
const held = focused && this.contains(focused) ? focused.closest("[data-select-id], [data-more-id]") : null;
|
|
5537
5858
|
const restore = held?.dataset["selectId"] ? rowSelector("data-select-id", held.dataset["selectId"]) : held?.dataset["moreId"] ? rowSelector("data-more-id", held.dataset["moreId"]) : null;
|
|
5538
5859
|
const heldRow = held?.closest("[data-conv-id]") ?? null;
|
|
5539
|
-
const
|
|
5860
|
+
const visible = (el2) => !el2.closest("[hidden]");
|
|
5861
|
+
const heldIndex = heldRow ? Array.from(this.querySelectorAll("[data-conv-id]")).filter(visible).indexOf(heldRow) : -1;
|
|
5540
5862
|
const loading = this.hasAttribute("loading");
|
|
5541
5863
|
this.setAttribute("aria-busy", loading ? "true" : "false");
|
|
5542
5864
|
if (loading) {
|
|
5865
|
+
this.removeAttribute("data-no-match");
|
|
5543
5866
|
const row = '<span class="aparte-skeleton aparte-skeleton--text"></span>';
|
|
5544
5867
|
this.innerHTML = '<div class="aparte-conv-list-loading" aria-hidden="true">' + row + row + row + row + row + row + '</div><span class="aparte-conv-list-loading-status aparte-sr-only" role="status"></span>';
|
|
5545
5868
|
const status = this.querySelector(".aparte-conv-list-loading-status");
|
|
@@ -5550,14 +5873,22 @@ class AparteConversationList extends HTMLElement {
|
|
|
5550
5873
|
else say();
|
|
5551
5874
|
return;
|
|
5552
5875
|
}
|
|
5553
|
-
|
|
5876
|
+
const line = this.querySelector(":scope > .aparte-conv-list-no-match");
|
|
5877
|
+
const groups = this._groups().map((g2) => this._renderGroup(g2)).join("");
|
|
5878
|
+
if (line) {
|
|
5879
|
+
for (const child of Array.from(this.childNodes)) if (child !== line) child.remove();
|
|
5880
|
+
line.insertAdjacentHTML("beforebegin", groups);
|
|
5881
|
+
} else {
|
|
5882
|
+
this.innerHTML = groups;
|
|
5883
|
+
}
|
|
5884
|
+
this._applyFilter();
|
|
5554
5885
|
if (!restore) return;
|
|
5555
5886
|
const back = this.querySelector(restore);
|
|
5556
|
-
if (back) {
|
|
5887
|
+
if (back && visible(back)) {
|
|
5557
5888
|
back.focus();
|
|
5558
5889
|
return;
|
|
5559
5890
|
}
|
|
5560
|
-
const rows = Array.from(this.querySelectorAll("[data-select-id]"));
|
|
5891
|
+
const rows = Array.from(this.querySelectorAll("[data-select-id]")).filter(visible);
|
|
5561
5892
|
const next = heldIndex >= 0 ? rows[heldIndex] ?? rows[rows.length - 1] : null;
|
|
5562
5893
|
if (next) {
|
|
5563
5894
|
next.focus();
|
|
@@ -5569,6 +5900,41 @@ class AparteConversationList extends HTMLElement {
|
|
|
5569
5900
|
}
|
|
5570
5901
|
this.focus();
|
|
5571
5902
|
}
|
|
5903
|
+
/** The query `filter()` holds, folded; re-applied on every render, so new rows obey it. */
|
|
5904
|
+
_query = "";
|
|
5905
|
+
/**
|
|
5906
|
+
* Show only the conversations whose title contains `query`, case- and
|
|
5907
|
+
* accent-insensitive, and the date groups that still hold one; return how many match.
|
|
5908
|
+
* An empty query shows everything. The query is kept through every render — a new
|
|
5909
|
+
* conversation, a rename — and when nothing matches the list says so in a status line
|
|
5910
|
+
* (the locale's `conversationSearchEmpty`) and carries `data-no-match`.
|
|
5911
|
+
*/
|
|
5912
|
+
filter(query) {
|
|
5913
|
+
this._query = foldText(query);
|
|
5914
|
+
return this._applyFilter();
|
|
5915
|
+
}
|
|
5916
|
+
_applyFilter() {
|
|
5917
|
+
const needle = this._query;
|
|
5918
|
+
if (this.hasAttribute("loading")) return 0;
|
|
5919
|
+
const matches = filterConversationRows(this, needle);
|
|
5920
|
+
const noMatch = needle !== "" && matches === 0;
|
|
5921
|
+
this.toggleAttribute("data-no-match", noMatch);
|
|
5922
|
+
let line = this.querySelector(":scope > .aparte-conv-list-no-match");
|
|
5923
|
+
const born = !line;
|
|
5924
|
+
if (!line) {
|
|
5925
|
+
line = document.createElement("p");
|
|
5926
|
+
line.className = "aparte-conv-list-no-match";
|
|
5927
|
+
line.setAttribute("role", "status");
|
|
5928
|
+
this.appendChild(line);
|
|
5929
|
+
}
|
|
5930
|
+
const text = noMatch ? this._t("conversationSearchEmpty") : "";
|
|
5931
|
+
const write = () => {
|
|
5932
|
+
if (line.isConnected && line.textContent !== text) line.textContent = text;
|
|
5933
|
+
};
|
|
5934
|
+
if (born && text && typeof requestAnimationFrame === "function") requestAnimationFrame(write);
|
|
5935
|
+
else write();
|
|
5936
|
+
return matches;
|
|
5937
|
+
}
|
|
5572
5938
|
_t(key) {
|
|
5573
5939
|
const locale = resolveConfig(this).getLocale();
|
|
5574
5940
|
return locale[key] ?? APARTE_DEFAULT_LOCALE[key] ?? "";
|
|
@@ -5686,7 +6052,7 @@ class AparteConversationList extends HTMLElement {
|
|
|
5686
6052
|
}
|
|
5687
6053
|
/** The question the confirm step asks — the popover's accessible name while it asks it. */
|
|
5688
6054
|
_confirmQuestion(conv) {
|
|
5689
|
-
return this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat"));
|
|
6055
|
+
return this._t("deleteConversationConfirm").replace("{title}", () => conv.title || this._t("newChat"));
|
|
5690
6056
|
}
|
|
5691
6057
|
_confirmMarkup(conv) {
|
|
5692
6058
|
const question = escapeHtml(this._confirmQuestion(conv));
|
|
@@ -6062,10 +6428,9 @@ class AparteSuggestions extends HTMLElement {
|
|
|
6062
6428
|
/** `hidden` was written by this element, so it is this element's to lift. */
|
|
6063
6429
|
_hidOwn = false;
|
|
6064
6430
|
/*
|
|
6065
|
-
* `empty-only`
|
|
6066
|
-
*
|
|
6067
|
-
*
|
|
6068
|
-
* signal now; the send stays as the other half, because it comes first — the row
|
|
6431
|
+
* `empty-only` follows the transcript: shown while it is empty, hidden once it has
|
|
6432
|
+
* messages, so a restored thread (nothing sent yet) has no starters on top of it
|
|
6433
|
+
* and a new chat brings them back. The send is the other half, because it comes first — the row
|
|
6069
6434
|
* hides on the click rather than a frame later when the bubble lands — and because
|
|
6070
6435
|
* it is all there is to read when the messages are not `<aparte-chat-bubble>`s
|
|
6071
6436
|
* (a wrapper's `renderBubble`) or there is no transcript at all. A transcript seen
|
|
@@ -6096,9 +6461,8 @@ class AparteSuggestions extends HTMLElement {
|
|
|
6096
6461
|
*
|
|
6097
6462
|
* Both, because both arrive: React 19 and Svelte assign the PROPERTY whenever the
|
|
6098
6463
|
* element has one of that name, so `<aparte-suggestions suggestions='[…]'>` — the
|
|
6099
|
-
* documented spelling, typed valid by both wrappers —
|
|
6100
|
-
* string
|
|
6101
|
-
* (and so `attributeChangedCallback`) was never written.
|
|
6464
|
+
* documented spelling, typed valid by both wrappers — reaches this setter as a
|
|
6465
|
+
* string while the attribute (and so `attributeChangedCallback`) is never written.
|
|
6102
6466
|
*/
|
|
6103
6467
|
set suggestions(value) {
|
|
6104
6468
|
this._suggestions = typeof value === "string" ? parseSuggestions(value) : Array.isArray(value) ? value.filter(isSuggestion) : [];
|
|
@@ -6254,9 +6618,13 @@ class AparteSuggestions extends HTMLElement {
|
|
|
6254
6618
|
return;
|
|
6255
6619
|
}
|
|
6256
6620
|
if (composer !== this._composer) this._watchComposer();
|
|
6621
|
+
const chipHadFocus = this.contains(document.activeElement);
|
|
6257
6622
|
composer.setValue(detail.prompt);
|
|
6258
6623
|
if (this.getAttribute("mode") === "fill") composer.focus();
|
|
6259
|
-
else
|
|
6624
|
+
else {
|
|
6625
|
+
composer.submit();
|
|
6626
|
+
if (chipHadFocus) composer.focus();
|
|
6627
|
+
}
|
|
6260
6628
|
}
|
|
6261
6629
|
}
|
|
6262
6630
|
if (!customElements.get("aparte-suggestions")) {
|
|
@@ -6289,7 +6657,7 @@ const ringSvg = () => {
|
|
|
6289
6657
|
};
|
|
6290
6658
|
class AparteContext extends HTMLElement {
|
|
6291
6659
|
static get observedAttributes() {
|
|
6292
|
-
return ["window", "warn", "danger", "target", "variant"];
|
|
6660
|
+
return ["window", "warn", "danger", "target", "variant", "used"];
|
|
6293
6661
|
}
|
|
6294
6662
|
_used = null;
|
|
6295
6663
|
_level = null;
|
|
@@ -6323,10 +6691,18 @@ class AparteContext extends HTMLElement {
|
|
|
6323
6691
|
this._compactRequested = false;
|
|
6324
6692
|
};
|
|
6325
6693
|
_onRerender = () => this._render();
|
|
6326
|
-
/** Tokens the last turn reported
|
|
6694
|
+
/** Tokens the last turn reported — or `used` set, until a turn reports — else `null`. */
|
|
6327
6695
|
get used() {
|
|
6328
6696
|
return this._used;
|
|
6329
6697
|
}
|
|
6698
|
+
/**
|
|
6699
|
+
* Writes the `used` attribute: a reading for a conversation restored without a turn of
|
|
6700
|
+
* this page, which had nothing to report until the next one. `null` clears it.
|
|
6701
|
+
*/
|
|
6702
|
+
set used(value) {
|
|
6703
|
+
if (value === null || value === void 0) this.removeAttribute("used");
|
|
6704
|
+
else this.setAttribute("used", String(value));
|
|
6705
|
+
}
|
|
6330
6706
|
/** The window in force — the attribute, else the current model's. */
|
|
6331
6707
|
get window() {
|
|
6332
6708
|
return this._window();
|
|
@@ -6359,7 +6735,11 @@ class AparteContext extends HTMLElement {
|
|
|
6359
6735
|
window.removeEventListener("aparte-compact-error", this._onCompactError);
|
|
6360
6736
|
window.removeEventListener("aparte-config-change", this._onRerender);
|
|
6361
6737
|
}
|
|
6362
|
-
attributeChangedCallback() {
|
|
6738
|
+
attributeChangedCallback(name) {
|
|
6739
|
+
if (name === "used") {
|
|
6740
|
+
const n2 = Number(this.getAttribute("used"));
|
|
6741
|
+
this._used = this.hasAttribute("used") && Number.isFinite(n2) && n2 >= 0 ? n2 : null;
|
|
6742
|
+
}
|
|
6363
6743
|
if (this.isConnected) this._render();
|
|
6364
6744
|
}
|
|
6365
6745
|
_window() {
|
|
@@ -6665,12 +7045,11 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6665
7045
|
* intersection observer reports which bubbles reach a reading band — the top 30% of
|
|
6666
7046
|
* the surface, from its very top so that the bubble a jump aligned there counts. The
|
|
6667
7047
|
* rule is a scrollspy's: the LAST question that has reached the band is the one the
|
|
6668
|
-
* reader is under.
|
|
6669
|
-
*
|
|
6670
|
-
*
|
|
6671
|
-
*
|
|
6672
|
-
*
|
|
6673
|
-
* holds, and the observer has nothing to report on the last few pixels.
|
|
7048
|
+
* reader is under. (The largest visible share would name the first of three questions
|
|
7049
|
+
* sitting in the band at the bottom of a thread of short turns.) The one scroll
|
|
7050
|
+
* position read is the bottom, because there the reader is under the latest question
|
|
7051
|
+
* whatever the band holds, and the observer has nothing to report on the last few
|
|
7052
|
+
* pixels.
|
|
6674
7053
|
*/
|
|
6675
7054
|
_observe(bubbles) {
|
|
6676
7055
|
this._unobserve();
|
|
@@ -6842,14 +7221,13 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6842
7221
|
* Scroll the transcript to a message. Announced first, cancelable; then a
|
|
6843
7222
|
* `scrollIntoView` on the bubble, smooth unless the reader prefers reduced motion.
|
|
6844
7223
|
* The clicked tick is the mark until the transcript has stopped moving: while a
|
|
6845
|
-
* smooth scroll crosses other questions the band would name each in turn
|
|
6846
|
-
* ended on the wrong one two times in three before this hold.
|
|
7224
|
+
* smooth scroll crosses other questions the band would name each in turn.
|
|
6847
7225
|
*
|
|
6848
7226
|
* Where the scroll lands is checked once it has settled, and corrected. The bubbles
|
|
6849
7227
|
* carry `content-visibility: auto`, so a bubble far from the reader has an estimated
|
|
6850
7228
|
* height until it is rendered: the scroll aims at an estimate, the bubbles above it
|
|
6851
|
-
* take their real size on the way, and the message ends up above or below the top
|
|
6852
|
-
*
|
|
7229
|
+
* take their real size on the way, and the message ends up above or below the top,
|
|
7230
|
+
* the further away the worse.
|
|
6853
7231
|
* A second `scrollIntoView` from a rendered neighbourhood lands; a few are allowed.
|
|
6854
7232
|
*/
|
|
6855
7233
|
jumpTo(messageId) {
|
|
@@ -6862,6 +7240,7 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6862
7240
|
if (!announced) return;
|
|
6863
7241
|
const bubble = this._bubbleOf(messageId);
|
|
6864
7242
|
if (!bubble) return;
|
|
7243
|
+
this._viewport()?.setAutoScroll?.(false);
|
|
6865
7244
|
this._scrollTo(bubble);
|
|
6866
7245
|
this._setCurrent(messageId);
|
|
6867
7246
|
this._hold(messageId);
|
|
@@ -6892,7 +7271,8 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6892
7271
|
const surface = this._settleSurface;
|
|
6893
7272
|
const bubble = id ? this._bubbleOf(id) : null;
|
|
6894
7273
|
if (id && bubble && surface && this._alignPasses < MAX_ALIGN_PASSES) {
|
|
6895
|
-
const
|
|
7274
|
+
const aim = surface.getBoundingClientRect().top + (parseFloat(getComputedStyle(surface).scrollPaddingTop) || 0);
|
|
7275
|
+
const off = bubble.getBoundingClientRect().top - aim;
|
|
6896
7276
|
if (Math.abs(off) > ALIGN_TOLERANCE) {
|
|
6897
7277
|
this._alignPasses++;
|
|
6898
7278
|
this._scrollTo(bubble);
|
|
@@ -6963,6 +7343,10 @@ class AparteSidebar extends HTMLElement {
|
|
|
6963
7343
|
* Same convention as `_relabel`, which leaves a host-authored `aria-label` alone.
|
|
6964
7344
|
*/
|
|
6965
7345
|
_ownHidden = false;
|
|
7346
|
+
/** The query `filter()` was last given, kept so the sidebar's no-match can follow its lists. */
|
|
7347
|
+
_query = "";
|
|
7348
|
+
/** Watches the lists' own `data-no-match` and `loading`, which change with their renders. */
|
|
7349
|
+
_listWatch = null;
|
|
6966
7350
|
/** Whether the sidebar is collapsed (hidden in the flow, or closed as a drawer). */
|
|
6967
7351
|
get collapsed() {
|
|
6968
7352
|
return this.hasAttribute("collapsed");
|
|
@@ -6983,6 +7367,12 @@ class AparteSidebar extends HTMLElement {
|
|
|
6983
7367
|
this._hostIntentSeeded = true;
|
|
6984
7368
|
}
|
|
6985
7369
|
this.addEventListener("input", this._onInput);
|
|
7370
|
+
if (typeof MutationObserver === "function") {
|
|
7371
|
+
this._listWatch = new MutationObserver((records) => {
|
|
7372
|
+
if (records.some((r) => r.target !== this)) this._syncNoMatch();
|
|
7373
|
+
});
|
|
7374
|
+
this._listWatch.observe(this, { subtree: true, attributes: true, attributeFilter: ["data-no-match", "loading"] });
|
|
7375
|
+
}
|
|
6986
7376
|
document.addEventListener("keydown", this._onKeydown);
|
|
6987
7377
|
document.addEventListener("click", this._onDocumentClick);
|
|
6988
7378
|
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
@@ -7014,6 +7404,8 @@ class AparteSidebar extends HTMLElement {
|
|
|
7014
7404
|
}
|
|
7015
7405
|
disconnectedCallback() {
|
|
7016
7406
|
this.removeEventListener("input", this._onInput);
|
|
7407
|
+
this._listWatch?.disconnect();
|
|
7408
|
+
this._listWatch = null;
|
|
7017
7409
|
document.removeEventListener("keydown", this._onKeydown);
|
|
7018
7410
|
document.removeEventListener("click", this._onDocumentClick);
|
|
7019
7411
|
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
@@ -7192,20 +7584,43 @@ class AparteSidebar extends HTMLElement {
|
|
|
7192
7584
|
this.filter(input.value);
|
|
7193
7585
|
};
|
|
7194
7586
|
/**
|
|
7195
|
-
*
|
|
7196
|
-
*
|
|
7587
|
+
* Filter the conversation lists inside by title (case- and accent-insensitive; an
|
|
7588
|
+
* empty query shows everything), and return how many conversations match. Each list
|
|
7589
|
+
* keeps the query through its own renders and says when nothing matches; the
|
|
7590
|
+
* sidebar carries `data-no-match` then too.
|
|
7197
7591
|
*/
|
|
7198
7592
|
filter(query) {
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7593
|
+
this._query = query;
|
|
7594
|
+
let matches = 0;
|
|
7595
|
+
let waiting = false;
|
|
7596
|
+
for (const list of this._lists()) {
|
|
7597
|
+
if (typeof list.filter === "function") matches += list.filter(query);
|
|
7598
|
+
else {
|
|
7599
|
+
matches += filterConversationRows(list, foldText(query));
|
|
7600
|
+
waiting = true;
|
|
7207
7601
|
}
|
|
7208
7602
|
}
|
|
7603
|
+
if (waiting && typeof customElements !== "undefined") {
|
|
7604
|
+
void customElements.whenDefined("aparte-conversation-list").then(() => {
|
|
7605
|
+
if (this._query === query) for (const list of this._lists()) list.filter?.(query);
|
|
7606
|
+
this._syncNoMatch();
|
|
7607
|
+
});
|
|
7608
|
+
}
|
|
7609
|
+
this._syncNoMatch();
|
|
7610
|
+
return matches;
|
|
7611
|
+
}
|
|
7612
|
+
_lists() {
|
|
7613
|
+
return Array.from(this.querySelectorAll("aparte-conversation-list"));
|
|
7614
|
+
}
|
|
7615
|
+
/**
|
|
7616
|
+
* `data-no-match` while the held query matches nothing in any list — read from the lists
|
|
7617
|
+
* themselves, so it follows their renders (a conversation that matches arriving, the last
|
|
7618
|
+
* match leaving) and is never set while one of them is still loading.
|
|
7619
|
+
*/
|
|
7620
|
+
_syncNoMatch() {
|
|
7621
|
+
const needle = foldText(this._query);
|
|
7622
|
+
const none = needle !== "" && this._lists().every((list) => typeof list.filter === "function" ? list.hasAttribute("data-no-match") : !list.hasAttribute("loading") && filterConversationRows(list, needle) === 0);
|
|
7623
|
+
this.toggleAttribute("data-no-match", none);
|
|
7209
7624
|
}
|
|
7210
7625
|
// ─── Locale ───────────────────────────────────────────────────────────
|
|
7211
7626
|
_onConfigChange = () => this._relabel();
|
|
@@ -7231,9 +7646,6 @@ function visibleFocusable(root) {
|
|
|
7231
7646
|
function nearestSidebar(control) {
|
|
7232
7647
|
return control.closest("aparte-sidebar") ?? rootOf(control).querySelector("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
|
|
7233
7648
|
}
|
|
7234
|
-
function fold(text) {
|
|
7235
|
-
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
|
|
7236
|
-
}
|
|
7237
7649
|
if (!customElements.get("aparte-sidebar")) {
|
|
7238
7650
|
customElements.define("aparte-sidebar", AparteSidebar);
|
|
7239
7651
|
}
|
|
@@ -7385,7 +7797,7 @@ class AparteSplit extends HTMLElement {
|
|
|
7385
7797
|
get stacked() {
|
|
7386
7798
|
return this.hasAttribute("data-stacked") || this.hasAttribute("single") || this.classList.contains("aparte-split--only-start") || this.classList.contains("aparte-split--only-end");
|
|
7387
7799
|
}
|
|
7388
|
-
/** One pane on demand, whatever the width
|
|
7800
|
+
/** One pane on demand, whatever the width. Reflected. */
|
|
7389
7801
|
get single() {
|
|
7390
7802
|
return this.hasAttribute("single");
|
|
7391
7803
|
}
|
|
@@ -7456,6 +7868,7 @@ class AparteSplit extends HTMLElement {
|
|
|
7456
7868
|
this._watchBreakpoint();
|
|
7457
7869
|
this._watchSize();
|
|
7458
7870
|
this._ready = true;
|
|
7871
|
+
this._syncPaneControls();
|
|
7459
7872
|
}
|
|
7460
7873
|
disconnectedCallback() {
|
|
7461
7874
|
this._handle?.removeEventListener("pointerdown", this._onPointerDown);
|
|
@@ -7507,6 +7920,7 @@ class AparteSplit extends HTMLElement {
|
|
|
7507
7920
|
}
|
|
7508
7921
|
case "pane": {
|
|
7509
7922
|
const pane = this.pane;
|
|
7923
|
+
this._syncPaneControls();
|
|
7510
7924
|
if (pane === this._lastPane) return;
|
|
7511
7925
|
this._lastPane = pane;
|
|
7512
7926
|
this._commit();
|
|
@@ -7524,6 +7938,7 @@ class AparteSplit extends HTMLElement {
|
|
|
7524
7938
|
case "single":
|
|
7525
7939
|
if (this.single && this._dragging) this._endDrag("pointer", this._dragStartPercent);
|
|
7526
7940
|
this._stampHandle();
|
|
7941
|
+
this._syncPaneControls();
|
|
7527
7942
|
return;
|
|
7528
7943
|
case "breakpoint":
|
|
7529
7944
|
this._watchBreakpoint();
|
|
@@ -7606,8 +8021,8 @@ class AparteSplit extends HTMLElement {
|
|
|
7606
8021
|
*
|
|
7607
8022
|
* While STACKED there is nothing to read: one pane is `display: none` and the other
|
|
7608
8023
|
* spans the single track, so the ratio is 0 or 100 whatever the seam was set to.
|
|
7609
|
-
* Measuring it there
|
|
7610
|
-
*
|
|
8024
|
+
* Measuring it there would write `position="0"` into the attribute, the event and the
|
|
8025
|
+
* host's storage when "Preview" is tapped on a phone.
|
|
7611
8026
|
*/
|
|
7612
8027
|
_achievedPercent() {
|
|
7613
8028
|
if (this.stacked) return this._position;
|
|
@@ -7938,6 +8353,7 @@ class AparteSplit extends HTMLElement {
|
|
|
7938
8353
|
const was = this.hasAttribute("data-stacked");
|
|
7939
8354
|
if (stacked && !was && this._dragging) this._endDrag("pointer", this._dragStartPercent);
|
|
7940
8355
|
this.toggleAttribute("data-stacked", stacked);
|
|
8356
|
+
if (stacked !== was) this._syncPaneControls();
|
|
7941
8357
|
if (stacked && !was) {
|
|
7942
8358
|
if (!this.hasAttribute("pane")) this.showPane("start");
|
|
7943
8359
|
} else if (!stacked && was) {
|
|
@@ -7969,6 +8385,28 @@ class AparteSplit extends HTMLElement {
|
|
|
7969
8385
|
}
|
|
7970
8386
|
this.showPane(which ?? (this.pane === "start" ? "end" : "start"));
|
|
7971
8387
|
};
|
|
8388
|
+
/**
|
|
8389
|
+
* The two-button switch says which pane it shows: `aria-pressed` on every
|
|
8390
|
+
* `[data-aparte-split-pane="start"|"end"]` that reaches this split, true on the one
|
|
8391
|
+
* naming the pane in view, while the split is stacked; side by side both panes are in
|
|
8392
|
+
* view and neither button is pressed, so none carries it. A toggle (an id, or empty) is
|
|
8393
|
+
* an action and gets none. Read when the split connects, whenever `pane` changes and
|
|
8394
|
+
* when it stacks or unstacks, so a control added later is stamped at the next switch.
|
|
8395
|
+
*/
|
|
8396
|
+
_syncPaneControls() {
|
|
8397
|
+
const roots = /* @__PURE__ */ new Set([rootOf(this), document]);
|
|
8398
|
+
for (const root of roots) {
|
|
8399
|
+
for (const control of root.querySelectorAll("[data-aparte-split-pane]")) {
|
|
8400
|
+
const which = (control.getAttribute("data-aparte-split-pane") ?? "").trim();
|
|
8401
|
+
if (which !== "start" && which !== "end") continue;
|
|
8402
|
+
if (control.localName !== "button" && control.getAttribute("role") !== "button") continue;
|
|
8403
|
+
const target = control.closest("aparte-split") ?? rootOf(control).querySelector("aparte-split") ?? document.querySelector("aparte-split");
|
|
8404
|
+
if (target !== this) continue;
|
|
8405
|
+
if (this.stacked) control.setAttribute("aria-pressed", String(which === this.pane));
|
|
8406
|
+
else control.removeAttribute("aria-pressed");
|
|
8407
|
+
}
|
|
8408
|
+
}
|
|
8409
|
+
}
|
|
7972
8410
|
// ─── Locale ───────────────────────────────────────────────────────────
|
|
7973
8411
|
_onConfigChange = () => this._relabel();
|
|
7974
8412
|
/**
|