@aparte/core 0.19.3 → 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/dist/client/aparte-client.d.ts +66 -95
- 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 +88 -57
- package/dist/components/bubble/aparte-chat-bubble.d.ts.map +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 +2 -2
- package/dist/components/composer/aparte-composer-add-attachment.d.ts +1 -1
- package/dist/components/composer/aparte-composer-input.d.ts +1 -5
- package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-send.d.ts +8 -10
- package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer.d.ts +48 -80
- package/dist/components/composer/aparte-composer.d.ts.map +1 -1
- package/dist/components/context/aparte-context.d.ts +9 -4
- package/dist/components/context/aparte-context.d.ts.map +1 -1
- package/dist/components/conversation-list/aparte-conversation-list.d.ts +23 -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.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 +70 -70
- package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
- package/dist/config/aparte-config.d.ts +87 -66
- 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 +57 -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 +3 -3
- package/dist/conversations/conversation-manager.d.ts.map +1 -1
- package/dist/custom-elements.json +13054 -12334
- package/dist/{dialog-triggers-ChnPFG9M.js → dialog-triggers-3Rk7PuZa.js} +444 -287
- 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 +11 -5
- 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 +12 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +682 -327
- package/dist/index.js.map +1 -1
- package/dist/index.node.d.ts +6 -3
- 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/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/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/backend-handler.d.ts +4 -6
- package/dist/transport/backend-handler.d.ts.map +1 -1
- package/dist/transport/backend-transport.d.ts +14 -0
- 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/index.d.ts +2 -2
- 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 +21 -13
- package/dist/types/segments.d.ts.map +1 -1
- package/dist/types/tools.d.ts +20 -22
- 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 +29 -3
- package/dist/dialog-triggers-ChnPFG9M.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
|
}
|
|
@@ -767,17 +800,48 @@ function isWithin(node, ancestor) {
|
|
|
767
800
|
}
|
|
768
801
|
return false;
|
|
769
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
|
+
}
|
|
770
837
|
class AparteElicitation extends HTMLElement {
|
|
771
838
|
_pending = null;
|
|
772
839
|
/**
|
|
773
840
|
* A turn ended — cancel the open question only if it was OUR turn.
|
|
774
841
|
*
|
|
775
|
-
* These two listeners sit on `window
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
* the user was still looking at. Same defect as the `compact()` handler that
|
|
779
|
-
* emptied both chats, which its four sibling handlers in `AparteClient` already
|
|
780
|
-
* 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.
|
|
781
845
|
*
|
|
782
846
|
* The leniency rule is the composer's, deliberately: an event with no
|
|
783
847
|
* `targetId` is for everyone (a single-chat app never sets one), and a chat we
|
|
@@ -913,12 +977,20 @@ class AparteElicitation extends HTMLElement {
|
|
|
913
977
|
}
|
|
914
978
|
const cfg = resolveConfig(this);
|
|
915
979
|
if (request.kind === "approval") {
|
|
916
|
-
const
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
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));
|
|
920
984
|
panel2.onSettle((answer) => settle({ action: "accept", content: answer }));
|
|
921
|
-
|
|
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
|
+
};
|
|
922
994
|
slot.token = composer.showPanel(panel2.el, {
|
|
923
995
|
submitEnabled: panel2.isComplete(),
|
|
924
996
|
mode: approvalMode(),
|
|
@@ -931,22 +1003,27 @@ class AparteElicitation extends HTMLElement {
|
|
|
931
1003
|
return;
|
|
932
1004
|
}
|
|
933
1005
|
const schema = request.schema ?? { type: "string" };
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
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));
|
|
937
1011
|
const skip = document.createElement("button");
|
|
938
1012
|
skip.type = "button";
|
|
939
1013
|
skip.className = "aparte-btn aparte-elic-skip";
|
|
940
1014
|
skip.textContent = cfg.t("elicitationSkip");
|
|
941
1015
|
skip.addEventListener("click", () => settle({ action: "decline" }));
|
|
942
1016
|
panel.dismiss.appendChild(skip);
|
|
1017
|
+
const retract = addRetract(panel.el, panel.dismiss, cfg, () => panel.focus(), syncSubmit);
|
|
943
1018
|
panel.onSettle((content) => settle({ action: "accept", content }));
|
|
944
1019
|
this._pending = {
|
|
945
1020
|
abort: () => fail(),
|
|
946
1021
|
composer,
|
|
947
1022
|
relabel: () => {
|
|
948
1023
|
panel.relabel();
|
|
949
|
-
|
|
1024
|
+
const now = resolveConfig(this);
|
|
1025
|
+
skip.textContent = now.t("elicitationSkip");
|
|
1026
|
+
retract.relabel(now);
|
|
950
1027
|
}
|
|
951
1028
|
};
|
|
952
1029
|
slot.token = composer.showPanel(panel.el, {
|
|
@@ -957,8 +1034,8 @@ class AparteElicitation extends HTMLElement {
|
|
|
957
1034
|
* or a turn ending. The composer tears the panel down either way; only
|
|
958
1035
|
* this callback can settle the promise, and without it the request hung
|
|
959
1036
|
* AND `_pending` stayed set, so every later question was short-circuited
|
|
960
|
-
* for the life of the page. `
|
|
961
|
-
* 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.
|
|
962
1039
|
*/
|
|
963
1040
|
onEvict: () => fail(),
|
|
964
1041
|
onSubmit: () => {
|
|
@@ -995,12 +1072,11 @@ class AparteElicitation extends HTMLElement {
|
|
|
995
1072
|
* The composer to present in: the nearest one in an ancestor subtree, and
|
|
996
1073
|
* nothing else.
|
|
997
1074
|
*
|
|
998
|
-
* There
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
* 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.
|
|
1004
1080
|
*
|
|
1005
1081
|
* Returning `null` instead REJECTS the request, which is honest: nothing was
|
|
1006
1082
|
* shown, so nothing was answered. The warning names the fix, because this is a
|
|
@@ -1035,6 +1111,7 @@ class AparteChat extends HTMLElement {
|
|
|
1035
1111
|
}
|
|
1036
1112
|
_observer = null;
|
|
1037
1113
|
_loadingObserver = null;
|
|
1114
|
+
_offGlide = null;
|
|
1038
1115
|
/** Waits for a `<aparte-chat-viewport>` child to appear — see `_watchForViewport`. */
|
|
1039
1116
|
_childObserver = null;
|
|
1040
1117
|
/** The viewport is loading and the gate is on — see `_applyLoadingGate`. */
|
|
@@ -1055,8 +1132,12 @@ class AparteChat extends HTMLElement {
|
|
|
1055
1132
|
this._syncEmptyWatch();
|
|
1056
1133
|
this._syncLoadingWatch();
|
|
1057
1134
|
this._watchForViewport();
|
|
1135
|
+
this._offGlide?.();
|
|
1136
|
+
this._offGlide = gateGlide(this);
|
|
1058
1137
|
}
|
|
1059
1138
|
disconnectedCallback() {
|
|
1139
|
+
this._offGlide?.();
|
|
1140
|
+
this._offGlide = null;
|
|
1060
1141
|
this._observer?.disconnect();
|
|
1061
1142
|
this._observer = null;
|
|
1062
1143
|
this._loadingObserver?.disconnect();
|
|
@@ -1139,11 +1220,10 @@ class AparteChat extends HTMLElement {
|
|
|
1139
1220
|
/**
|
|
1140
1221
|
* Watch the viewport so `data-empty` follows the conversation.
|
|
1141
1222
|
*
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
* 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.
|
|
1147
1227
|
*/
|
|
1148
1228
|
_syncEmptyWatch() {
|
|
1149
1229
|
this._observer?.disconnect();
|
|
@@ -1170,12 +1250,11 @@ class AparteChat extends HTMLElement {
|
|
|
1170
1250
|
this._loadingObserver.observe(viewport, { attributes: true, attributeFilter: ["loading"] });
|
|
1171
1251
|
}
|
|
1172
1252
|
/**
|
|
1173
|
-
* Both watches above need a viewport to observe, and
|
|
1174
|
-
* connect
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
* 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.
|
|
1179
1258
|
*/
|
|
1180
1259
|
_watchForViewport() {
|
|
1181
1260
|
this._childObserver?.disconnect();
|
|
@@ -1210,8 +1289,8 @@ class AparteChat extends HTMLElement {
|
|
|
1210
1289
|
}
|
|
1211
1290
|
/**
|
|
1212
1291
|
* Empty means no message AND none on its way. A viewport that says `loading` holds
|
|
1213
|
-
* no bubble yet
|
|
1214
|
-
*
|
|
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.
|
|
1215
1294
|
*/
|
|
1216
1295
|
_updateEmpty() {
|
|
1217
1296
|
const viewport = this.querySelector("aparte-chat-viewport");
|
|
@@ -1253,6 +1332,16 @@ function segmentRenderResultToElement(result, segment) {
|
|
|
1253
1332
|
if (el2 && segment && !el2.hasAttribute("data-segment-id")) el2.setAttribute("data-segment-id", segment.id);
|
|
1254
1333
|
return el2;
|
|
1255
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
|
+
}
|
|
1256
1345
|
class AparteChatBubble extends HTMLElement {
|
|
1257
1346
|
_contentEl = null;
|
|
1258
1347
|
_segmentsEl = null;
|
|
@@ -1268,8 +1357,20 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1268
1357
|
_usage = null;
|
|
1269
1358
|
/** Cleanup returned by the avatar provider — called on disconnect/re-render. */
|
|
1270
1359
|
_avatarCleanup = null;
|
|
1360
|
+
/** Whether the avatar slot holds what a provider drew, which goes when the provider does. */
|
|
1361
|
+
_avatarFromProvider = false;
|
|
1271
1362
|
/** Sibling count for tree-based branch navigation (set by setSiblings()) */
|
|
1272
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;
|
|
1273
1374
|
/** Sibling index for tree-based branch navigation (set by setSiblings()) */
|
|
1274
1375
|
_siblingIndex = 0;
|
|
1275
1376
|
/** True while the user-message inline editor is open. */
|
|
@@ -1301,7 +1402,29 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1301
1402
|
this._renderAvatar();
|
|
1302
1403
|
this._relabelSegments();
|
|
1303
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
|
+
}
|
|
1304
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
|
+
}
|
|
1305
1428
|
/**
|
|
1306
1429
|
* Ask every rendered segment to re-read its config-derived text.
|
|
1307
1430
|
*
|
|
@@ -1339,9 +1462,11 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1339
1462
|
const set = (selector, label) => {
|
|
1340
1463
|
this.querySelector(selector)?.setAttribute("aria-label", label);
|
|
1341
1464
|
};
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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");
|
|
1345
1470
|
}
|
|
1346
1471
|
/**
|
|
1347
1472
|
* Config governing this bubble: the instance config of the nearest
|
|
@@ -1361,6 +1486,40 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1361
1486
|
this.addEventListener("click", this._onBranchPickerClick);
|
|
1362
1487
|
this.addEventListener("click", this._onLinkClick);
|
|
1363
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
|
+
}
|
|
1364
1523
|
disconnectedCallback() {
|
|
1365
1524
|
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
1366
1525
|
this.removeEventListener("click", this._onBranchPickerClick);
|
|
@@ -1473,11 +1632,11 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1473
1632
|
* query returns the first match in document order — and sanitized model
|
|
1474
1633
|
* markdown renders inside that same container, with `data-*` attributes
|
|
1475
1634
|
* deliberately preserved (they are inert). So a decoy `data-segment-id` planted
|
|
1476
|
-
* in an earlier segment's prose
|
|
1635
|
+
* in an earlier segment's prose would win over the real segment element.
|
|
1477
1636
|
*
|
|
1478
1637
|
* Parser ids are unguessable UUIDs, but a tool segment is `tool-${toolCallId}`
|
|
1479
|
-
* and the MODEL chooses that id — so
|
|
1480
|
-
*
|
|
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
|
|
1481
1640
|
* the human-in-the-loop control.
|
|
1482
1641
|
*/
|
|
1483
1642
|
removeSegment(segmentId) {
|
|
@@ -1557,6 +1716,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1557
1716
|
console.warn(`[AparteChatBubble] _appendSegmentEl ABORT: _segmentsEl is null`);
|
|
1558
1717
|
return;
|
|
1559
1718
|
}
|
|
1719
|
+
if (this._segments.length === 1) this._drawnWithVersion = renderingVersionOf(this._cfg);
|
|
1560
1720
|
const renderer = resolveSegmentRenderer(segment.type, this._cfg);
|
|
1561
1721
|
if (renderer) {
|
|
1562
1722
|
const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
|
|
@@ -1619,6 +1779,9 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1619
1779
|
const displayName = this._getDisplayName();
|
|
1620
1780
|
const initial = this._getAvatarInitial();
|
|
1621
1781
|
const shell = this._cfg.getBubbleShellRenderer?.();
|
|
1782
|
+
this._drawnShell = shell ?? null;
|
|
1783
|
+
this._avatarFromProvider = false;
|
|
1784
|
+
this._wroteRootLabel = this._wroteBarLabel = this._wroteArrowLabels = !shell;
|
|
1622
1785
|
if (shell) {
|
|
1623
1786
|
const out = runWithConfig(this._cfg, () => shell({ role: this._role, name: displayName, avatarInitial: initial }));
|
|
1624
1787
|
if (out instanceof HTMLElement) this.replaceChildren(out);
|
|
@@ -1664,11 +1827,39 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1664
1827
|
if (messageEl && !messageEl.hasAttribute("role")) {
|
|
1665
1828
|
messageEl.setAttribute("role", "article");
|
|
1666
1829
|
messageEl.setAttribute("aria-label", this._getAriaLabel());
|
|
1830
|
+
this._wroteRootLabel = true;
|
|
1667
1831
|
}
|
|
1668
1832
|
this._contentEl = this.querySelector(".aparte-content");
|
|
1669
1833
|
this._segmentsEl = this.querySelector(".aparte-segments");
|
|
1670
1834
|
this._attachmentsEl = this.querySelector(".aparte-attachments");
|
|
1671
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
|
+
}
|
|
1672
1863
|
this._actionBarEl?.addEventListener("keydown", this._onActionBarKeydown);
|
|
1673
1864
|
this._branchPickerEl = this.querySelector(".aparte-branch-picker");
|
|
1674
1865
|
this._footerEl = this.querySelector(".aparte-footer");
|
|
@@ -1680,8 +1871,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1680
1871
|
}
|
|
1681
1872
|
/**
|
|
1682
1873
|
* Show the built-in waiting indicator while this bubble is in flight and has
|
|
1683
|
-
* nothing to show yet — the gap between "user sends" and the first token
|
|
1684
|
-
* 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.
|
|
1685
1875
|
*
|
|
1686
1876
|
* The dots are CSS (no per-token work, themable, honours reduced-motion); the
|
|
1687
1877
|
* accessible name is `locale.typing`, next to the `aria-busy` the streaming state
|
|
@@ -1708,11 +1898,10 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1708
1898
|
/**
|
|
1709
1899
|
* Hand the avatar host element off to the registered AvatarProvider, if any.
|
|
1710
1900
|
*
|
|
1711
|
-
* With no provider the slot
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1715
|
-
* 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`.
|
|
1716
1905
|
*/
|
|
1717
1906
|
_renderAvatar() {
|
|
1718
1907
|
const avatar = this.querySelector(".aparte-avatar");
|
|
@@ -1725,10 +1914,34 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1725
1914
|
this._avatarCleanup = null;
|
|
1726
1915
|
}
|
|
1727
1916
|
const provider = this._cfg.getAvatarProvider();
|
|
1728
|
-
if (!provider)
|
|
1917
|
+
if (!provider) {
|
|
1918
|
+
if (this._avatarFromProvider) {
|
|
1919
|
+
avatar.replaceChildren();
|
|
1920
|
+
this._avatarFromProvider = false;
|
|
1921
|
+
}
|
|
1922
|
+
this._markAvatarImage(avatar);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1729
1925
|
avatar.textContent = "";
|
|
1730
1926
|
const cleanup = provider.render(this._role, avatar);
|
|
1927
|
+
this._avatarFromProvider = true;
|
|
1731
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 });
|
|
1732
1945
|
}
|
|
1733
1946
|
_updateRole() {
|
|
1734
1947
|
const message = this.querySelector(".aparte-message");
|
|
@@ -1736,13 +1949,13 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1736
1949
|
const nameEl = this.querySelector(".aparte-name");
|
|
1737
1950
|
if (message) {
|
|
1738
1951
|
message.setAttribute("data-role", this._role);
|
|
1739
|
-
if (message.getAttribute("role") === "article") {
|
|
1952
|
+
if (this._wroteRootLabel && message.getAttribute("role") === "article") {
|
|
1740
1953
|
message.setAttribute("aria-label", this._getAriaLabel());
|
|
1741
1954
|
}
|
|
1742
1955
|
}
|
|
1743
1956
|
if (avatar) {
|
|
1744
1957
|
avatar.setAttribute("data-role", this._role);
|
|
1745
|
-
if (avatar.textContent) avatar.textContent = this._getAvatarInitial();
|
|
1958
|
+
if (avatar.textContent && !this._avatarFromProvider) avatar.textContent = this._getAvatarInitial();
|
|
1746
1959
|
}
|
|
1747
1960
|
if (nameEl) {
|
|
1748
1961
|
nameEl.textContent = this._getDisplayName();
|
|
@@ -1753,10 +1966,12 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1753
1966
|
_updateName() {
|
|
1754
1967
|
const avatar = this.querySelector(".aparte-avatar");
|
|
1755
1968
|
const nameEl = this.querySelector(".aparte-name");
|
|
1756
|
-
if (avatar && avatar.textContent && !this._cfg.getAvatarProvider()) {
|
|
1969
|
+
if (avatar && avatar.textContent && !this._cfg.getAvatarProvider() && !this._avatarFromProvider) {
|
|
1757
1970
|
avatar.textContent = this._getAvatarInitial();
|
|
1758
1971
|
}
|
|
1759
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());
|
|
1760
1975
|
}
|
|
1761
1976
|
_updateContent() {
|
|
1762
1977
|
if (!this._contentEl) return;
|
|
@@ -1765,6 +1980,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1765
1980
|
this._updateWaiting();
|
|
1766
1981
|
return;
|
|
1767
1982
|
}
|
|
1983
|
+
this._drawnWithVersion = renderingVersionOf(this._cfg);
|
|
1768
1984
|
this._contentEl.style.display = "";
|
|
1769
1985
|
runWithConfig(
|
|
1770
1986
|
this._cfg,
|
|
@@ -1802,6 +2018,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1802
2018
|
}
|
|
1803
2019
|
_renderSegments() {
|
|
1804
2020
|
if (!this._segmentsEl) return;
|
|
2021
|
+
this._drawnWithVersion = renderingVersionOf(this._cfg);
|
|
1805
2022
|
this._segmentsEl.innerHTML = "";
|
|
1806
2023
|
for (const segment of this._segments) {
|
|
1807
2024
|
const renderer = resolveSegmentRenderer(segment.type, this._cfg);
|
|
@@ -1848,8 +2065,16 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1848
2065
|
timestampEl.textContent = "";
|
|
1849
2066
|
}
|
|
1850
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
|
+
*/
|
|
1851
2074
|
_getAriaLabel() {
|
|
1852
2075
|
const locale = this._cfg.getLocale();
|
|
2076
|
+
const name = this.getAttribute("name");
|
|
2077
|
+
if (name) return (locale.messageFrom || "Message from {name}").replace("{name}", () => name);
|
|
1853
2078
|
return this._role === "user" ? locale.yourMessage ?? "Your message" : locale.assistantResponse ?? "Assistant response";
|
|
1854
2079
|
}
|
|
1855
2080
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -1907,9 +2132,9 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1907
2132
|
*
|
|
1908
2133
|
* An attachment's `url` is documented as "URL or data URI" and a storage adapter
|
|
1909
2134
|
* re-mints it when a conversation is restored, so it is the app's value, not the
|
|
1910
|
-
* composer's —
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
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.
|
|
1913
2138
|
*
|
|
1914
2139
|
* `blob:` is added because it is the shape core itself mints — `filesToAttachments`
|
|
1915
2140
|
* calls `URL.createObjectURL`, and `AparteMessage.attachments` documents the same
|
|
@@ -1930,21 +2155,12 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1930
2155
|
/**
|
|
1931
2156
|
* The branch arrows are handled by DELEGATION, on this element, bound once.
|
|
1932
2157
|
*
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
* all — not late, nothing. Invisible on a fast machine; reproducible on
|
|
1937
|
-
* WebKit-Linux in CI, where `‹` left the picker on "2 / 2" and a 20-second
|
|
1938
|
-
* assertion watched it stay there.
|
|
1939
|
-
*
|
|
1940
|
-
* Delegation makes `_render()` irrelevant to it: the listener lives on the host,
|
|
1941
|
-
* which is never replaced, and `closest()` finds whichever button exists at the
|
|
1942
|
-
* moment of the click. It is also less work — one listener per bubble instead of
|
|
1943
|
-
* 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.
|
|
1944
2161
|
*
|
|
1945
2162
|
* Bound in `connectedCallback` and removed in `disconnectedCallback` as a stable
|
|
1946
|
-
* field,
|
|
1947
|
-
* stacked listeners twice before (the viewport, and `aparte-select`).
|
|
2163
|
+
* field, so a re-connect never stacks listeners.
|
|
1948
2164
|
*/
|
|
1949
2165
|
_onBranchPickerClick = (event) => {
|
|
1950
2166
|
const target = event.target;
|
|
@@ -1963,7 +2179,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1963
2179
|
/**
|
|
1964
2180
|
* A link the MODEL wrote is about to be followed. Announced as a cancelable event
|
|
1965
2181
|
* before the browser acts, so a host can route it — an external browser, a
|
|
1966
|
-
* confirmation, an embedded view — without intercepting the DOM
|
|
2182
|
+
* confirmation, an embedded view — without intercepting the DOM. With
|
|
1967
2183
|
* no listener the browser follows it, which the sanitizer has already made open in
|
|
1968
2184
|
* a new tab for an external URL; `preventDefault()` on the event cancels the click.
|
|
1969
2185
|
*
|
|
@@ -2092,7 +2308,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2092
2308
|
}
|
|
2093
2309
|
this._actionBarEl.innerHTML = buttons.join("");
|
|
2094
2310
|
this._syncCopyAction();
|
|
2095
|
-
this._appendCustomActions(
|
|
2311
|
+
this._appendCustomActions();
|
|
2096
2312
|
this._applyTranscriptBusy();
|
|
2097
2313
|
this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
|
|
2098
2314
|
btn.addEventListener("click", (e) => this._handleActionClick(e));
|
|
@@ -2102,18 +2318,14 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2102
2318
|
/**
|
|
2103
2319
|
* `role="toolbar"` promises a keyboard model. This is it.
|
|
2104
2320
|
*
|
|
2105
|
-
*
|
|
2106
|
-
*
|
|
2107
|
-
*
|
|
2108
|
-
* stops per message, so the role described a behaviour that did not exist: a reader
|
|
2109
|
-
* told to press Right heard nothing move, and tabbing through a long transcript
|
|
2110
|
-
* 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.
|
|
2111
2324
|
*
|
|
2112
2325
|
* The roving index is re-applied from `_syncFooterVisibility`, which is the single
|
|
2113
|
-
* funnel all three build paths
|
|
2114
|
-
*
|
|
2115
|
-
*
|
|
2116
|
-
* 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.
|
|
2117
2329
|
*
|
|
2118
2330
|
* DISABLED buttons are skipped, not merely un-tabbable: while the transcript is busy
|
|
2119
2331
|
* retry and edit are disabled, and a toolbar whose arrows stop on a dead control
|
|
@@ -2161,9 +2373,9 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2161
2373
|
buttons[next]?.focus();
|
|
2162
2374
|
};
|
|
2163
2375
|
/**
|
|
2164
|
-
* An empty action bar is not a bar: with every action off it
|
|
2165
|
-
* `role="toolbar"` with nothing in it (announced as such)
|
|
2166
|
-
*
|
|
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
|
|
2167
2379
|
* contents — the footer stays as long as the branch picker or the bar has
|
|
2168
2380
|
* something to show.
|
|
2169
2381
|
*/
|
|
@@ -2177,7 +2389,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2177
2389
|
this.querySelector(".aparte-message")?.toggleAttribute("data-branches", !pickerHidden);
|
|
2178
2390
|
}
|
|
2179
2391
|
/** Append the registered custom action buttons for this bubble's role. */
|
|
2180
|
-
_appendCustomActions(
|
|
2392
|
+
_appendCustomActions() {
|
|
2181
2393
|
if (!this._actionBarEl) return;
|
|
2182
2394
|
for (const a2 of this._cfg.getActions("bubble")) {
|
|
2183
2395
|
const roles = a2.bubble?.roles ?? ["user", "assistant"];
|
|
@@ -2188,8 +2400,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2188
2400
|
btn.dataset["action"] = `custom:${a2.id}`;
|
|
2189
2401
|
btn.setAttribute("aria-label", a2.label);
|
|
2190
2402
|
btn.setAttribute("title", a2.label);
|
|
2191
|
-
|
|
2192
|
-
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 ?? "";
|
|
2193
2404
|
this._actionBarEl.appendChild(btn);
|
|
2194
2405
|
}
|
|
2195
2406
|
}
|
|
@@ -2378,6 +2589,8 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2378
2589
|
if (this._contentEl) this._contentEl.style.display = "";
|
|
2379
2590
|
this.querySelector(".aparte-message")?.removeAttribute("data-editing");
|
|
2380
2591
|
this._editing = false;
|
|
2592
|
+
this._redrawFrameIfChanged();
|
|
2593
|
+
this._redrawIfStale();
|
|
2381
2594
|
this._updateActionBar();
|
|
2382
2595
|
const returnTo = this._editReturnAction;
|
|
2383
2596
|
this._editReturnAction = null;
|
|
@@ -2434,6 +2647,9 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2434
2647
|
}
|
|
2435
2648
|
this._updateWaiting();
|
|
2436
2649
|
if (wasStreaming && !streaming) this._updateContent();
|
|
2650
|
+
if (wasStreaming && !streaming && this._segments.length > 0 && renderingVersionOf(this._cfg) !== this._drawnWithVersion) {
|
|
2651
|
+
this._renderSegments();
|
|
2652
|
+
}
|
|
2437
2653
|
}
|
|
2438
2654
|
}
|
|
2439
2655
|
if (!customElements.get("aparte-chat-bubble")) {
|
|
@@ -2596,6 +2812,15 @@ class AparteChatStatus extends HTMLElement {
|
|
|
2596
2812
|
_updateText(text) {
|
|
2597
2813
|
const container = this.querySelector(".aparte-status-container");
|
|
2598
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
|
+
}
|
|
2599
2824
|
this._writeText(text);
|
|
2600
2825
|
container.setAttribute("aria-label", text || this._word());
|
|
2601
2826
|
}
|
|
@@ -2655,17 +2880,14 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2655
2880
|
* Not `readonly`: a test shortens it to run past the window's end in a few frames. */
|
|
2656
2881
|
_settleWindowMs = 400;
|
|
2657
2882
|
/**
|
|
2658
|
-
* Until when a smooth scroll of ours is in flight
|
|
2659
|
-
* bottom with `scrollTo({ behavior: 'smooth' })
|
|
2660
|
-
* first tokens
|
|
2661
|
-
*
|
|
2662
|
-
*
|
|
2663
|
-
*
|
|
2664
|
-
*
|
|
2665
|
-
*
|
|
2666
|
-
* (a second smooth `scrollTo` is continuous) instead of writing `scrollTop`; the
|
|
2667
|
-
* browser's `scrollend` closes it early where supported. Streaming is instant again
|
|
2668
|
-
* 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.
|
|
2669
2891
|
*/
|
|
2670
2892
|
_glideUntil = 0;
|
|
2671
2893
|
/** The glide's budget when `scrollend` is not there to close it: Chromium's smooth scroll over a viewport takes ~300–500 ms. */
|
|
@@ -2689,11 +2911,22 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2689
2911
|
_maxRenderedBubbles = 1e3;
|
|
2690
2912
|
_resizeObserver = null;
|
|
2691
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();
|
|
2692
2925
|
/**
|
|
2693
2926
|
* Opening or closing a `<details>` — a tool row, a reasoning block, an artifact card, a host's
|
|
2694
2927
|
* own — changes the transcript's height through an ATTRIBUTE, which neither observer sees: the
|
|
2695
2928
|
* mutation observer watches children, and the resize observer leaves the wrapper out on
|
|
2696
|
-
* purpose. `toggle` does not bubble, so it is caught on the way down
|
|
2929
|
+
* purpose. `toggle` does not bubble, so it is caught on the way down.
|
|
2697
2930
|
*/
|
|
2698
2931
|
_onDisclosureToggle = () => {
|
|
2699
2932
|
this._scheduleSpacerUpdate();
|
|
@@ -2778,8 +3011,8 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2778
3011
|
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
2779
3012
|
}
|
|
2780
3013
|
/**
|
|
2781
|
-
* A locale switch changes the reading direction, and `dir`
|
|
2782
|
-
*
|
|
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.
|
|
2783
3016
|
* Only OUR config: an instance-scoped change elsewhere must not touch us.
|
|
2784
3017
|
*/
|
|
2785
3018
|
_onConfigChange = (e) => {
|
|
@@ -2796,15 +3029,14 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2796
3029
|
* A scrollable region that no element can hold focus in cannot be scrolled from
|
|
2797
3030
|
* the keyboard in WebKit: Chromium and Firefox hand an unfocusable overflow box a
|
|
2798
3031
|
* caret or a "scroller" tab stop of their own, Safari does not. So a plain-text
|
|
2799
|
-
* transcript — no links, no code blocks, nothing focusable inside —
|
|
2800
|
-
* past the first screen for a Safari keyboard user, with no error and
|
|
2801
|
-
* 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.
|
|
2802
3035
|
*
|
|
2803
|
-
* `tabindex="0"` on the surface itself, in BOTH DOM modes.
|
|
2804
|
-
*
|
|
2805
|
-
*
|
|
2806
|
-
*
|
|
2807
|
-
* 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.
|
|
2808
3040
|
*
|
|
2809
3041
|
* The name ships WITH the tab stop rather than after it: a focusable `role="log"`
|
|
2810
3042
|
* with no accessible name is announced as an unnamed region, which is a worse
|
|
@@ -2933,10 +3165,10 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2933
3165
|
* The id of the message currently streaming, if any — searched across the whole
|
|
2934
3166
|
* TREE, and answering with an OFF-HEAD one first when there are two.
|
|
2935
3167
|
*
|
|
2936
|
-
*
|
|
3168
|
+
* Not a scan of `getMessages()`, the active path: a retry or an edit on an
|
|
2937
3169
|
* earlier bubble calls `addSiblingOf`, which switches the branch to the new pending
|
|
2938
|
-
* reply
|
|
2939
|
-
* 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.
|
|
2940
3172
|
*
|
|
2941
3173
|
* A new pending reply is itself marked streaming, so "the first one found" would
|
|
2942
3174
|
* answer the head and let the write through — the very case this exists to refuse.
|
|
@@ -2959,10 +3191,10 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2959
3191
|
/**
|
|
2960
3192
|
* While a reply streams, the transcript is read-only except for Stop.
|
|
2961
3193
|
*
|
|
2962
|
-
* The streaming message's own footer
|
|
2963
|
-
*
|
|
2964
|
-
* re-
|
|
2965
|
-
* 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
|
|
2966
3198
|
* decides it is the whole transcript's, so it lives on the viewport (`data-busy`)
|
|
2967
3199
|
* and is pushed to the bubbles it holds; a bubble mounted later, under a
|
|
2968
3200
|
* framework's DOM, reads the attribute itself when it connects.
|
|
@@ -3417,10 +3649,13 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3417
3649
|
}
|
|
3418
3650
|
}
|
|
3419
3651
|
/**
|
|
3420
|
-
* 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.
|
|
3421
3655
|
*/
|
|
3422
3656
|
setAutoScroll(enabled) {
|
|
3423
3657
|
this._isAutoScrollEnabled = enabled;
|
|
3658
|
+
if (!enabled) this._readerInputAt = performance.now();
|
|
3424
3659
|
}
|
|
3425
3660
|
/**
|
|
3426
3661
|
* Signal that a framework (e.g. Angular) manages the bubble DOM.
|
|
@@ -3769,7 +4004,19 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3769
4004
|
});
|
|
3770
4005
|
}
|
|
3771
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();
|
|
3772
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();
|
|
4018
|
+
});
|
|
4019
|
+
this._observeTail();
|
|
3773
4020
|
this.addEventListener("toggle", this._onDisclosureToggle, true);
|
|
3774
4021
|
const observeTarget = this._frameworkManagedDOM ? this : wrapper;
|
|
3775
4022
|
if (observeTarget) {
|
|
@@ -3822,10 +4069,9 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3822
4069
|
* the transcript's padding plus the scrollbar gutter the scroller reserves on both
|
|
3823
4070
|
* edges, at whatever step its container query has taken — as
|
|
3824
4071
|
* `--aparte-transcript-inset`. The composer pads by it (composer.css), so its box
|
|
3825
|
-
* lands on the row's box at every width. The
|
|
3826
|
-
*
|
|
3827
|
-
*
|
|
3828
|
-
* 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
|
|
3829
4075
|
* same reason `_updateOverlayInset` is. On the HOST, not on this element: the
|
|
3830
4076
|
* composer is a sibling, and a custom property only travels down.
|
|
3831
4077
|
*/
|
|
@@ -3856,7 +4102,8 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3856
4102
|
const drop = this._lastScrollTop - top;
|
|
3857
4103
|
const churn = Math.abs(height - this._lastScrollHeight);
|
|
3858
4104
|
const now = performance.now();
|
|
3859
|
-
const
|
|
4105
|
+
const shrank = height < this._lastScrollHeight - 1;
|
|
4106
|
+
const afterMutation = now - this._lastMutationAt < this._settleShadowMs && (drop <= this._settleBoundPx || shrank);
|
|
3860
4107
|
const settlingOurs = now - this._ownScrollAt < this._ownScrollWindowMs && now - this._readerInputAt >= this._ownScrollWindowMs && !this._pointerHeld && (drop <= churn + 2 || afterMutation);
|
|
3861
4108
|
const readerWentUp = drop > 1 && !settlingOurs;
|
|
3862
4109
|
const readerHandOnIt = drop > 1 && now - this._readerInputAt < this._ownScrollWindowMs;
|
|
@@ -3899,9 +4146,8 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3899
4146
|
* The browser says a scroll came to rest — the glide is over ONLY if it rested at the
|
|
3900
4147
|
* bottom. WebKit fires `scrollend` when a smooth scroll is REPLACED by another (our own
|
|
3901
4148
|
* re-target), 45–60 ms into the glide and hundreds of pixels short; taken at its word,
|
|
3902
|
-
* that
|
|
3903
|
-
*
|
|
3904
|
-
* 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.
|
|
3905
4151
|
*/
|
|
3906
4152
|
_handleScrollEnd() {
|
|
3907
4153
|
if (!this._container) return;
|
|
@@ -3911,37 +4157,27 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3911
4157
|
/**
|
|
3912
4158
|
* Confirm over the next few frames that we actually reached the bottom.
|
|
3913
4159
|
*
|
|
3914
|
-
* One assignment is not enough
|
|
3915
|
-
*
|
|
3916
|
-
*
|
|
3917
|
-
*
|
|
3918
|
-
*
|
|
3919
|
-
*
|
|
3920
|
-
*
|
|
3921
|
-
* is the right rule for keeping auto-follow armed and the wrong one as a
|
|
3922
|
-
* definition of "anchored".
|
|
3923
|
-
*
|
|
3924
|
-
* Ruled out on the way here, so nobody pays for it twice: not a WebKit
|
|
3925
|
-
* padding-accounting difference (a probe writing `scrollTop = 1e7` reached
|
|
3926
|
-
* exactly `scrollHeight - clientHeight`), not a missing `characterData`
|
|
3927
|
-
* 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".
|
|
3928
4167
|
*
|
|
3929
4168
|
* A BOUNDED retry, not one corrective frame: a single frame lands on the same
|
|
3930
|
-
* stale layout and
|
|
3931
|
-
*
|
|
3932
|
-
*
|
|
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.
|
|
3933
4172
|
*
|
|
3934
|
-
* Bounded by TIME, and it does not stop at the first closed gap
|
|
3935
|
-
*
|
|
3936
|
-
*
|
|
3937
|
-
*
|
|
3938
|
-
*
|
|
3939
|
-
*
|
|
3940
|
-
*
|
|
3941
|
-
*
|
|
3942
|
-
* that fails exactly on the slow engine, so the budget is real milliseconds now,
|
|
3943
|
-
* and a closed gap only ends the chain when the window is over. One chain, not
|
|
3944
|
-
* 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.
|
|
3945
4181
|
*/
|
|
3946
4182
|
_settleAtBottom() {
|
|
3947
4183
|
this._settleUntil = performance.now() + this._settleWindowMs;
|
|
@@ -3973,6 +4209,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3973
4209
|
this._glideSettleTimer = null;
|
|
3974
4210
|
if (!this._container || !this._isAutoScrollEnabled || this._gliding()) return;
|
|
3975
4211
|
const max = this._container.scrollHeight - this._container.clientHeight;
|
|
4212
|
+
if (this._container.scrollTop < this._lastScrollTop - 1) return;
|
|
3976
4213
|
if (max - this._container.scrollTop > 1) this._scrollToBottom();
|
|
3977
4214
|
}, this._glideWindowMs + 16);
|
|
3978
4215
|
} else {
|
|
@@ -3988,10 +4225,10 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3988
4225
|
*
|
|
3989
4226
|
* The two answer different questions: the flag is intent ("should new content
|
|
3990
4227
|
* pull the view down"), the button is a fact ("is there anything below the
|
|
3991
|
-
* fold"). Mirroring the flag
|
|
4228
|
+
* fold"). Mirroring the flag would make the button lie whenever the two diverge —
|
|
3992
4229
|
* `navigateBranch` deliberately disarms auto-follow, so swapping a branch while
|
|
3993
|
-
* already at the bottom of a scrollable transcript
|
|
3994
|
-
* 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
|
|
3995
4232
|
* post-mutation frame and after a path swap, so it converges to the truth
|
|
3996
4233
|
* whatever a framework's render timing does in between.
|
|
3997
4234
|
*/
|
|
@@ -4044,13 +4281,29 @@ class AparteChatViewport extends HTMLElement {
|
|
|
4044
4281
|
return;
|
|
4045
4282
|
}
|
|
4046
4283
|
const contentBelowUserTop = scrollHeightWithoutSpacer - userTopInContent;
|
|
4047
|
-
const
|
|
4284
|
+
const covered = parseFloat(getComputedStyle(this._container).scrollPaddingTop) || 0;
|
|
4285
|
+
const needed = this._container.clientHeight - covered - contentBelowUserTop;
|
|
4048
4286
|
const maxSpacer = this._container.clientHeight;
|
|
4049
4287
|
this._setSpacerHeight(Math.min(Math.max(0, needed), maxSpacer));
|
|
4050
4288
|
if (this._isAutoScrollEnabled) {
|
|
4051
4289
|
this._scrollToBottom();
|
|
4052
4290
|
}
|
|
4053
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
|
+
}
|
|
4054
4307
|
/**
|
|
4055
4308
|
* Schedule a spacer recalculation on the next animation frame.
|
|
4056
4309
|
* Batches multiple rapid calls (e.g. during token streaming) into one.
|
|
@@ -4106,6 +4359,9 @@ class AparteChatViewport extends HTMLElement {
|
|
|
4106
4359
|
this.removeEventListener("aparte-branch-navigate", this._onBranchNavigate);
|
|
4107
4360
|
this._resizeObserver?.disconnect();
|
|
4108
4361
|
this._mutationObserver?.disconnect();
|
|
4362
|
+
this._tailObserver?.disconnect();
|
|
4363
|
+
this._tailObserver = null;
|
|
4364
|
+
this._tail = [];
|
|
4109
4365
|
this.removeEventListener("toggle", this._onDisclosureToggle, true);
|
|
4110
4366
|
this._overlayObserver?.disconnect();
|
|
4111
4367
|
this._overlayObserver = null;
|
|
@@ -4141,14 +4397,11 @@ class AparteComposer extends HTMLElement {
|
|
|
4141
4397
|
/**
|
|
4142
4398
|
* Who owns the one panel slot, and how to tell them they lost it.
|
|
4143
4399
|
*
|
|
4144
|
-
* There is exactly one slot
|
|
4145
|
-
*
|
|
4146
|
-
*
|
|
4147
|
-
*
|
|
4148
|
-
*
|
|
4149
|
-
* turn finished lost its panel while `<aparte-elicitation>` kept `_pending` set,
|
|
4150
|
-
* so the request never settled AND every later request was short-circuited for the
|
|
4151
|
-
* 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.
|
|
4152
4405
|
*/
|
|
4153
4406
|
_panelToken = null;
|
|
4154
4407
|
_panelOnEvict = null;
|
|
@@ -4171,10 +4424,10 @@ class AparteComposer extends HTMLElement {
|
|
|
4171
4424
|
*
|
|
4172
4425
|
* A wrapper runs `AparteChatHost.bind()` — which calls `attachConfig` — from its
|
|
4173
4426
|
* POST-mount hook, so this element connects BEFORE the boundary exists. Caching
|
|
4174
|
-
*
|
|
4175
|
-
* locale
|
|
4176
|
-
* `requireModelSelection` gate
|
|
4177
|
-
*
|
|
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.
|
|
4178
4431
|
*/
|
|
4179
4432
|
get _cfg() {
|
|
4180
4433
|
return resolveConfig(this);
|
|
@@ -4195,10 +4448,10 @@ class AparteComposer extends HTMLElement {
|
|
|
4195
4448
|
* Mirror `locale.direction` onto ourselves, so everything inside — input, buttons,
|
|
4196
4449
|
* the toolbar row and whatever the consumer put in it — inherits it.
|
|
4197
4450
|
*
|
|
4198
|
-
*
|
|
4199
|
-
*
|
|
4200
|
-
*
|
|
4201
|
-
*
|
|
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`.
|
|
4202
4455
|
*
|
|
4203
4456
|
* One attribute on the host rather than a stamp per child: inheritance is the
|
|
4204
4457
|
* mechanism, so nothing needs to know about the consumer's markup.
|
|
@@ -4252,9 +4505,9 @@ class AparteComposer extends HTMLElement {
|
|
|
4252
4505
|
/**
|
|
4253
4506
|
* The attribute is the state, so the property writes it. It has to exist: React 19
|
|
4254
4507
|
* and Svelte assign the PROPERTY whenever the element has one of that name, and a
|
|
4255
|
-
* getter alone
|
|
4256
|
-
* as valid — throw and take the render down. `presenceOn` because `''` is what
|
|
4257
|
-
* 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.
|
|
4258
4511
|
*/
|
|
4259
4512
|
set disabled(value) {
|
|
4260
4513
|
this.toggleAttribute("disabled", presenceOn(value));
|
|
@@ -4340,27 +4593,23 @@ class AparteComposer extends HTMLElement {
|
|
|
4340
4593
|
*
|
|
4341
4594
|
* While a panel is up, the composer is answering a QUESTION, not composing a
|
|
4342
4595
|
* message — so the affordances that lead nowhere go away with the text input:
|
|
4343
|
-
* the attachment picker above all, which
|
|
4344
|
-
* being asked something
|
|
4345
|
-
* real session). Ratified decision #8: an affordance nothing can honour is not
|
|
4346
|
-
* 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.
|
|
4347
4598
|
*
|
|
4348
4599
|
* What STAYS, deliberately: the attachments strip, because pending attachments
|
|
4349
4600
|
* are the user's state and not an action to offer — hiding them would look like
|
|
4350
4601
|
* losing them; and the toolbar, because switching model still does something.
|
|
4351
4602
|
*
|
|
4352
4603
|
* The send button is the part the PANEL decides, through `mode`. `'submit'`
|
|
4353
|
-
* keeps it; `'none'` says this panel has no act for it and it is not drawn.
|
|
4354
|
-
*
|
|
4355
|
-
*
|
|
4356
|
-
*
|
|
4357
|
-
*
|
|
4358
|
-
* 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.
|
|
4359
4609
|
*
|
|
4360
|
-
*
|
|
4361
|
-
*
|
|
4362
|
-
*
|
|
4363
|
-
* 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.
|
|
4364
4613
|
*
|
|
4365
4614
|
* Returns the TOKEN for this panel. Pass it back to `hidePanel` so a presenter
|
|
4366
4615
|
* that has already lost the slot cannot close the panel that replaced it, and
|
|
@@ -4399,18 +4648,6 @@ class AparteComposer extends HTMLElement {
|
|
|
4399
4648
|
* no token it closes whatever is there, which is what a consumer driving the
|
|
4400
4649
|
* composer directly means.
|
|
4401
4650
|
*
|
|
4402
|
-
* BOTH forms notify. The no-token form used to call `_teardownPanel` directly,
|
|
4403
|
-
* which nulls `_panelOnEvict` without ever calling it — so the documented public
|
|
4404
|
-
* `hidePanel()` closed an open approval panel and left its request pending
|
|
4405
|
-
* forever. The turn hung on "waiting for you", and because
|
|
4406
|
-
* `AparteConfig.requestUserInput` chains each request on the previous one, NO
|
|
4407
|
-
* further question or approval on that config was ever presented again, for the
|
|
4408
|
-
* life of the page.
|
|
4409
|
-
*
|
|
4410
|
-
* The old JSDoc justified the silent branch as "what `reset()` needs". It was not:
|
|
4411
|
-
* `reset()` calls `_evictPanel()`, which notifies. The branch had no consumer and
|
|
4412
|
-
* one failure mode.
|
|
4413
|
-
*
|
|
4414
4651
|
* The two forms differ on purpose, and the difference is who already knows:
|
|
4415
4652
|
*
|
|
4416
4653
|
* - **With a matching token** the OWNER is closing its own panel, which is what
|
|
@@ -4470,10 +4707,10 @@ class AparteComposer extends HTMLElement {
|
|
|
4470
4707
|
/**
|
|
4471
4708
|
* Publish the panel mode as an attribute, so CSS can act on it.
|
|
4472
4709
|
*
|
|
4473
|
-
* The same reasoning `data-panel-active` is set for
|
|
4474
|
-
*
|
|
4475
|
-
*
|
|
4476
|
-
*
|
|
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.
|
|
4477
4714
|
*/
|
|
4478
4715
|
_reflectPanelMode() {
|
|
4479
4716
|
if (this._panelActive) this.setAttribute("data-panel-mode", this._panelMode);
|
|
@@ -4583,15 +4820,12 @@ class AparteComposer extends HTMLElement {
|
|
|
4583
4820
|
* Which chat this composer belongs to: its `target` attribute, or the id of the
|
|
4584
4821
|
* chat host above it.
|
|
4585
4822
|
*
|
|
4586
|
-
* All four wrappers set `target` themselves
|
|
4587
|
-
*
|
|
4588
|
-
*
|
|
4589
|
-
*
|
|
4590
|
-
*
|
|
4591
|
-
* 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.
|
|
4592
4828
|
*
|
|
4593
|
-
* Found by a two-chat test written for the elicitation presenter, which is the
|
|
4594
|
-
* only reason it surfaced: raw core with two chats is a shape nothing exercised.
|
|
4595
4829
|
* The hosts matched are the ones `aparte-chat-bubble._resolveTargetId()` matches,
|
|
4596
4830
|
* for the reason written there — Angular's wrapper root IS `<aparte-chat>`, the
|
|
4597
4831
|
* other three render a `[data-aparte-chat]` div.
|
|
@@ -4629,11 +4863,11 @@ class AparteComposer extends HTMLElement {
|
|
|
4629
4863
|
* Whether a terminal event ends the turn this composer is following.
|
|
4630
4864
|
*
|
|
4631
4865
|
* `_isForThisComposer` answers "is this chat's?", and both turns of a retry belong
|
|
4632
|
-
* to the same chat: the client aborts the superseded reply
|
|
4633
|
-
* `run-aborted` for it,
|
|
4634
|
-
*
|
|
4635
|
-
*
|
|
4636
|
-
*
|
|
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.
|
|
4637
4871
|
*/
|
|
4638
4872
|
_isForThisTurn(e) {
|
|
4639
4873
|
const id = e.detail?.messageId;
|
|
@@ -4661,11 +4895,7 @@ class AparteComposerInput extends HTMLElement {
|
|
|
4661
4895
|
_maxHeight = 200;
|
|
4662
4896
|
_minHeight = 44;
|
|
4663
4897
|
/**
|
|
4664
|
-
* Re-measures the editor when its WIDTH changes
|
|
4665
|
-
* (connect, next frame, fonts.ready) all miss the same case: a box not yet laid
|
|
4666
|
-
* out — the placeholder wraps in a 0 px column, `scrollHeight` reads ~300 px, and
|
|
4667
|
-
* that became the inline height until the first keystroke. Width is the variable
|
|
4668
|
-
* none of them watched.
|
|
4898
|
+
* Re-measures the editor when its WIDTH changes.
|
|
4669
4899
|
*/
|
|
4670
4900
|
_resizeObserver = null;
|
|
4671
4901
|
_unsubscribes = [];
|
|
@@ -4850,9 +5080,10 @@ class AparteComposerInput extends HTMLElement {
|
|
|
4850
5080
|
e.preventDefault();
|
|
4851
5081
|
const cd = e.clipboardData;
|
|
4852
5082
|
if (!cd) return;
|
|
4853
|
-
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;
|
|
4854
5085
|
if (imageFile) {
|
|
4855
|
-
|
|
5086
|
+
root?.addAttachments([imageFile]);
|
|
4856
5087
|
return;
|
|
4857
5088
|
}
|
|
4858
5089
|
const text = cd.getData("text/plain");
|
|
@@ -4914,9 +5145,8 @@ class AparteComposerSend extends HTMLElement {
|
|
|
4914
5145
|
*
|
|
4915
5146
|
* This button has four meanings — send, stop, submit an answer, advance to the
|
|
4916
5147
|
* next question — and three of them are decided by state it does not own: the
|
|
4917
|
-
* root's `streaming`, and this payload. It
|
|
4918
|
-
*
|
|
4919
|
-
* 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.
|
|
4920
5150
|
*/
|
|
4921
5151
|
_panel = null;
|
|
4922
5152
|
// Bound handler
|
|
@@ -4999,14 +5229,13 @@ class AparteComposerSend extends HTMLElement {
|
|
|
4999
5229
|
this._syncState();
|
|
5000
5230
|
}
|
|
5001
5231
|
/**
|
|
5002
|
-
* Panel open: this one button
|
|
5232
|
+
* Panel open: this one button means "submit the answer".
|
|
5003
5233
|
*
|
|
5004
|
-
* The icon
|
|
5005
|
-
*
|
|
5006
|
-
*
|
|
5007
|
-
*
|
|
5008
|
-
*
|
|
5009
|
-
* 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.
|
|
5010
5239
|
*/
|
|
5011
5240
|
_syncPanelState() {
|
|
5012
5241
|
const panel = this._panel;
|
|
@@ -5171,7 +5400,7 @@ class AparteComposerAttachments extends HTMLElement {
|
|
|
5171
5400
|
this.hidden = files.length === 0;
|
|
5172
5401
|
this._revokeUrls();
|
|
5173
5402
|
const cfg = resolveConfig(this);
|
|
5174
|
-
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);
|
|
5175
5404
|
this.innerHTML = files.map((file) => {
|
|
5176
5405
|
const name = escapeHtml(file.name);
|
|
5177
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>`;
|
|
@@ -5468,6 +5697,21 @@ class AparteComposerToolbar extends HTMLElement {
|
|
|
5468
5697
|
if (!customElements.get("aparte-composer-toolbar")) {
|
|
5469
5698
|
customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
|
|
5470
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
|
+
}
|
|
5471
5715
|
function rowSelector(attr, id) {
|
|
5472
5716
|
return `[${attr}="${cssEscape(id)}"]`;
|
|
5473
5717
|
}
|
|
@@ -5499,11 +5743,10 @@ class AparteConversationList extends HTMLElement {
|
|
|
5499
5743
|
* Let the list finish the gesture through the registered conversation manager.
|
|
5500
5744
|
* Reflected as the `manage` attribute.
|
|
5501
5745
|
*
|
|
5502
|
-
* The property
|
|
5503
|
-
* and
|
|
5504
|
-
*
|
|
5505
|
-
* turning management off
|
|
5506
|
-
* 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.
|
|
5507
5750
|
*/
|
|
5508
5751
|
get manage() {
|
|
5509
5752
|
return this.hasAttribute("manage");
|
|
@@ -5571,13 +5814,12 @@ class AparteConversationList extends HTMLElement {
|
|
|
5571
5814
|
/**
|
|
5572
5815
|
* A press holds every render until its click has landed. The host re-assigns
|
|
5573
5816
|
* `conversations` on every manager change (a save moves a row, a select marks one),
|
|
5574
|
-
* and a row replaced between `mousedown` and `mouseup`
|
|
5575
|
-
* it to the list, where the delegated handler
|
|
5576
|
-
*
|
|
5577
|
-
*
|
|
5578
|
-
*
|
|
5579
|
-
*
|
|
5580
|
-
* 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.
|
|
5581
5823
|
*/
|
|
5582
5824
|
_pressed = false;
|
|
5583
5825
|
_renderAfterPress = false;
|
|
@@ -5615,10 +5857,12 @@ class AparteConversationList extends HTMLElement {
|
|
|
5615
5857
|
const held = focused && this.contains(focused) ? focused.closest("[data-select-id], [data-more-id]") : null;
|
|
5616
5858
|
const restore = held?.dataset["selectId"] ? rowSelector("data-select-id", held.dataset["selectId"]) : held?.dataset["moreId"] ? rowSelector("data-more-id", held.dataset["moreId"]) : null;
|
|
5617
5859
|
const heldRow = held?.closest("[data-conv-id]") ?? null;
|
|
5618
|
-
const
|
|
5860
|
+
const visible = (el2) => !el2.closest("[hidden]");
|
|
5861
|
+
const heldIndex = heldRow ? Array.from(this.querySelectorAll("[data-conv-id]")).filter(visible).indexOf(heldRow) : -1;
|
|
5619
5862
|
const loading = this.hasAttribute("loading");
|
|
5620
5863
|
this.setAttribute("aria-busy", loading ? "true" : "false");
|
|
5621
5864
|
if (loading) {
|
|
5865
|
+
this.removeAttribute("data-no-match");
|
|
5622
5866
|
const row = '<span class="aparte-skeleton aparte-skeleton--text"></span>';
|
|
5623
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>';
|
|
5624
5868
|
const status = this.querySelector(".aparte-conv-list-loading-status");
|
|
@@ -5629,14 +5873,22 @@ class AparteConversationList extends HTMLElement {
|
|
|
5629
5873
|
else say();
|
|
5630
5874
|
return;
|
|
5631
5875
|
}
|
|
5632
|
-
|
|
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();
|
|
5633
5885
|
if (!restore) return;
|
|
5634
5886
|
const back = this.querySelector(restore);
|
|
5635
|
-
if (back) {
|
|
5887
|
+
if (back && visible(back)) {
|
|
5636
5888
|
back.focus();
|
|
5637
5889
|
return;
|
|
5638
5890
|
}
|
|
5639
|
-
const rows = Array.from(this.querySelectorAll("[data-select-id]"));
|
|
5891
|
+
const rows = Array.from(this.querySelectorAll("[data-select-id]")).filter(visible);
|
|
5640
5892
|
const next = heldIndex >= 0 ? rows[heldIndex] ?? rows[rows.length - 1] : null;
|
|
5641
5893
|
if (next) {
|
|
5642
5894
|
next.focus();
|
|
@@ -5648,6 +5900,41 @@ class AparteConversationList extends HTMLElement {
|
|
|
5648
5900
|
}
|
|
5649
5901
|
this.focus();
|
|
5650
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
|
+
}
|
|
5651
5938
|
_t(key) {
|
|
5652
5939
|
const locale = resolveConfig(this).getLocale();
|
|
5653
5940
|
return locale[key] ?? APARTE_DEFAULT_LOCALE[key] ?? "";
|
|
@@ -5765,7 +6052,7 @@ class AparteConversationList extends HTMLElement {
|
|
|
5765
6052
|
}
|
|
5766
6053
|
/** The question the confirm step asks — the popover's accessible name while it asks it. */
|
|
5767
6054
|
_confirmQuestion(conv) {
|
|
5768
|
-
return this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat"));
|
|
6055
|
+
return this._t("deleteConversationConfirm").replace("{title}", () => conv.title || this._t("newChat"));
|
|
5769
6056
|
}
|
|
5770
6057
|
_confirmMarkup(conv) {
|
|
5771
6058
|
const question = escapeHtml(this._confirmQuestion(conv));
|
|
@@ -6141,10 +6428,9 @@ class AparteSuggestions extends HTMLElement {
|
|
|
6141
6428
|
/** `hidden` was written by this element, so it is this element's to lift. */
|
|
6142
6429
|
_hidOwn = false;
|
|
6143
6430
|
/*
|
|
6144
|
-
* `empty-only`
|
|
6145
|
-
*
|
|
6146
|
-
*
|
|
6147
|
-
* 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
|
|
6148
6434
|
* hides on the click rather than a frame later when the bubble lands — and because
|
|
6149
6435
|
* it is all there is to read when the messages are not `<aparte-chat-bubble>`s
|
|
6150
6436
|
* (a wrapper's `renderBubble`) or there is no transcript at all. A transcript seen
|
|
@@ -6175,9 +6461,8 @@ class AparteSuggestions extends HTMLElement {
|
|
|
6175
6461
|
*
|
|
6176
6462
|
* Both, because both arrive: React 19 and Svelte assign the PROPERTY whenever the
|
|
6177
6463
|
* element has one of that name, so `<aparte-suggestions suggestions='[…]'>` — the
|
|
6178
|
-
* documented spelling, typed valid by both wrappers —
|
|
6179
|
-
* string
|
|
6180
|
-
* (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.
|
|
6181
6466
|
*/
|
|
6182
6467
|
set suggestions(value) {
|
|
6183
6468
|
this._suggestions = typeof value === "string" ? parseSuggestions(value) : Array.isArray(value) ? value.filter(isSuggestion) : [];
|
|
@@ -6372,7 +6657,7 @@ const ringSvg = () => {
|
|
|
6372
6657
|
};
|
|
6373
6658
|
class AparteContext extends HTMLElement {
|
|
6374
6659
|
static get observedAttributes() {
|
|
6375
|
-
return ["window", "warn", "danger", "target", "variant"];
|
|
6660
|
+
return ["window", "warn", "danger", "target", "variant", "used"];
|
|
6376
6661
|
}
|
|
6377
6662
|
_used = null;
|
|
6378
6663
|
_level = null;
|
|
@@ -6406,10 +6691,18 @@ class AparteContext extends HTMLElement {
|
|
|
6406
6691
|
this._compactRequested = false;
|
|
6407
6692
|
};
|
|
6408
6693
|
_onRerender = () => this._render();
|
|
6409
|
-
/** Tokens the last turn reported
|
|
6694
|
+
/** Tokens the last turn reported — or `used` set, until a turn reports — else `null`. */
|
|
6410
6695
|
get used() {
|
|
6411
6696
|
return this._used;
|
|
6412
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
|
+
}
|
|
6413
6706
|
/** The window in force — the attribute, else the current model's. */
|
|
6414
6707
|
get window() {
|
|
6415
6708
|
return this._window();
|
|
@@ -6442,7 +6735,11 @@ class AparteContext extends HTMLElement {
|
|
|
6442
6735
|
window.removeEventListener("aparte-compact-error", this._onCompactError);
|
|
6443
6736
|
window.removeEventListener("aparte-config-change", this._onRerender);
|
|
6444
6737
|
}
|
|
6445
|
-
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
|
+
}
|
|
6446
6743
|
if (this.isConnected) this._render();
|
|
6447
6744
|
}
|
|
6448
6745
|
_window() {
|
|
@@ -6748,12 +7045,11 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6748
7045
|
* intersection observer reports which bubbles reach a reading band — the top 30% of
|
|
6749
7046
|
* the surface, from its very top so that the bubble a jump aligned there counts. The
|
|
6750
7047
|
* rule is a scrollspy's: the LAST question that has reached the band is the one the
|
|
6751
|
-
* reader is under.
|
|
6752
|
-
*
|
|
6753
|
-
*
|
|
6754
|
-
*
|
|
6755
|
-
*
|
|
6756
|
-
* 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.
|
|
6757
7053
|
*/
|
|
6758
7054
|
_observe(bubbles) {
|
|
6759
7055
|
this._unobserve();
|
|
@@ -6925,14 +7221,13 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6925
7221
|
* Scroll the transcript to a message. Announced first, cancelable; then a
|
|
6926
7222
|
* `scrollIntoView` on the bubble, smooth unless the reader prefers reduced motion.
|
|
6927
7223
|
* The clicked tick is the mark until the transcript has stopped moving: while a
|
|
6928
|
-
* smooth scroll crosses other questions the band would name each in turn
|
|
6929
|
-
* 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.
|
|
6930
7225
|
*
|
|
6931
7226
|
* Where the scroll lands is checked once it has settled, and corrected. The bubbles
|
|
6932
7227
|
* carry `content-visibility: auto`, so a bubble far from the reader has an estimated
|
|
6933
7228
|
* height until it is rendered: the scroll aims at an estimate, the bubbles above it
|
|
6934
|
-
* take their real size on the way, and the message ends up above or below the top
|
|
6935
|
-
*
|
|
7229
|
+
* take their real size on the way, and the message ends up above or below the top,
|
|
7230
|
+
* the further away the worse.
|
|
6936
7231
|
* A second `scrollIntoView` from a rendered neighbourhood lands; a few are allowed.
|
|
6937
7232
|
*/
|
|
6938
7233
|
jumpTo(messageId) {
|
|
@@ -6945,6 +7240,7 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6945
7240
|
if (!announced) return;
|
|
6946
7241
|
const bubble = this._bubbleOf(messageId);
|
|
6947
7242
|
if (!bubble) return;
|
|
7243
|
+
this._viewport()?.setAutoScroll?.(false);
|
|
6948
7244
|
this._scrollTo(bubble);
|
|
6949
7245
|
this._setCurrent(messageId);
|
|
6950
7246
|
this._hold(messageId);
|
|
@@ -6975,7 +7271,8 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6975
7271
|
const surface = this._settleSurface;
|
|
6976
7272
|
const bubble = id ? this._bubbleOf(id) : null;
|
|
6977
7273
|
if (id && bubble && surface && this._alignPasses < MAX_ALIGN_PASSES) {
|
|
6978
|
-
const
|
|
7274
|
+
const aim = surface.getBoundingClientRect().top + (parseFloat(getComputedStyle(surface).scrollPaddingTop) || 0);
|
|
7275
|
+
const off = bubble.getBoundingClientRect().top - aim;
|
|
6979
7276
|
if (Math.abs(off) > ALIGN_TOLERANCE) {
|
|
6980
7277
|
this._alignPasses++;
|
|
6981
7278
|
this._scrollTo(bubble);
|
|
@@ -7046,6 +7343,10 @@ class AparteSidebar extends HTMLElement {
|
|
|
7046
7343
|
* Same convention as `_relabel`, which leaves a host-authored `aria-label` alone.
|
|
7047
7344
|
*/
|
|
7048
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;
|
|
7049
7350
|
/** Whether the sidebar is collapsed (hidden in the flow, or closed as a drawer). */
|
|
7050
7351
|
get collapsed() {
|
|
7051
7352
|
return this.hasAttribute("collapsed");
|
|
@@ -7066,6 +7367,12 @@ class AparteSidebar extends HTMLElement {
|
|
|
7066
7367
|
this._hostIntentSeeded = true;
|
|
7067
7368
|
}
|
|
7068
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
|
+
}
|
|
7069
7376
|
document.addEventListener("keydown", this._onKeydown);
|
|
7070
7377
|
document.addEventListener("click", this._onDocumentClick);
|
|
7071
7378
|
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
@@ -7097,6 +7404,8 @@ class AparteSidebar extends HTMLElement {
|
|
|
7097
7404
|
}
|
|
7098
7405
|
disconnectedCallback() {
|
|
7099
7406
|
this.removeEventListener("input", this._onInput);
|
|
7407
|
+
this._listWatch?.disconnect();
|
|
7408
|
+
this._listWatch = null;
|
|
7100
7409
|
document.removeEventListener("keydown", this._onKeydown);
|
|
7101
7410
|
document.removeEventListener("click", this._onDocumentClick);
|
|
7102
7411
|
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
@@ -7275,20 +7584,43 @@ class AparteSidebar extends HTMLElement {
|
|
|
7275
7584
|
this.filter(input.value);
|
|
7276
7585
|
};
|
|
7277
7586
|
/**
|
|
7278
|
-
*
|
|
7279
|
-
*
|
|
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.
|
|
7280
7591
|
*/
|
|
7281
7592
|
filter(query) {
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
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;
|
|
7290
7601
|
}
|
|
7291
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);
|
|
7292
7624
|
}
|
|
7293
7625
|
// ─── Locale ───────────────────────────────────────────────────────────
|
|
7294
7626
|
_onConfigChange = () => this._relabel();
|
|
@@ -7314,9 +7646,6 @@ function visibleFocusable(root) {
|
|
|
7314
7646
|
function nearestSidebar(control) {
|
|
7315
7647
|
return control.closest("aparte-sidebar") ?? rootOf(control).querySelector("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
|
|
7316
7648
|
}
|
|
7317
|
-
function fold(text) {
|
|
7318
|
-
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
|
|
7319
|
-
}
|
|
7320
7649
|
if (!customElements.get("aparte-sidebar")) {
|
|
7321
7650
|
customElements.define("aparte-sidebar", AparteSidebar);
|
|
7322
7651
|
}
|
|
@@ -7468,7 +7797,7 @@ class AparteSplit extends HTMLElement {
|
|
|
7468
7797
|
get stacked() {
|
|
7469
7798
|
return this.hasAttribute("data-stacked") || this.hasAttribute("single") || this.classList.contains("aparte-split--only-start") || this.classList.contains("aparte-split--only-end");
|
|
7470
7799
|
}
|
|
7471
|
-
/** One pane on demand, whatever the width
|
|
7800
|
+
/** One pane on demand, whatever the width. Reflected. */
|
|
7472
7801
|
get single() {
|
|
7473
7802
|
return this.hasAttribute("single");
|
|
7474
7803
|
}
|
|
@@ -7539,6 +7868,7 @@ class AparteSplit extends HTMLElement {
|
|
|
7539
7868
|
this._watchBreakpoint();
|
|
7540
7869
|
this._watchSize();
|
|
7541
7870
|
this._ready = true;
|
|
7871
|
+
this._syncPaneControls();
|
|
7542
7872
|
}
|
|
7543
7873
|
disconnectedCallback() {
|
|
7544
7874
|
this._handle?.removeEventListener("pointerdown", this._onPointerDown);
|
|
@@ -7590,6 +7920,7 @@ class AparteSplit extends HTMLElement {
|
|
|
7590
7920
|
}
|
|
7591
7921
|
case "pane": {
|
|
7592
7922
|
const pane = this.pane;
|
|
7923
|
+
this._syncPaneControls();
|
|
7593
7924
|
if (pane === this._lastPane) return;
|
|
7594
7925
|
this._lastPane = pane;
|
|
7595
7926
|
this._commit();
|
|
@@ -7607,6 +7938,7 @@ class AparteSplit extends HTMLElement {
|
|
|
7607
7938
|
case "single":
|
|
7608
7939
|
if (this.single && this._dragging) this._endDrag("pointer", this._dragStartPercent);
|
|
7609
7940
|
this._stampHandle();
|
|
7941
|
+
this._syncPaneControls();
|
|
7610
7942
|
return;
|
|
7611
7943
|
case "breakpoint":
|
|
7612
7944
|
this._watchBreakpoint();
|
|
@@ -7689,8 +8021,8 @@ class AparteSplit extends HTMLElement {
|
|
|
7689
8021
|
*
|
|
7690
8022
|
* While STACKED there is nothing to read: one pane is `display: none` and the other
|
|
7691
8023
|
* spans the single track, so the ratio is 0 or 100 whatever the seam was set to.
|
|
7692
|
-
* Measuring it there
|
|
7693
|
-
*
|
|
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.
|
|
7694
8026
|
*/
|
|
7695
8027
|
_achievedPercent() {
|
|
7696
8028
|
if (this.stacked) return this._position;
|
|
@@ -8021,6 +8353,7 @@ class AparteSplit extends HTMLElement {
|
|
|
8021
8353
|
const was = this.hasAttribute("data-stacked");
|
|
8022
8354
|
if (stacked && !was && this._dragging) this._endDrag("pointer", this._dragStartPercent);
|
|
8023
8355
|
this.toggleAttribute("data-stacked", stacked);
|
|
8356
|
+
if (stacked !== was) this._syncPaneControls();
|
|
8024
8357
|
if (stacked && !was) {
|
|
8025
8358
|
if (!this.hasAttribute("pane")) this.showPane("start");
|
|
8026
8359
|
} else if (!stacked && was) {
|
|
@@ -8052,6 +8385,28 @@ class AparteSplit extends HTMLElement {
|
|
|
8052
8385
|
}
|
|
8053
8386
|
this.showPane(which ?? (this.pane === "start" ? "end" : "start"));
|
|
8054
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
|
+
}
|
|
8055
8410
|
// ─── Locale ───────────────────────────────────────────────────────────
|
|
8056
8411
|
_onConfigChange = () => this._relabel();
|
|
8057
8412
|
/**
|