@aparte/core 0.16.11 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/dist/client/aparte-client.d.ts +42 -3
- package/dist/client/aparte-client.d.ts.map +1 -1
- package/dist/components/bubble/aparte-chat-bubble.d.ts +22 -0
- package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
- package/dist/components/chat/aparte-chat.d.ts +55 -14
- package/dist/components/chat/aparte-chat.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-action.d.ts +5 -3
- package/dist/components/composer/aparte-composer-action.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-toolbar.d.ts +5 -2
- package/dist/components/composer/aparte-composer-toolbar.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer.d.ts +27 -0
- package/dist/components/composer/aparte-composer.d.ts.map +1 -1
- package/dist/components/context/aparte-context.d.ts +8 -0
- package/dist/components/context/aparte-context.d.ts.map +1 -1
- package/dist/components/conversation-list/aparte-conversation-list.d.ts +82 -17
- package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
- package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
- package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
- package/dist/components/split/aparte-split.d.ts.map +1 -1
- package/dist/components/suggestions/aparte-suggestions.d.ts +37 -3
- package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
- package/dist/components/viewport/aparte-chat-viewport.d.ts +55 -1
- package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
- package/dist/config/action-provider.d.ts +14 -14
- package/dist/config/action-provider.d.ts.map +1 -1
- package/dist/config/aparte-config.d.ts +109 -14
- package/dist/config/aparte-config.d.ts.map +1 -1
- package/dist/config/bubble-shell-renderer.d.ts +50 -12
- package/dist/config/bubble-shell-renderer.d.ts.map +1 -1
- package/dist/config/locale.d.ts +33 -1
- 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 +14 -0
- package/dist/conversations/conversation-controller.d.ts.map +1 -1
- package/dist/conversations/conversation-manager.d.ts +57 -19
- package/dist/conversations/conversation-manager.d.ts.map +1 -1
- package/dist/conversations/types.d.ts +18 -11
- package/dist/conversations/types.d.ts.map +1 -1
- package/dist/custom-elements.json +12007 -11027
- package/dist/{dialog-triggers-cYGTlfZf.js → dialog-triggers-CJWw0P3l.js} +652 -161
- package/dist/dialog-triggers-CJWw0P3l.js.map +1 -0
- package/dist/generated/element-attributes.d.ts +15 -9
- package/dist/generated/element-attributes.d.ts.map +1 -1
- package/dist/host/aparte-chat-host.d.ts +30 -0
- package/dist/host/aparte-chat-host.d.ts.map +1 -1
- package/dist/index.css +1 -8393
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +644 -111
- package/dist/index.js.map +1 -1
- package/dist/index.node.d.ts +1 -1
- package/dist/index.node.d.ts.map +1 -1
- package/dist/index.node.js +1 -1
- package/dist/index.node.js.map +1 -1
- package/dist/interop/element-props.d.ts +1 -1
- package/dist/primitives/select/aparte-optgroup.d.ts +1 -0
- package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
- package/dist/primitives/select/aparte-select.d.ts +12 -1
- package/dist/primitives/select/aparte-select.d.ts.map +1 -1
- package/dist/renderers/markdown-stream.d.ts.map +1 -1
- package/dist/renderers/segments/tool-call.d.ts.map +1 -1
- package/dist/runtime/message-repository.d.ts +16 -3
- package/dist/runtime/message-repository.d.ts.map +1 -1
- package/dist/types/chat.d.ts +41 -46
- package/dist/types/chat.d.ts.map +1 -1
- package/dist/types/event-map.d.ts +7 -7
- package/dist/types/event-map.d.ts.map +1 -1
- package/dist/types/events.d.ts +7 -6
- package/dist/types/events.d.ts.map +1 -1
- package/dist/types/imperative-api.d.ts +16 -0
- package/dist/types/imperative-api.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/models.d.ts +9 -0
- package/dist/types/models.d.ts.map +1 -1
- package/dist/types/segments.d.ts +56 -13
- package/dist/types/segments.d.ts.map +1 -1
- package/dist/types/tools.d.ts +14 -0
- package/dist/types/tools.d.ts.map +1 -1
- package/dist/utils/event-target.d.ts +31 -0
- package/dist/utils/event-target.d.ts.map +1 -0
- package/dist/utils/transcript.d.ts +13 -0
- package/dist/utils/transcript.d.ts.map +1 -0
- package/package.json +2 -2
- package/dist/dialog-triggers-cYGTlfZf.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { $ as resolveConfig, a4 as subscribeConfigChange, a1 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, J as escapeAttr, F as cssEscape,
|
|
2
|
-
import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, G, H, I, L, M, R,
|
|
1
|
+
import { $ as resolveConfig, a7 as eventOrigin, a8 as activeElementIn, a4 as subscribeConfigChange, a1 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, J as escapeAttr, F as cssEscape, a9 as mergeSegmentUpdate, K as escapeHtml, aa as writeStreamedMarkdown, S as isSafeUrl, C as copyText, N as getSegmentRenderer, O as installDefaultRenderersOnce, r as AparteMessageRepository, ab as stampSegmentActivity, ac as stampSegmentOnInsert, ad as stampSegmentOnUpdate, ae as renumberSegments, af as openSegmentIds, ag as isTerminalStatus, ah as adoptMessageSegments, Q as isAwaitingReply, W as populateBubbleFromMessage, a6 as uuid, a0 as revokeAttachmentUrls, V as parseMarkdownToSegments, ai as APARTE_DERIVED_SEGMENTS, e as APARTE_DEFAULT_LOCALE, aj as chatBoundaryOf, ak as rootOf, P as installDialogTriggersOnce } from "./dialog-triggers-CJWw0P3l.js";
|
|
2
|
+
import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, G, H, I, L, M, R, T, U, X, Y, Z, _, a2, a3, a5 } from "./dialog-triggers-CJWw0P3l.js";
|
|
3
3
|
function presenceOn(value) {
|
|
4
4
|
return value === "" ? true : Boolean(value);
|
|
5
5
|
}
|
|
@@ -95,6 +95,9 @@ class AparteOptgroup extends HTMLElement {
|
|
|
95
95
|
get collapsible() {
|
|
96
96
|
return this.hasAttribute("collapsible");
|
|
97
97
|
}
|
|
98
|
+
set collapsible(val) {
|
|
99
|
+
this.toggleAttribute("collapsible", presenceOn(val));
|
|
100
|
+
}
|
|
98
101
|
get collapsed() {
|
|
99
102
|
return this.hasAttribute("collapsed");
|
|
100
103
|
}
|
|
@@ -202,6 +205,31 @@ class AparteSelect extends HTMLElement {
|
|
|
202
205
|
_boundHandleOptionClick = this._handleOptionClick.bind(this);
|
|
203
206
|
_boundHandleDocumentClick = this._handleDocumentClick.bind(this);
|
|
204
207
|
_boundHandleKeydown = this._handleKeydown.bind(this);
|
|
208
|
+
/**
|
|
209
|
+
* The trigger's own three, bound for the same reason and left inline for a whole
|
|
210
|
+
* release: they were arrows on `this`, so nothing could ever remove them, and the
|
|
211
|
+
* trigger element survives a re-connect. One re-parent made a click toggle twice —
|
|
212
|
+
* open and shut inside the same click, i.e. a control that looks dead — and two made
|
|
213
|
+
* it announce `aparte-select-open` twice. The docblock above describes exactly this,
|
|
214
|
+
* fixed for the option click and not for its neighbours.
|
|
215
|
+
*/
|
|
216
|
+
_boundHandleTriggerClick = () => {
|
|
217
|
+
this._toggle();
|
|
218
|
+
};
|
|
219
|
+
_boundHandleTriggerKeydown = (e) => {
|
|
220
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
221
|
+
if (this._isOpen) return;
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
this._toggle();
|
|
224
|
+
}
|
|
225
|
+
if (e.key === "ArrowDown" && !this._isOpen) {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
this._openDropdown();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
_boundHandleSearchInput = (e) => {
|
|
231
|
+
this._filterOptions(e.target.value.toLowerCase());
|
|
232
|
+
};
|
|
205
233
|
static get observedAttributes() {
|
|
206
234
|
return ["value", "placeholder", "disabled", "searchable", "open"];
|
|
207
235
|
}
|
|
@@ -335,6 +363,7 @@ class AparteSelect extends HTMLElement {
|
|
|
335
363
|
this._updateTriggerLabel();
|
|
336
364
|
}
|
|
337
365
|
_setupMutationObserver() {
|
|
366
|
+
this._observer?.disconnect();
|
|
338
367
|
this._observer = new MutationObserver(() => {
|
|
339
368
|
this._updateDropdownContent();
|
|
340
369
|
this._updateTriggerLabel();
|
|
@@ -382,24 +411,14 @@ class AparteSelect extends HTMLElement {
|
|
|
382
411
|
this._restoreActive();
|
|
383
412
|
}
|
|
384
413
|
_setupEventListeners() {
|
|
385
|
-
this._trigger?.
|
|
386
|
-
this._trigger?.addEventListener("
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
e.preventDefault();
|
|
390
|
-
this._toggle();
|
|
391
|
-
}
|
|
392
|
-
if (e.key === "ArrowDown" && !this._isOpen) {
|
|
393
|
-
e.preventDefault();
|
|
394
|
-
this._openDropdown();
|
|
395
|
-
}
|
|
396
|
-
});
|
|
414
|
+
this._trigger?.removeEventListener("click", this._boundHandleTriggerClick);
|
|
415
|
+
this._trigger?.addEventListener("click", this._boundHandleTriggerClick);
|
|
416
|
+
this._trigger?.removeEventListener("keydown", this._boundHandleTriggerKeydown);
|
|
417
|
+
this._trigger?.addEventListener("keydown", this._boundHandleTriggerKeydown);
|
|
397
418
|
this.removeEventListener("click", this._boundHandleOptionClick);
|
|
398
419
|
this.addEventListener("click", this._boundHandleOptionClick);
|
|
399
|
-
this._searchInput?.
|
|
400
|
-
|
|
401
|
-
this._filterOptions(query);
|
|
402
|
-
});
|
|
420
|
+
this._searchInput?.removeEventListener("input", this._boundHandleSearchInput);
|
|
421
|
+
this._searchInput?.addEventListener("input", this._boundHandleSearchInput);
|
|
403
422
|
document.addEventListener("click", this._boundHandleDocumentClick);
|
|
404
423
|
document.addEventListener("keydown", this._boundHandleKeydown);
|
|
405
424
|
}
|
|
@@ -410,13 +429,14 @@ class AparteSelect extends HTMLElement {
|
|
|
410
429
|
}
|
|
411
430
|
}
|
|
412
431
|
_handleDocumentClick(e) {
|
|
413
|
-
|
|
432
|
+
const origin = eventOrigin(e);
|
|
433
|
+
if (!origin || !this.contains(origin)) {
|
|
414
434
|
this._closeDropdown();
|
|
415
435
|
}
|
|
416
436
|
}
|
|
417
437
|
_handleKeydown(e) {
|
|
418
438
|
if (!this._isOpen) return;
|
|
419
|
-
const inSearch =
|
|
439
|
+
const inSearch = activeElementIn(this) === this._searchInput;
|
|
420
440
|
switch (e.key) {
|
|
421
441
|
case "Escape":
|
|
422
442
|
e.preventDefault();
|
|
@@ -951,11 +971,21 @@ class AparteElicitation extends HTMLElement {
|
|
|
951
971
|
if (typeof customElements !== "undefined" && !customElements.get("aparte-elicitation")) {
|
|
952
972
|
customElements.define("aparte-elicitation", AparteElicitation);
|
|
953
973
|
}
|
|
974
|
+
function isTranscriptEmpty(viewport) {
|
|
975
|
+
return !viewport.hasAttribute("loading") && !viewport.querySelector("aparte-chat-bubble");
|
|
976
|
+
}
|
|
954
977
|
class AparteChat extends HTMLElement {
|
|
955
978
|
static get observedAttributes() {
|
|
956
|
-
return ["placeholder", "disabled", "submit-on-enter", "
|
|
979
|
+
return ["placeholder", "disabled", "submit-on-enter", "attachments"];
|
|
957
980
|
}
|
|
958
981
|
_observer = null;
|
|
982
|
+
_loadingObserver = null;
|
|
983
|
+
/** Waits for a `<aparte-chat-viewport>` child to appear — see `_watchForViewport`. */
|
|
984
|
+
_childObserver = null;
|
|
985
|
+
/** The viewport is loading and the gate is on — see `_applyLoadingGate`. */
|
|
986
|
+
_gated = false;
|
|
987
|
+
/** The composer's `disabled` is the gate's to remove, not one the consumer set. */
|
|
988
|
+
_gateOwns = false;
|
|
959
989
|
/**
|
|
960
990
|
* True only for the composition THIS element injected. An author-provided
|
|
961
991
|
* composer (or a `framework-managed` host) is never edited by the attachments
|
|
@@ -968,21 +998,27 @@ class AparteChat extends HTMLElement {
|
|
|
968
998
|
this._forwardAttr("disabled");
|
|
969
999
|
this._forwardAttr("submit-on-enter");
|
|
970
1000
|
this._syncEmptyWatch();
|
|
1001
|
+
this._syncLoadingWatch();
|
|
1002
|
+
this._watchForViewport();
|
|
971
1003
|
}
|
|
972
1004
|
disconnectedCallback() {
|
|
973
1005
|
this._observer?.disconnect();
|
|
974
1006
|
this._observer = null;
|
|
1007
|
+
this._loadingObserver?.disconnect();
|
|
1008
|
+
this._loadingObserver = null;
|
|
1009
|
+
this._childObserver?.disconnect();
|
|
1010
|
+
this._childObserver = null;
|
|
975
1011
|
}
|
|
976
1012
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
977
1013
|
if (oldValue === newValue) return;
|
|
978
|
-
if (name === "center-empty") {
|
|
979
|
-
this._syncEmptyWatch();
|
|
980
|
-
return;
|
|
981
|
-
}
|
|
982
1014
|
if (name === "attachments") {
|
|
983
1015
|
this._syncAttachments();
|
|
984
1016
|
return;
|
|
985
1017
|
}
|
|
1018
|
+
if (name === "disabled" && newValue === null && this._gated) {
|
|
1019
|
+
this._gateOwns = true;
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
986
1022
|
const composer = this.querySelector("aparte-composer");
|
|
987
1023
|
if (!composer) return;
|
|
988
1024
|
if (newValue !== null) composer.setAttribute(name, newValue);
|
|
@@ -1045,24 +1081,86 @@ class AparteChat extends HTMLElement {
|
|
|
1045
1081
|
if (!this.hasAttribute(name)) return;
|
|
1046
1082
|
this.querySelector("aparte-composer")?.setAttribute(name, this.getAttribute(name) ?? "");
|
|
1047
1083
|
}
|
|
1048
|
-
/**
|
|
1084
|
+
/**
|
|
1085
|
+
* Watch the viewport so `data-empty` follows the conversation.
|
|
1086
|
+
*
|
|
1087
|
+
* It used to run only under `center-empty`, which made the chat's one statement of
|
|
1088
|
+
* "this conversation is empty" a by-product of a layout option: an app that wanted
|
|
1089
|
+
* starters on an empty thread, or a welcome of its own, had to count messages itself.
|
|
1090
|
+
* Every selector core writes against it still requires `[center-empty]`, so reflecting
|
|
1091
|
+
* it everywhere moves no layout.
|
|
1092
|
+
*/
|
|
1049
1093
|
_syncEmptyWatch() {
|
|
1050
1094
|
this._observer?.disconnect();
|
|
1051
1095
|
this._observer = null;
|
|
1052
|
-
if (!this.hasAttribute("center-empty")) {
|
|
1053
|
-
this.removeAttribute("data-empty");
|
|
1054
|
-
return;
|
|
1055
|
-
}
|
|
1056
1096
|
const viewport = this.querySelector("aparte-chat-viewport");
|
|
1057
1097
|
if (!viewport) return;
|
|
1058
1098
|
this._updateEmpty();
|
|
1059
1099
|
this._observer = new MutationObserver(() => this._updateEmpty());
|
|
1060
|
-
this._observer.observe(viewport, { childList: true, subtree: true });
|
|
1100
|
+
this._observer.observe(viewport, { childList: true, subtree: true, attributes: true, attributeFilter: ["loading"] });
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* A transcript on its way cannot take a message: while the viewport says `loading`
|
|
1104
|
+
* the composer is disabled, whatever else the chat is set to. A send in that window
|
|
1105
|
+
* raced the fetch — the controller appended to a history that `setMessages`
|
|
1106
|
+
* overwrote a moment later. Independent of `center-empty`: every chat gates.
|
|
1107
|
+
*/
|
|
1108
|
+
_syncLoadingWatch() {
|
|
1109
|
+
this._loadingObserver?.disconnect();
|
|
1110
|
+
this._loadingObserver = null;
|
|
1111
|
+
const viewport = this.querySelector("aparte-chat-viewport");
|
|
1112
|
+
if (!viewport) return;
|
|
1113
|
+
this._applyLoadingGate();
|
|
1114
|
+
this._loadingObserver = new MutationObserver(() => this._applyLoadingGate());
|
|
1115
|
+
this._loadingObserver.observe(viewport, { attributes: true, attributeFilter: ["loading"] });
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Both watches above need a viewport to observe, and they were installed once, at
|
|
1119
|
+
* connect, when there might not be one yet: `<aparte-chat framework-managed>` IS
|
|
1120
|
+
* Angular's host element — upgraded on insert, filled by the template afterwards — and
|
|
1121
|
+
* hand-written markup upgrades before its children are parsed. Neither ever got its
|
|
1122
|
+
* observer, so the documented "every chat gates" was inert on exactly the chats whose
|
|
1123
|
+
* transcript is fetched. This waits for the child to arrive, installs them, and stops.
|
|
1124
|
+
*/
|
|
1125
|
+
_watchForViewport() {
|
|
1126
|
+
this._childObserver?.disconnect();
|
|
1127
|
+
this._childObserver = null;
|
|
1128
|
+
if (this.querySelector("aparte-chat-viewport")) return;
|
|
1129
|
+
this._childObserver = new MutationObserver(() => {
|
|
1130
|
+
if (!this.querySelector("aparte-chat-viewport")) return;
|
|
1131
|
+
this._childObserver?.disconnect();
|
|
1132
|
+
this._childObserver = null;
|
|
1133
|
+
this._syncEmptyWatch();
|
|
1134
|
+
this._syncLoadingWatch();
|
|
1135
|
+
});
|
|
1136
|
+
this._childObserver.observe(this, { childList: true, subtree: true });
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* The gate only ever ADDS `disabled` and only ever removes what it added: a `disabled`
|
|
1140
|
+
* the consumer set — on this element, or on a composer of their own — stays.
|
|
1141
|
+
*/
|
|
1142
|
+
_applyLoadingGate() {
|
|
1143
|
+
const composer = this.querySelector("aparte-composer");
|
|
1144
|
+
const loading = this.querySelector("aparte-chat-viewport")?.hasAttribute("loading") ?? false;
|
|
1145
|
+
if (!composer) return;
|
|
1146
|
+
if (loading && !this._gated) {
|
|
1147
|
+
this._gated = true;
|
|
1148
|
+
this._gateOwns = !composer.hasAttribute("disabled");
|
|
1149
|
+
composer.setAttribute("disabled", "");
|
|
1150
|
+
} else if (!loading && this._gated) {
|
|
1151
|
+
this._gated = false;
|
|
1152
|
+
if (this._gateOwns && !this.hasAttribute("disabled")) composer.removeAttribute("disabled");
|
|
1153
|
+
this._gateOwns = false;
|
|
1154
|
+
}
|
|
1061
1155
|
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Empty means no message AND none on its way. A viewport that says `loading` holds
|
|
1158
|
+
* no bubble yet, and it used to count as empty: the welcome and the centred composer
|
|
1159
|
+
* showed while a conversation was being fetched.
|
|
1160
|
+
*/
|
|
1062
1161
|
_updateEmpty() {
|
|
1063
1162
|
const viewport = this.querySelector("aparte-chat-viewport");
|
|
1064
|
-
|
|
1065
|
-
this.toggleAttribute("data-empty", empty);
|
|
1163
|
+
this.toggleAttribute("data-empty", !viewport || isTranscriptEmpty(viewport));
|
|
1066
1164
|
}
|
|
1067
1165
|
}
|
|
1068
1166
|
if (!customElements.get("aparte-chat")) {
|
|
@@ -1507,6 +1605,11 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1507
1605
|
</div>
|
|
1508
1606
|
`;
|
|
1509
1607
|
}
|
|
1608
|
+
const messageEl = this.querySelector(".aparte-message");
|
|
1609
|
+
if (messageEl && !messageEl.hasAttribute("role")) {
|
|
1610
|
+
messageEl.setAttribute("role", "article");
|
|
1611
|
+
messageEl.setAttribute("aria-label", this._getAriaLabel());
|
|
1612
|
+
}
|
|
1510
1613
|
this._contentEl = this.querySelector(".aparte-content");
|
|
1511
1614
|
this._segmentsEl = this.querySelector(".aparte-segments");
|
|
1512
1615
|
this._attachmentsEl = this.querySelector(".aparte-attachments");
|
|
@@ -1578,7 +1681,9 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1578
1681
|
const nameEl = this.querySelector(".aparte-name");
|
|
1579
1682
|
if (message) {
|
|
1580
1683
|
message.setAttribute("data-role", this._role);
|
|
1581
|
-
message.
|
|
1684
|
+
if (message.getAttribute("role") === "article") {
|
|
1685
|
+
message.setAttribute("aria-label", this._getAriaLabel());
|
|
1686
|
+
}
|
|
1582
1687
|
}
|
|
1583
1688
|
if (avatar) {
|
|
1584
1689
|
avatar.setAttribute("data-role", this._role);
|
|
@@ -1714,7 +1819,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1714
1819
|
}
|
|
1715
1820
|
this._attachmentsEl.innerHTML = this._attachments.map((a4) => {
|
|
1716
1821
|
const name = escapeHtml(a4.name);
|
|
1717
|
-
if (a4.type.startsWith("image/")) {
|
|
1822
|
+
if (a4.type.startsWith("image/") && this._isShowableUrl(a4.url)) {
|
|
1718
1823
|
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumbnail__image" src="${escapeHtml(a4.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
|
|
1719
1824
|
}
|
|
1720
1825
|
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumbnail__label">${escapeHtml(this._fileExt(a4.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
|
|
@@ -1742,6 +1847,24 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1742
1847
|
});
|
|
1743
1848
|
}
|
|
1744
1849
|
/** Uppercased file extension (≤4 chars), or 'FILE' when there is none. */
|
|
1850
|
+
/**
|
|
1851
|
+
* The same answer `copyAttributes` gives before it writes an `src`, plus `blob:`.
|
|
1852
|
+
*
|
|
1853
|
+
* An attachment's `url` is documented as "URL or data URI" and a storage adapter
|
|
1854
|
+
* re-mints it when a conversation is restored, so it is the app's value, not the
|
|
1855
|
+
* composer's — it was escaped and written, where every other URL core emits is
|
|
1856
|
+
* asked whether its SCHEME is allowed at all. A picture whose URL is refused is
|
|
1857
|
+
* not half-rendered: it falls back to the file chip, which still names the file.
|
|
1858
|
+
*
|
|
1859
|
+
* `blob:` is added because it is the shape core itself mints — `filesToAttachments`
|
|
1860
|
+
* calls `URL.createObjectURL`, and `AparteMessage.attachments` documents the same
|
|
1861
|
+
* on hydration. `isSafeUrl` refuses it, and rightly: that list governs MODEL
|
|
1862
|
+
* markup, which has no way to make a blob URL resolve.
|
|
1863
|
+
*/
|
|
1864
|
+
_isShowableUrl(url) {
|
|
1865
|
+
if (!url) return false;
|
|
1866
|
+
return isSafeUrl(url, "img") || /^blob:/i.test(url.trim());
|
|
1867
|
+
}
|
|
1745
1868
|
_fileExt(filename) {
|
|
1746
1869
|
const dot = filename.lastIndexOf(".");
|
|
1747
1870
|
return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
|
|
@@ -2255,7 +2378,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2255
2378
|
}
|
|
2256
2379
|
}
|
|
2257
2380
|
this._updateWaiting();
|
|
2258
|
-
if (wasStreaming && !streaming) this.
|
|
2381
|
+
if (wasStreaming && !streaming) this._updateContent();
|
|
2259
2382
|
}
|
|
2260
2383
|
}
|
|
2261
2384
|
if (!customElements.get("aparte-chat-bubble")) {
|
|
@@ -2421,6 +2544,20 @@ class AparteChatStatus extends HTMLElement {
|
|
|
2421
2544
|
if (!customElements.get("aparte-chat-status")) {
|
|
2422
2545
|
customElements.define("aparte-chat-status", AparteChatStatus);
|
|
2423
2546
|
}
|
|
2547
|
+
function withParsedSegments(message, blocks) {
|
|
2548
|
+
if (message.role !== "assistant" || message.segments?.length) return message;
|
|
2549
|
+
if (typeof message.content !== "string" || !message.content.trim()) return message;
|
|
2550
|
+
const segments = parseMarkdownToSegments(message.content, { blocks });
|
|
2551
|
+
const onlyProse = segments.length <= 1 && segments.every((s2) => s2.type === "text");
|
|
2552
|
+
return onlyProse ? message : markDerivedSegments({ ...message, segments });
|
|
2553
|
+
}
|
|
2554
|
+
function markDerivedSegments(message) {
|
|
2555
|
+
Object.defineProperty(message, APARTE_DERIVED_SEGMENTS, { value: true, enumerable: false });
|
|
2556
|
+
return message;
|
|
2557
|
+
}
|
|
2558
|
+
function hasDerivedSegments(message) {
|
|
2559
|
+
return message[APARTE_DERIVED_SEGMENTS] === true;
|
|
2560
|
+
}
|
|
2424
2561
|
class AparteChatViewport extends HTMLElement {
|
|
2425
2562
|
// The scroll surface: an internal `.aparte-viewport-container` div (core mode)
|
|
2426
2563
|
// or the host element itself (framework-managed mode). HTMLElement covers both.
|
|
@@ -2443,6 +2580,11 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2443
2580
|
_lastScrollTop = 0;
|
|
2444
2581
|
/** The scroll height at the last scroll event: how much it moved since bounds what churn can do. */
|
|
2445
2582
|
_lastScrollHeight = 0;
|
|
2583
|
+
/** When the transcript last mutated under our observer — an engine's settle adjustment follows one. */
|
|
2584
|
+
_lastMutationAt = Number.NEGATIVE_INFINITY;
|
|
2585
|
+
/** How long after a mutation an engine's settle adjustment can arrive, and how far it moves. */
|
|
2586
|
+
_settleShadowMs = 100;
|
|
2587
|
+
_settleBoundPx = 100;
|
|
2446
2588
|
/** When this component last moved `scrollTop` itself — see `_handleScroll`. "Never" is
|
|
2447
2589
|
* -Infinity rather than 0: `performance.now()` can be under a second old. */
|
|
2448
2590
|
_ownScrollAt = Number.NEGATIVE_INFINITY;
|
|
@@ -2504,7 +2646,53 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2504
2646
|
*/
|
|
2505
2647
|
_frameworkManagedDOM = false;
|
|
2506
2648
|
static get observedAttributes() {
|
|
2507
|
-
return ["scroll-threshold", "max-rendered-bubbles"];
|
|
2649
|
+
return ["scroll-threshold", "max-rendered-bubbles", "loading"];
|
|
2650
|
+
}
|
|
2651
|
+
/**
|
|
2652
|
+
* The transcript is on its way: a conversation was chosen and its messages are
|
|
2653
|
+
* being fetched. Two skeleton turns stand where they will be, the scroll surface
|
|
2654
|
+
* is `aria-busy`, and a visually hidden line names the wait. Reflected as the
|
|
2655
|
+
* `loading` attribute — `AparteConversationController` sets it through its
|
|
2656
|
+
* binding; a loop of your own sets it by hand. Not the same thing as `data-busy`,
|
|
2657
|
+
* which is a reply streaming into a transcript that is already there.
|
|
2658
|
+
*/
|
|
2659
|
+
get loading() {
|
|
2660
|
+
return this.hasAttribute("loading");
|
|
2661
|
+
}
|
|
2662
|
+
set loading(value) {
|
|
2663
|
+
this.toggleAttribute("loading", presenceOn(value));
|
|
2664
|
+
}
|
|
2665
|
+
setLoading(loading) {
|
|
2666
|
+
this.loading = loading;
|
|
2667
|
+
}
|
|
2668
|
+
/** Draw or remove the wait, from the attribute. Safe before the structure exists. */
|
|
2669
|
+
_syncLoading() {
|
|
2670
|
+
const container = this._container;
|
|
2671
|
+
if (!container) return;
|
|
2672
|
+
const on = this.hasAttribute("loading");
|
|
2673
|
+
container.setAttribute("aria-busy", on ? "true" : "false");
|
|
2674
|
+
if (this._frameworkManagedDOM) return;
|
|
2675
|
+
const existing = container.querySelector(":scope > .aparte-viewport-loading");
|
|
2676
|
+
if (!on) {
|
|
2677
|
+
existing?.remove();
|
|
2678
|
+
container.querySelector(":scope > .aparte-viewport-loading-status")?.remove();
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
if (existing) return;
|
|
2682
|
+
const cfg = resolveConfig(this);
|
|
2683
|
+
const skeleton = document.createElement("div");
|
|
2684
|
+
skeleton.className = "aparte-viewport-loading";
|
|
2685
|
+
skeleton.setAttribute("aria-hidden", "true");
|
|
2686
|
+
skeleton.innerHTML = '<span class="aparte-skeleton aparte-skeleton--rect aparte-viewport-loading__user"></span><span class="aparte-skeleton aparte-skeleton--text"></span><span class="aparte-skeleton aparte-skeleton--text"></span><span class="aparte-skeleton aparte-skeleton--text"></span><span class="aparte-skeleton aparte-skeleton--text aparte-viewport-loading__last"></span>';
|
|
2687
|
+
const status = document.createElement("span");
|
|
2688
|
+
status.className = "aparte-viewport-loading-status aparte-sr-only";
|
|
2689
|
+
status.setAttribute("role", "status");
|
|
2690
|
+
container.prepend(skeleton, status);
|
|
2691
|
+
const say = () => {
|
|
2692
|
+
if (status.isConnected) status.textContent = cfg.t("loadingConversation");
|
|
2693
|
+
};
|
|
2694
|
+
if (typeof requestAnimationFrame === "function") requestAnimationFrame(say);
|
|
2695
|
+
else say();
|
|
2508
2696
|
}
|
|
2509
2697
|
constructor() {
|
|
2510
2698
|
super();
|
|
@@ -2589,6 +2777,9 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2589
2777
|
this._maxRenderedBubbles = parseInt(newValue || "1000", 10);
|
|
2590
2778
|
this._pruneRenderedBubbles();
|
|
2591
2779
|
break;
|
|
2780
|
+
case "loading":
|
|
2781
|
+
this._syncLoading();
|
|
2782
|
+
break;
|
|
2592
2783
|
}
|
|
2593
2784
|
}
|
|
2594
2785
|
/**
|
|
@@ -2670,12 +2861,32 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2670
2861
|
if (streaming !== null && streaming !== head) return null;
|
|
2671
2862
|
return head;
|
|
2672
2863
|
}
|
|
2673
|
-
/**
|
|
2864
|
+
/**
|
|
2865
|
+
* The id of the message currently streaming, if any — searched across the whole
|
|
2866
|
+
* TREE, and answering with an OFF-HEAD one first when there are two.
|
|
2867
|
+
*
|
|
2868
|
+
* It used to scan `getMessages()`, the active path. A retry or an edit on an
|
|
2869
|
+
* earlier bubble calls `addSiblingOf`, which switches the branch to the new pending
|
|
2870
|
+
* reply: the turn still being written leaves the path, the scan found nothing, and
|
|
2871
|
+
* the rest of that turn was appended to the message that had just replaced it.
|
|
2872
|
+
*
|
|
2873
|
+
* A new pending reply is itself marked streaming, so "the first one found" would
|
|
2874
|
+
* answer the head and let the write through — the very case this exists to refuse.
|
|
2875
|
+
* Answering the off-head one instead makes `_activeMessageId` refuse while ANY turn
|
|
2876
|
+
* elsewhere in the tree is still open. That also refuses the new turn's own first
|
|
2877
|
+
* deltas for as long as the superseded one has not settled, which is the trade this
|
|
2878
|
+
* whole path already makes: losing the tail is visible, writing it onto someone
|
|
2879
|
+
* else's message is not.
|
|
2880
|
+
*/
|
|
2674
2881
|
_streamingMessageId() {
|
|
2675
|
-
|
|
2676
|
-
|
|
2882
|
+
const head = this._repo.headId;
|
|
2883
|
+
let atHead = null;
|
|
2884
|
+
for (const message of this._repo.getAllMessages()) {
|
|
2885
|
+
if (!message.isStreaming) continue;
|
|
2886
|
+
if (message.id !== head) return message.id;
|
|
2887
|
+
atHead = message.id;
|
|
2677
2888
|
}
|
|
2678
|
-
return
|
|
2889
|
+
return atHead;
|
|
2679
2890
|
}
|
|
2680
2891
|
/**
|
|
2681
2892
|
* While a reply streams, the transcript is read-only except for Stop.
|
|
@@ -2817,6 +3028,12 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2817
3028
|
/**
|
|
2818
3029
|
* Atomic update for a message by ID
|
|
2819
3030
|
* Supports updating content, status, segments, and other metadata
|
|
3031
|
+
*
|
|
3032
|
+
* An id the tree does not hold is a silent no-op, deliberately: the message may have
|
|
3033
|
+
* been truncated away by an edit while a turn was still writing to it, and inventing
|
|
3034
|
+
* it back would put a message the reader deleted on screen. It does mean a late error
|
|
3035
|
+
* card can have nowhere to land — `aparte-message-error` still fires, so a host that
|
|
3036
|
+
* wants to show one has the event.
|
|
2820
3037
|
*/
|
|
2821
3038
|
updateMessage(messageId, updates) {
|
|
2822
3039
|
const message = this._repo.getMessageById(messageId);
|
|
@@ -2855,6 +3072,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2855
3072
|
* the framework owns the DOM and will create the bubble element itself.
|
|
2856
3073
|
*/
|
|
2857
3074
|
appendMessage(message, options) {
|
|
3075
|
+
message = withParsedSegments(message, resolveConfig(this).getStreamBlocks());
|
|
2858
3076
|
const stored = options?.historical ? adoptMessageSegments(message) : message.segments?.length ? { ...message, segments: message.segments.reduce(
|
|
2859
3077
|
(acc, segment) => {
|
|
2860
3078
|
acc.push(stampSegmentOnInsert(
|
|
@@ -2868,6 +3086,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2868
3086
|
[]
|
|
2869
3087
|
) } : { ...message };
|
|
2870
3088
|
if (!options?.historical && isAwaitingReply(message)) stored.isStreaming = true;
|
|
3089
|
+
if (hasDerivedSegments(message)) markDerivedSegments(stored);
|
|
2871
3090
|
this._repo.addOrUpdateMessage(this._repo.headId, stored);
|
|
2872
3091
|
if (!this._frameworkManagedDOM) {
|
|
2873
3092
|
const wrapper = this.querySelector(".aparte-messages-wrapper");
|
|
@@ -3050,12 +3269,15 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3050
3269
|
*/
|
|
3051
3270
|
importTree(tree) {
|
|
3052
3271
|
this.clearAll({ revokeAttachments: false });
|
|
3272
|
+
const blocks = resolveConfig(this).getStreamBlocks();
|
|
3053
3273
|
this._repo.import({
|
|
3054
3274
|
...tree,
|
|
3055
|
-
messages: tree.messages.map((entry) =>
|
|
3056
|
-
|
|
3057
|
-
message
|
|
3058
|
-
|
|
3275
|
+
messages: tree.messages.map((entry) => {
|
|
3276
|
+
const parsed = withParsedSegments(entry.message, blocks);
|
|
3277
|
+
const message = adoptMessageSegments(parsed);
|
|
3278
|
+
if (hasDerivedSegments(parsed)) markDerivedSegments(message);
|
|
3279
|
+
return { ...entry, message };
|
|
3280
|
+
})
|
|
3059
3281
|
});
|
|
3060
3282
|
this._reRenderActivePath();
|
|
3061
3283
|
}
|
|
@@ -3070,7 +3292,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3070
3292
|
*/
|
|
3071
3293
|
clearAll(options) {
|
|
3072
3294
|
if (options?.revokeAttachments !== false) {
|
|
3073
|
-
for (const message of this._repo.
|
|
3295
|
+
for (const message of this._repo.getAllMessages()) {
|
|
3074
3296
|
revokeAttachmentUrls(message.attachments);
|
|
3075
3297
|
}
|
|
3076
3298
|
}
|
|
@@ -3292,6 +3514,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3292
3514
|
container.appendChild(wrapper);
|
|
3293
3515
|
this.appendChild(container);
|
|
3294
3516
|
this._container = container;
|
|
3517
|
+
this._syncLoading();
|
|
3295
3518
|
this._scrollBtn = document.createElement("button");
|
|
3296
3519
|
this._scrollBtn.className = "aparte-btn aparte-btn--surface aparte-btn--circle aparte-btn--lg aparte-scroll-btn aparte-scroll-btn--hidden";
|
|
3297
3520
|
this._scrollBtn.setAttribute("type", "button");
|
|
@@ -3382,6 +3605,19 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3382
3605
|
* 1 run in 6: the rebuild's height churn moved scrollTop within the second after
|
|
3383
3606
|
* the click, and the click made that look like the reader's).
|
|
3384
3607
|
*/
|
|
3608
|
+
/**
|
|
3609
|
+
* Is a pointer pressed on the surface right now? A press on the TEXT is not a scroll
|
|
3610
|
+
* gesture (`_noteReaderInput` counts only the gutter), but a hand that is down and
|
|
3611
|
+
* dragging a selection past the edge scrolls the container all the same — and that
|
|
3612
|
+
* decrease is the reader's. Held from the press to its release, wherever it lands.
|
|
3613
|
+
*/
|
|
3614
|
+
_pointerHeld = false;
|
|
3615
|
+
_holdPointer = () => {
|
|
3616
|
+
this._pointerHeld = true;
|
|
3617
|
+
};
|
|
3618
|
+
_releasePointer = () => {
|
|
3619
|
+
this._pointerHeld = false;
|
|
3620
|
+
};
|
|
3385
3621
|
_noteReaderInput = (e) => {
|
|
3386
3622
|
if (e.type === "keydown" && !this._scrollKeys.has(e.key)) return;
|
|
3387
3623
|
if (e.type === "pointerdown") {
|
|
@@ -3414,6 +3650,9 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3414
3650
|
for (const type of ["wheel", "touchmove", "pointerdown", "keydown"]) {
|
|
3415
3651
|
this._container?.addEventListener(type, this._noteReaderInput, { passive: true });
|
|
3416
3652
|
}
|
|
3653
|
+
this._container?.addEventListener("pointerdown", this._holdPointer, { passive: true });
|
|
3654
|
+
document.addEventListener("pointerup", this._releasePointer, true);
|
|
3655
|
+
document.addEventListener("pointercancel", this._releasePointer, true);
|
|
3417
3656
|
this._scrollBtn?.addEventListener("click", this._onScrollBtnClick);
|
|
3418
3657
|
this.addEventListener("aparte-branch-navigate", this._onBranchNavigate);
|
|
3419
3658
|
}
|
|
@@ -3446,6 +3685,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3446
3685
|
this._updateOverlayInset();
|
|
3447
3686
|
}
|
|
3448
3687
|
this._mutationObserver = new MutationObserver((mutations) => {
|
|
3688
|
+
this._lastMutationAt = performance.now();
|
|
3449
3689
|
if (this._frameworkManagedDOM) this._keepScrollButtonLast();
|
|
3450
3690
|
if (this._isAutoScrollEnabled && !this._gliding()) {
|
|
3451
3691
|
const added = mutations.flatMap((m2) => Array.from(m2.addedNodes)).filter((n2) => n2 instanceof Element && n2.tagName === "APARTE-CHAT-BUBBLE");
|
|
@@ -3547,7 +3787,8 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3547
3787
|
const drop = this._lastScrollTop - top;
|
|
3548
3788
|
const churn = Math.abs(height - this._lastScrollHeight);
|
|
3549
3789
|
const now = performance.now();
|
|
3550
|
-
const
|
|
3790
|
+
const afterMutation = now - this._lastMutationAt < this._settleShadowMs && drop <= this._settleBoundPx;
|
|
3791
|
+
const settlingOurs = now - this._ownScrollAt < this._ownScrollWindowMs && now - this._readerInputAt >= this._ownScrollWindowMs && !this._pointerHeld && (drop <= churn + 2 || afterMutation);
|
|
3551
3792
|
const readerWentUp = drop > 1 && !settlingOurs;
|
|
3552
3793
|
const readerHandOnIt = drop > 1 && now - this._readerInputAt < this._ownScrollWindowMs;
|
|
3553
3794
|
this._lastScrollTop = top;
|
|
@@ -3788,6 +4029,10 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3788
4029
|
for (const type of ["wheel", "touchmove", "pointerdown", "keydown"]) {
|
|
3789
4030
|
this._container?.removeEventListener(type, this._noteReaderInput);
|
|
3790
4031
|
}
|
|
4032
|
+
this._container?.removeEventListener("pointerdown", this._holdPointer);
|
|
4033
|
+
document.removeEventListener("pointerup", this._releasePointer, true);
|
|
4034
|
+
document.removeEventListener("pointercancel", this._releasePointer, true);
|
|
4035
|
+
this._pointerHeld = false;
|
|
3791
4036
|
this._scrollBtn?.removeEventListener("click", this._onScrollBtnClick);
|
|
3792
4037
|
this.removeEventListener("aparte-branch-navigate", this._onBranchNavigate);
|
|
3793
4038
|
this._resizeObserver?.disconnect();
|
|
@@ -3934,6 +4179,16 @@ class AparteComposer extends HTMLElement {
|
|
|
3934
4179
|
get disabled() {
|
|
3935
4180
|
return this.hasAttribute("disabled");
|
|
3936
4181
|
}
|
|
4182
|
+
/**
|
|
4183
|
+
* The attribute is the state, so the property writes it. It has to exist: React 19
|
|
4184
|
+
* and Svelte assign the PROPERTY whenever the element has one of that name, and a
|
|
4185
|
+
* getter alone made `<aparte-composer disabled="">` — a spelling the wrappers type
|
|
4186
|
+
* as valid — throw and take the render down. `presenceOn` because `''` is what
|
|
4187
|
+
* those templates hand a presence property (#62).
|
|
4188
|
+
*/
|
|
4189
|
+
set disabled(value) {
|
|
4190
|
+
this.toggleAttribute("disabled", presenceOn(value));
|
|
4191
|
+
}
|
|
3937
4192
|
/**
|
|
3938
4193
|
* When false, Shift+Enter submits and a bare Enter inserts a newline —
|
|
3939
4194
|
* the inverse of the default. Driven by the `submit-on-enter` attribute.
|
|
@@ -3947,6 +4202,11 @@ class AparteComposer extends HTMLElement {
|
|
|
3947
4202
|
get placeholder() {
|
|
3948
4203
|
return this.getAttribute("placeholder") ?? "";
|
|
3949
4204
|
}
|
|
4205
|
+
/** Writes the attribute the inputs read (see `disabled` for why the setter exists). */
|
|
4206
|
+
set placeholder(value) {
|
|
4207
|
+
if (value === null || value === void 0) this.removeAttribute("placeholder");
|
|
4208
|
+
else this.setAttribute("placeholder", String(value));
|
|
4209
|
+
}
|
|
3950
4210
|
get targetId() {
|
|
3951
4211
|
return this.getAttribute("target");
|
|
3952
4212
|
}
|
|
@@ -4287,13 +4547,35 @@ class AparteComposer extends HTMLElement {
|
|
|
4287
4547
|
}
|
|
4288
4548
|
return void 0;
|
|
4289
4549
|
}
|
|
4550
|
+
/**
|
|
4551
|
+
* The message this composer's turn is writing, when the event that started it said.
|
|
4552
|
+
* Kept so the END of a DIFFERENT turn cannot be read as the end of this one — see
|
|
4553
|
+
* `_isForThisTurn`.
|
|
4554
|
+
*/
|
|
4555
|
+
_streamingMessageId = null;
|
|
4556
|
+
/**
|
|
4557
|
+
* Whether a terminal event ends the turn this composer is following.
|
|
4558
|
+
*
|
|
4559
|
+
* `_isForThisComposer` answers "is this chat's?", and both turns of a retry belong
|
|
4560
|
+
* to the same chat: the client aborts the superseded reply, the engine emits
|
|
4561
|
+
* `run-aborted` for it, and that unwind turned Stop back into Send while the new
|
|
4562
|
+
* reply was still streaming — and evicted the open panel, i.e. the approval question
|
|
4563
|
+
* a tool was still waiting on. An event with no `messageId` still ends the turn: a
|
|
4564
|
+
* loop of one's own saying "done" has always meant this one.
|
|
4565
|
+
*/
|
|
4566
|
+
_isForThisTurn(e) {
|
|
4567
|
+
const id = e.detail?.messageId;
|
|
4568
|
+
return !id || this._streamingMessageId === null || id === this._streamingMessageId;
|
|
4569
|
+
}
|
|
4290
4570
|
_handleMessageStart(e) {
|
|
4291
4571
|
if (!this._isForThisComposer(e)) return;
|
|
4572
|
+
this._streamingMessageId = e.detail?.messageId ?? null;
|
|
4292
4573
|
this._streaming = true;
|
|
4293
4574
|
this._emit("streaming-change", { streaming: true });
|
|
4294
4575
|
}
|
|
4295
4576
|
_handleMessageDone(e) {
|
|
4296
|
-
if (!this._isForThisComposer(e)) return;
|
|
4577
|
+
if (!this._isForThisComposer(e) || !this._isForThisTurn(e)) return;
|
|
4578
|
+
this._streamingMessageId = null;
|
|
4297
4579
|
this._streaming = false;
|
|
4298
4580
|
this._emit("streaming-change", { streaming: false });
|
|
4299
4581
|
if (this._panelActive) this._evictPanel();
|
|
@@ -5080,8 +5362,10 @@ class AparteComposerToolbar extends HTMLElement {
|
|
|
5080
5362
|
_observer = null;
|
|
5081
5363
|
connectedCallback() {
|
|
5082
5364
|
this._syncEmpty();
|
|
5083
|
-
this._observer ??= new MutationObserver(() =>
|
|
5084
|
-
|
|
5365
|
+
this._observer ??= new MutationObserver((records) => {
|
|
5366
|
+
if (records.some((r) => !(r.type === "attributes" && r.target === this))) this._syncEmpty();
|
|
5367
|
+
});
|
|
5368
|
+
this._observer.observe(this, { childList: true, subtree: true, attributes: true, attributeFilter: ["hidden", "data-empty"] });
|
|
5085
5369
|
}
|
|
5086
5370
|
disconnectedCallback() {
|
|
5087
5371
|
this._observer?.disconnect();
|
|
@@ -5101,7 +5385,10 @@ class AparteComposerToolbar extends HTMLElement {
|
|
|
5101
5385
|
* not empty, and hiding it would be a twenty-minute mystery for whoever wrote it.
|
|
5102
5386
|
*/
|
|
5103
5387
|
_syncEmpty() {
|
|
5104
|
-
const
|
|
5388
|
+
const visibleChild = Array.from(this.children).some((c2) => !c2.hasAttribute("hidden") && !c2.hasAttribute("data-empty"));
|
|
5389
|
+
const ownText = Array.from(this.childNodes).some((n2) => n2.nodeType === Node.TEXT_NODE && (n2.textContent ?? "").trim() !== "");
|
|
5390
|
+
const hasContent = visibleChild || ownText;
|
|
5391
|
+
if (hasContent === !this.hasAttribute("data-empty")) return;
|
|
5105
5392
|
if (hasContent) this.removeAttribute("data-empty");
|
|
5106
5393
|
else this.setAttribute("data-empty", "");
|
|
5107
5394
|
}
|
|
@@ -5109,6 +5396,9 @@ class AparteComposerToolbar extends HTMLElement {
|
|
|
5109
5396
|
if (!customElements.get("aparte-composer-toolbar")) {
|
|
5110
5397
|
customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
|
|
5111
5398
|
}
|
|
5399
|
+
function rowSelector(attr, id) {
|
|
5400
|
+
return `[${attr}="${cssEscape(id)}"]`;
|
|
5401
|
+
}
|
|
5112
5402
|
const DAY = 864e5;
|
|
5113
5403
|
const MENU_GAP = 4;
|
|
5114
5404
|
const VIEWPORT_MARGIN = 8;
|
|
@@ -5117,8 +5407,37 @@ class AparteConversationList extends HTMLElement {
|
|
|
5117
5407
|
_activeId = null;
|
|
5118
5408
|
_open = null;
|
|
5119
5409
|
_renaming = null;
|
|
5410
|
+
_warnedNoManager = false;
|
|
5120
5411
|
static get observedAttributes() {
|
|
5121
|
-
return ["active-id", "
|
|
5412
|
+
return ["active-id", "flat", "loading", "manage"];
|
|
5413
|
+
}
|
|
5414
|
+
/**
|
|
5415
|
+
* The list is on its way: rows of the kit's skeleton stand where the rows will be,
|
|
5416
|
+
* the element is `aria-busy`, and a visually hidden line names the wait. Reflected
|
|
5417
|
+
* as the `loading` attribute, so a site sets it while its store answers and clears
|
|
5418
|
+
* it when `conversations` is set. A list that is not there yet is not an empty list.
|
|
5419
|
+
*/
|
|
5420
|
+
get loading() {
|
|
5421
|
+
return this.hasAttribute("loading");
|
|
5422
|
+
}
|
|
5423
|
+
set loading(value) {
|
|
5424
|
+
this.toggleAttribute("loading", presenceOn(value));
|
|
5425
|
+
}
|
|
5426
|
+
/**
|
|
5427
|
+
* Let the list finish the gesture through the registered conversation manager.
|
|
5428
|
+
* Reflected as the `manage` attribute.
|
|
5429
|
+
*
|
|
5430
|
+
* The property is not a nicety here: this is the attribute that authorises a WRITE,
|
|
5431
|
+
* and Vue and both Sveltes stringify a bound `false` onto an element that has no
|
|
5432
|
+
* property of that name — `manage="false"`, which `hasAttribute` reads as on. So
|
|
5433
|
+
* turning management off turned it on, and a delete the host meant to handle itself
|
|
5434
|
+
* was also done by the list. `presenceOn`, like `loading` above it.
|
|
5435
|
+
*/
|
|
5436
|
+
get manage() {
|
|
5437
|
+
return this.hasAttribute("manage");
|
|
5438
|
+
}
|
|
5439
|
+
set manage(value) {
|
|
5440
|
+
this.toggleAttribute("manage", presenceOn(value));
|
|
5122
5441
|
}
|
|
5123
5442
|
// ─── Lifecycle ────────────────────────────────────────────────────────
|
|
5124
5443
|
connectedCallback() {
|
|
@@ -5146,7 +5465,7 @@ class AparteConversationList extends HTMLElement {
|
|
|
5146
5465
|
if (name === "active-id") {
|
|
5147
5466
|
this._activeId = newValue;
|
|
5148
5467
|
this._updateActiveState();
|
|
5149
|
-
} else if (name === "
|
|
5468
|
+
} else if ((name === "flat" || name === "loading") && this.isConnected) {
|
|
5150
5469
|
this._render();
|
|
5151
5470
|
}
|
|
5152
5471
|
}
|
|
@@ -5174,7 +5493,42 @@ class AparteConversationList extends HTMLElement {
|
|
|
5174
5493
|
_render() {
|
|
5175
5494
|
this._closeMenu();
|
|
5176
5495
|
this._renaming = null;
|
|
5496
|
+
const focused = activeElementIn(this);
|
|
5497
|
+
const held = focused && this.contains(focused) ? focused.closest("[data-select-id], [data-more-id]") : null;
|
|
5498
|
+
const restore = held?.dataset["selectId"] ? rowSelector("data-select-id", held.dataset["selectId"]) : held?.dataset["moreId"] ? rowSelector("data-more-id", held.dataset["moreId"]) : null;
|
|
5499
|
+
const heldRow = held?.closest("[data-conv-id]") ?? null;
|
|
5500
|
+
const heldIndex = heldRow ? Array.from(this.querySelectorAll("[data-conv-id]")).indexOf(heldRow) : -1;
|
|
5501
|
+
const loading = this.hasAttribute("loading");
|
|
5502
|
+
this.setAttribute("aria-busy", loading ? "true" : "false");
|
|
5503
|
+
if (loading) {
|
|
5504
|
+
const row = '<span class="aparte-skeleton aparte-skeleton--text"></span>';
|
|
5505
|
+
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>';
|
|
5506
|
+
const status = this.querySelector(".aparte-conv-list-loading-status");
|
|
5507
|
+
const say = () => {
|
|
5508
|
+
if (status?.isConnected) status.textContent = this._t("loadingConversations");
|
|
5509
|
+
};
|
|
5510
|
+
if (typeof requestAnimationFrame === "function") requestAnimationFrame(say);
|
|
5511
|
+
else say();
|
|
5512
|
+
return;
|
|
5513
|
+
}
|
|
5177
5514
|
this.innerHTML = this._groups().map((g2) => this._renderGroup(g2)).join("");
|
|
5515
|
+
if (!restore) return;
|
|
5516
|
+
const back = this.querySelector(restore);
|
|
5517
|
+
if (back) {
|
|
5518
|
+
back.focus();
|
|
5519
|
+
return;
|
|
5520
|
+
}
|
|
5521
|
+
const rows = Array.from(this.querySelectorAll("[data-select-id]"));
|
|
5522
|
+
const next = heldIndex >= 0 ? rows[heldIndex] ?? rows[rows.length - 1] : null;
|
|
5523
|
+
if (next) {
|
|
5524
|
+
next.focus();
|
|
5525
|
+
return;
|
|
5526
|
+
}
|
|
5527
|
+
if (!this.hasAttribute("tabindex")) {
|
|
5528
|
+
this.setAttribute("tabindex", "-1");
|
|
5529
|
+
this.addEventListener("blur", () => this.removeAttribute("tabindex"), { once: true });
|
|
5530
|
+
}
|
|
5531
|
+
this.focus();
|
|
5178
5532
|
}
|
|
5179
5533
|
_t(key) {
|
|
5180
5534
|
const locale = resolveConfig(this).getLocale();
|
|
@@ -5183,12 +5537,12 @@ class AparteConversationList extends HTMLElement {
|
|
|
5183
5537
|
/**
|
|
5184
5538
|
* Pinned first, then the calendar buckets every chat product uses, then a heading
|
|
5185
5539
|
* per month (newest first), then whatever carries no date. Flat when nothing is
|
|
5186
|
-
* dated or the host asked for `
|
|
5540
|
+
* dated or the host asked for `flat`: a heading over the only group would say
|
|
5187
5541
|
* nothing.
|
|
5188
5542
|
*/
|
|
5189
5543
|
_groups() {
|
|
5190
5544
|
const items = this._conversations;
|
|
5191
|
-
if (this.hasAttribute("
|
|
5545
|
+
if (this.hasAttribute("flat") || !items.some((c2) => typeof c2.updatedAt === "number")) {
|
|
5192
5546
|
return [{ key: "all", label: null, items }];
|
|
5193
5547
|
}
|
|
5194
5548
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -5291,12 +5645,16 @@ class AparteConversationList extends HTMLElement {
|
|
|
5291
5645
|
<div class="aparte-menu__separator" role="separator"></div>
|
|
5292
5646
|
<button type="button" class="aparte-menu__item aparte-conv-menu__item--danger" role="menuitem" data-menu-action="delete">${trashGlyph}<span>${remove}</span></button>`;
|
|
5293
5647
|
}
|
|
5648
|
+
/** The question the confirm step asks — the popover's accessible name while it asks it. */
|
|
5649
|
+
_confirmQuestion(conv) {
|
|
5650
|
+
return this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat"));
|
|
5651
|
+
}
|
|
5294
5652
|
_confirmMarkup(conv) {
|
|
5295
|
-
const question = escapeHtml(this.
|
|
5653
|
+
const question = escapeHtml(this._confirmQuestion(conv));
|
|
5296
5654
|
const cancel = escapeHtml(this._t("cancel"));
|
|
5297
5655
|
const remove = escapeHtml(this._t("deleteConversation"));
|
|
5298
5656
|
return `
|
|
5299
|
-
<div class="aparte-conv-menu__confirm"
|
|
5657
|
+
<div class="aparte-conv-menu__confirm">
|
|
5300
5658
|
<p class="aparte-conv-menu__question">${question}</p>
|
|
5301
5659
|
<div class="aparte-conv-menu__actions">
|
|
5302
5660
|
<button type="button" class="aparte-btn aparte-btn--sm aparte-btn--ghost" data-menu-action="cancel">${cancel}</button>
|
|
@@ -5320,6 +5678,8 @@ class AparteConversationList extends HTMLElement {
|
|
|
5320
5678
|
this._placeMenu(button, menu);
|
|
5321
5679
|
menu.querySelector('[role="menuitem"]')?.focus();
|
|
5322
5680
|
document.addEventListener("pointerdown", this._onDocumentPointerDown, true);
|
|
5681
|
+
document.addEventListener("pointerup", this._onDocumentPointerUp, true);
|
|
5682
|
+
document.addEventListener("pointercancel", this._onDocumentPointerUp, true);
|
|
5323
5683
|
window.addEventListener("scroll", this._onWindowScroll, true);
|
|
5324
5684
|
window.addEventListener("resize", this._onWindowScroll);
|
|
5325
5685
|
}
|
|
@@ -5350,14 +5710,29 @@ class AparteConversationList extends HTMLElement {
|
|
|
5350
5710
|
open.menu.remove();
|
|
5351
5711
|
open.button.setAttribute("aria-expanded", "false");
|
|
5352
5712
|
document.removeEventListener("pointerdown", this._onDocumentPointerDown, true);
|
|
5713
|
+
document.removeEventListener("pointerup", this._onDocumentPointerUp, true);
|
|
5714
|
+
document.removeEventListener("pointercancel", this._onDocumentPointerUp, true);
|
|
5715
|
+
this._pressInMenu = false;
|
|
5353
5716
|
window.removeEventListener("scroll", this._onWindowScroll, true);
|
|
5354
5717
|
window.removeEventListener("resize", this._onWindowScroll);
|
|
5355
5718
|
if (returnFocus && open.button.isConnected) open.button.focus();
|
|
5356
5719
|
}
|
|
5720
|
+
/**
|
|
5721
|
+
* A pointer is down inside the open menu. WebKit does not move the focus to a
|
|
5722
|
+
* button on mousedown: the element that held it is blurred instead — `focusout`
|
|
5723
|
+
* with no `relatedTarget` — before the click reaches the item. `_onFocusOut` used
|
|
5724
|
+
* to read that as the focus leaving the menu and close it on the press, so with a
|
|
5725
|
+
* mouse no item could be chosen at all. Held from the press to its release.
|
|
5726
|
+
*/
|
|
5727
|
+
_pressInMenu = false;
|
|
5728
|
+
_onDocumentPointerUp = () => {
|
|
5729
|
+
this._pressInMenu = false;
|
|
5730
|
+
};
|
|
5357
5731
|
_onDocumentPointerDown = (e) => {
|
|
5358
5732
|
const open = this._open;
|
|
5359
5733
|
if (!open) return;
|
|
5360
|
-
const target = e
|
|
5734
|
+
const target = eventOrigin(e);
|
|
5735
|
+
this._pressInMenu = !!target && open.menu.contains(target);
|
|
5361
5736
|
if (open.menu.contains(target) || open.button.contains(target)) return;
|
|
5362
5737
|
this._closeMenu();
|
|
5363
5738
|
};
|
|
@@ -5368,9 +5743,37 @@ class AparteConversationList extends HTMLElement {
|
|
|
5368
5743
|
const open = this._open;
|
|
5369
5744
|
if (!open) return;
|
|
5370
5745
|
const next = e.relatedTarget;
|
|
5746
|
+
if (!next && this._pressInMenu) return;
|
|
5371
5747
|
if (next && (open.menu.contains(next) || open.button.contains(next))) return;
|
|
5372
5748
|
if (open.menu.contains(e.target)) this._closeMenu();
|
|
5373
5749
|
};
|
|
5750
|
+
/**
|
|
5751
|
+
* The gesture, finished. The event has already gone out — cancelable, so a host
|
|
5752
|
+
* that wants the write for itself calls `preventDefault()` and this stands down —
|
|
5753
|
+
* and what is left is the registered conversation manager doing it. Without
|
|
5754
|
+
* `manage` the element behaves exactly as it did before the attribute existed.
|
|
5755
|
+
*
|
|
5756
|
+
* A `manage` with no manager registered is a menu that does nothing, so the first
|
|
5757
|
+
* gesture says so and names the call that fixes it. Said here rather than at
|
|
5758
|
+
* `connectedCallback` because a list in the page's HTML connects before the script
|
|
5759
|
+
* that registers the manager runs: a warning at connect would fire on a setup that
|
|
5760
|
+
* is perfectly correct one tick later.
|
|
5761
|
+
*/
|
|
5762
|
+
_manage(event, run) {
|
|
5763
|
+
if (!this.hasAttribute("manage") || event.defaultPrevented) return;
|
|
5764
|
+
const manager = resolveConfig(this).getConversationManager();
|
|
5765
|
+
if (!manager) {
|
|
5766
|
+
if (this._warnedNoManager) return;
|
|
5767
|
+
this._warnedNoManager = true;
|
|
5768
|
+
console.warn(
|
|
5769
|
+
"[aparte-conversation-list] `manage` is set but no conversation manager is registered, so the row menu writes nothing. Call setConversationManager(manager) on your config, or drop `manage` and handle the events yourself."
|
|
5770
|
+
);
|
|
5771
|
+
return;
|
|
5772
|
+
}
|
|
5773
|
+
void run(manager).catch((err) => {
|
|
5774
|
+
console.warn("[aparte-conversation-list] the conversation manager refused the change:", err);
|
|
5775
|
+
});
|
|
5776
|
+
}
|
|
5374
5777
|
_onMenuAction(action) {
|
|
5375
5778
|
const open = this._open;
|
|
5376
5779
|
if (!open) return;
|
|
@@ -5392,46 +5795,64 @@ class AparteConversationList extends HTMLElement {
|
|
|
5392
5795
|
case "pin":
|
|
5393
5796
|
this._closeMenu(true);
|
|
5394
5797
|
if (conv.pinnedAt) {
|
|
5395
|
-
|
|
5396
|
-
"aparte-unpin
|
|
5397
|
-
{ detail: { id }, bubbles: true, composed: true }
|
|
5398
|
-
)
|
|
5798
|
+
const unpin = new CustomEvent(
|
|
5799
|
+
"aparte-conversation-unpin",
|
|
5800
|
+
{ detail: { id }, bubbles: true, composed: true, cancelable: true }
|
|
5801
|
+
);
|
|
5802
|
+
this.dispatchEvent(unpin);
|
|
5803
|
+
this._manage(unpin, (m2) => m2.unpin(id));
|
|
5399
5804
|
} else {
|
|
5400
|
-
|
|
5401
|
-
"aparte-pin
|
|
5402
|
-
{ detail: { id }, bubbles: true, composed: true }
|
|
5403
|
-
)
|
|
5805
|
+
const pin = new CustomEvent(
|
|
5806
|
+
"aparte-conversation-pin",
|
|
5807
|
+
{ detail: { id }, bubbles: true, composed: true, cancelable: true }
|
|
5808
|
+
);
|
|
5809
|
+
this.dispatchEvent(pin);
|
|
5810
|
+
this._manage(pin, (m2) => m2.pin(id));
|
|
5404
5811
|
}
|
|
5405
5812
|
return;
|
|
5406
5813
|
case "archive":
|
|
5407
5814
|
this._closeMenu(true);
|
|
5408
5815
|
if (conv.archivedAt) {
|
|
5409
|
-
|
|
5410
|
-
"aparte-unarchive
|
|
5411
|
-
{ detail: { id }, bubbles: true, composed: true }
|
|
5412
|
-
)
|
|
5816
|
+
const unarchive = new CustomEvent(
|
|
5817
|
+
"aparte-conversation-unarchive",
|
|
5818
|
+
{ detail: { id }, bubbles: true, composed: true, cancelable: true }
|
|
5819
|
+
);
|
|
5820
|
+
this.dispatchEvent(unarchive);
|
|
5821
|
+
this._manage(unarchive, (m2) => m2.unarchive(id));
|
|
5413
5822
|
} else {
|
|
5414
|
-
|
|
5415
|
-
"aparte-archive
|
|
5416
|
-
{ detail: { id }, bubbles: true, composed: true }
|
|
5417
|
-
)
|
|
5823
|
+
const archive = new CustomEvent(
|
|
5824
|
+
"aparte-conversation-archive",
|
|
5825
|
+
{ detail: { id }, bubbles: true, composed: true, cancelable: true }
|
|
5826
|
+
);
|
|
5827
|
+
this.dispatchEvent(archive);
|
|
5828
|
+
this._manage(archive, (m2) => m2.archive(id));
|
|
5418
5829
|
}
|
|
5419
5830
|
return;
|
|
5420
|
-
case "delete":
|
|
5421
|
-
open.menu.
|
|
5422
|
-
|
|
5831
|
+
case "delete": {
|
|
5832
|
+
const previous = [...open.menu.childNodes];
|
|
5833
|
+
const holder = document.createElement("div");
|
|
5834
|
+
holder.innerHTML = this._confirmMarkup(conv);
|
|
5835
|
+
open.menu.append(...holder.childNodes);
|
|
5836
|
+
open.menu.setAttribute("role", "dialog");
|
|
5837
|
+
open.menu.setAttribute("aria-label", this._confirmQuestion(conv));
|
|
5423
5838
|
open.menu.querySelector('[data-menu-action="cancel"]')?.focus();
|
|
5839
|
+
for (const node of previous) node.remove();
|
|
5840
|
+
this._placeMenu(open.button, open.menu);
|
|
5424
5841
|
return;
|
|
5842
|
+
}
|
|
5425
5843
|
case "cancel":
|
|
5426
5844
|
this._closeMenu(true);
|
|
5427
5845
|
return;
|
|
5428
|
-
case "confirm-delete":
|
|
5846
|
+
case "confirm-delete": {
|
|
5429
5847
|
this._closeMenu(true);
|
|
5430
|
-
|
|
5431
|
-
"aparte-delete
|
|
5432
|
-
{ detail: { id }, bubbles: true, composed: true }
|
|
5433
|
-
)
|
|
5848
|
+
const remove = new CustomEvent(
|
|
5849
|
+
"aparte-conversation-delete",
|
|
5850
|
+
{ detail: { id }, bubbles: true, composed: true, cancelable: true }
|
|
5851
|
+
);
|
|
5852
|
+
this.dispatchEvent(remove);
|
|
5853
|
+
this._manage(remove, (m2) => m2.delete(id));
|
|
5434
5854
|
return;
|
|
5855
|
+
}
|
|
5435
5856
|
}
|
|
5436
5857
|
}
|
|
5437
5858
|
_menuFocusables() {
|
|
@@ -5441,7 +5862,7 @@ class AparteConversationList extends HTMLElement {
|
|
|
5441
5862
|
}
|
|
5442
5863
|
_onMenuKeydown(e) {
|
|
5443
5864
|
const items = this._menuFocusables();
|
|
5444
|
-
const index = items.indexOf(
|
|
5865
|
+
const index = items.indexOf(activeElementIn(this));
|
|
5445
5866
|
const focusAt = (i2) => {
|
|
5446
5867
|
e.preventDefault();
|
|
5447
5868
|
items[(i2 + items.length) % items.length]?.focus();
|
|
@@ -5507,10 +5928,12 @@ class AparteConversationList extends HTMLElement {
|
|
|
5507
5928
|
const title = renaming.input.value.trim();
|
|
5508
5929
|
this._render();
|
|
5509
5930
|
if (commit && conv && title && title !== conv.title) {
|
|
5510
|
-
|
|
5511
|
-
"aparte-rename
|
|
5512
|
-
{ detail: { id: conv.id, title }, bubbles: true, composed: true }
|
|
5513
|
-
)
|
|
5931
|
+
const rename = new CustomEvent(
|
|
5932
|
+
"aparte-conversation-rename",
|
|
5933
|
+
{ detail: { id: conv.id, title }, bubbles: true, composed: true, cancelable: true }
|
|
5934
|
+
);
|
|
5935
|
+
this.dispatchEvent(rename);
|
|
5936
|
+
this._manage(rename, (m2) => m2.updateTitle(conv.id, title));
|
|
5514
5937
|
}
|
|
5515
5938
|
if (restoreFocus) {
|
|
5516
5939
|
this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
|
|
@@ -5540,7 +5963,7 @@ class AparteConversationList extends HTMLElement {
|
|
|
5540
5963
|
if (select) {
|
|
5541
5964
|
this._closeMenu();
|
|
5542
5965
|
this.dispatchEvent(new CustomEvent(
|
|
5543
|
-
"aparte-select
|
|
5966
|
+
"aparte-conversation-select",
|
|
5544
5967
|
{ detail: { id: select.dataset["selectId"] }, bubbles: true, composed: true }
|
|
5545
5968
|
));
|
|
5546
5969
|
}
|
|
@@ -5574,16 +5997,52 @@ class AparteConversationList extends HTMLElement {
|
|
|
5574
5997
|
}
|
|
5575
5998
|
if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
|
|
5576
5999
|
const isSuggestion = (value) => typeof value === "string" || typeof value === "object" && value !== null && typeof value.label === "string";
|
|
6000
|
+
const parseSuggestions = (raw) => {
|
|
6001
|
+
try {
|
|
6002
|
+
const parsed = JSON.parse(raw);
|
|
6003
|
+
return Array.isArray(parsed) ? parsed.filter(isSuggestion) : [];
|
|
6004
|
+
} catch {
|
|
6005
|
+
console.warn('[aparte-suggestions] `suggestions` is not a JSON array. Use ["…"] or [{"label": "…", "prompt": "…"}].');
|
|
6006
|
+
return [];
|
|
6007
|
+
}
|
|
6008
|
+
};
|
|
5577
6009
|
const labelOf = (s2) => typeof s2 === "string" ? s2 : s2.label;
|
|
5578
6010
|
const promptOf = (s2) => typeof s2 === "string" ? s2 : s2.prompt ?? s2.label;
|
|
5579
6011
|
class AparteSuggestions extends HTMLElement {
|
|
5580
6012
|
static get observedAttributes() {
|
|
5581
|
-
return ["suggestions", "target"];
|
|
6013
|
+
return ["suggestions", "target", "empty-only"];
|
|
5582
6014
|
}
|
|
5583
6015
|
_suggestions = [];
|
|
5584
6016
|
_composer = null;
|
|
6017
|
+
/** The viewport of the chat this row serves, while `empty-only` follows it. */
|
|
6018
|
+
_transcript = null;
|
|
6019
|
+
_transcriptObserver = null;
|
|
6020
|
+
/** The composer sent since the transcript was last seen empty. */
|
|
6021
|
+
_sent = false;
|
|
6022
|
+
_wasEmpty = true;
|
|
6023
|
+
/** `hidden` was written by this element, so it is this element's to lift. */
|
|
6024
|
+
_hidOwn = false;
|
|
6025
|
+
/*
|
|
6026
|
+
* `empty-only` used to read the send alone: hidden on the first one, and never shown
|
|
6027
|
+
* again. Over a restored thread (nothing sent yet) the starters sat on top of the
|
|
6028
|
+
* transcript, and after a new chat they never came back. The transcript is the
|
|
6029
|
+
* signal now; the send stays as the other half, because it comes first — the row
|
|
6030
|
+
* hides on the click rather than a frame later when the bubble lands — and because
|
|
6031
|
+
* it is all there is to read when the messages are not `<aparte-chat-bubble>`s
|
|
6032
|
+
* (a wrapper's `renderBubble`) or there is no transcript at all. A transcript seen
|
|
6033
|
+
* going from full to empty is a new conversation, which forgets the send.
|
|
6034
|
+
*/
|
|
5585
6035
|
_onSend = () => {
|
|
5586
|
-
if (this.hasAttribute("empty-only"))
|
|
6036
|
+
if (!this.hasAttribute("empty-only")) return;
|
|
6037
|
+
if (!this._transcript) this._watchTranscript();
|
|
6038
|
+
this._sent = true;
|
|
6039
|
+
this._applyEmptyOnly();
|
|
6040
|
+
};
|
|
6041
|
+
_applyEmptyOnly = () => {
|
|
6042
|
+
const empty = this._transcript ? isTranscriptEmpty(this._transcript) : true;
|
|
6043
|
+
if (empty && !this._wasEmpty) this._sent = false;
|
|
6044
|
+
this._wasEmpty = empty;
|
|
6045
|
+
this._setHidden(!empty || this._sent);
|
|
5587
6046
|
};
|
|
5588
6047
|
_onConfigChange = () => {
|
|
5589
6048
|
const row = this.querySelector(".aparte-suggestions");
|
|
@@ -5593,8 +6052,17 @@ class AparteSuggestions extends HTMLElement {
|
|
|
5593
6052
|
get suggestions() {
|
|
5594
6053
|
return this._suggestions;
|
|
5595
6054
|
}
|
|
6055
|
+
/**
|
|
6056
|
+
* Takes the array, or the JSON string the attribute documents.
|
|
6057
|
+
*
|
|
6058
|
+
* Both, because both arrive: React 19 and Svelte assign the PROPERTY whenever the
|
|
6059
|
+
* element has one of that name, so `<aparte-suggestions suggestions='[…]'>` — the
|
|
6060
|
+
* documented spelling, typed valid by both wrappers — reached this setter as a
|
|
6061
|
+
* string, `Array.isArray` said no, and the row rendered nothing while the attribute
|
|
6062
|
+
* (and so `attributeChangedCallback`) was never written.
|
|
6063
|
+
*/
|
|
5596
6064
|
set suggestions(value) {
|
|
5597
|
-
this._suggestions = Array.isArray(value) ? value.filter(isSuggestion) : [];
|
|
6065
|
+
this._suggestions = typeof value === "string" ? parseSuggestions(value) : Array.isArray(value) ? value.filter(isSuggestion) : [];
|
|
5598
6066
|
if (this.isConnected) this._render();
|
|
5599
6067
|
}
|
|
5600
6068
|
connectedCallback() {
|
|
@@ -5606,6 +6074,9 @@ class AparteSuggestions extends HTMLElement {
|
|
|
5606
6074
|
disconnectedCallback() {
|
|
5607
6075
|
this._composer?.removeEventListener("aparte-send", this._onSend);
|
|
5608
6076
|
this._composer = null;
|
|
6077
|
+
this._transcriptObserver?.disconnect();
|
|
6078
|
+
this._transcriptObserver = null;
|
|
6079
|
+
this._transcript = null;
|
|
5609
6080
|
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
5610
6081
|
}
|
|
5611
6082
|
attributeChangedCallback(name) {
|
|
@@ -5615,6 +6086,12 @@ class AparteSuggestions extends HTMLElement {
|
|
|
5615
6086
|
this._render();
|
|
5616
6087
|
} else if (name === "target") {
|
|
5617
6088
|
this._watchComposer();
|
|
6089
|
+
} else if (name === "empty-only") {
|
|
6090
|
+
this._watchTranscript();
|
|
6091
|
+
if (!this.hasAttribute("empty-only")) {
|
|
6092
|
+
this._sent = false;
|
|
6093
|
+
this._setHidden(false);
|
|
6094
|
+
}
|
|
5618
6095
|
}
|
|
5619
6096
|
}
|
|
5620
6097
|
_groupLabel() {
|
|
@@ -5630,13 +6107,7 @@ class AparteSuggestions extends HTMLElement {
|
|
|
5630
6107
|
const raw = this.getAttribute("suggestions");
|
|
5631
6108
|
if (raw === null || raw === this._parsed) return;
|
|
5632
6109
|
this._parsed = raw;
|
|
5633
|
-
|
|
5634
|
-
const parsed = JSON.parse(raw);
|
|
5635
|
-
this._suggestions = Array.isArray(parsed) ? parsed.filter(isSuggestion) : [];
|
|
5636
|
-
} catch {
|
|
5637
|
-
console.warn('[aparte-suggestions] `suggestions` is not a JSON array. Use ["…"] or [{"label": "…", "prompt": "…"}].');
|
|
5638
|
-
this._suggestions = [];
|
|
5639
|
-
}
|
|
6110
|
+
this._suggestions = parseSuggestions(raw);
|
|
5640
6111
|
}
|
|
5641
6112
|
/**
|
|
5642
6113
|
* The composer the click goes through, resolved the way the composer's own parts
|
|
@@ -5662,6 +6133,49 @@ class AparteSuggestions extends HTMLElement {
|
|
|
5662
6133
|
this._composer?.removeEventListener("aparte-send", this._onSend);
|
|
5663
6134
|
this._composer = this._resolveComposer();
|
|
5664
6135
|
this._composer?.addEventListener("aparte-send", this._onSend);
|
|
6136
|
+
this._watchTranscript();
|
|
6137
|
+
}
|
|
6138
|
+
/**
|
|
6139
|
+
* The transcript of the chat the click goes to: the chat around the composer, else
|
|
6140
|
+
* the one around this element, else the one `target` names (here, or on that
|
|
6141
|
+
* composer). No last resort on the page, unlike the composer's: following another
|
|
6142
|
+
* chat's transcript would be worse than following none, which falls back to the send.
|
|
6143
|
+
*/
|
|
6144
|
+
_resolveTranscript() {
|
|
6145
|
+
const id = this.getAttribute("target") ?? this._composer?.getAttribute("target") ?? null;
|
|
6146
|
+
const chat = chatBoundaryOf(this._composer) ?? chatBoundaryOf(this) ?? (id ? document.getElementById(id) : null);
|
|
6147
|
+
if (!chat) return null;
|
|
6148
|
+
return chat.matches("aparte-chat-viewport") ? chat : chat.querySelector("aparte-chat-viewport");
|
|
6149
|
+
}
|
|
6150
|
+
/** Follow the transcript while `empty-only` is set, and stop following it when it is not. */
|
|
6151
|
+
_watchTranscript() {
|
|
6152
|
+
const transcript = this.hasAttribute("empty-only") ? this._resolveTranscript() : null;
|
|
6153
|
+
if (transcript !== this._transcript) {
|
|
6154
|
+
this._transcriptObserver?.disconnect();
|
|
6155
|
+
this._transcriptObserver = null;
|
|
6156
|
+
this._transcript = transcript;
|
|
6157
|
+
if (transcript) {
|
|
6158
|
+
this._transcriptObserver = new MutationObserver(this._applyEmptyOnly);
|
|
6159
|
+
this._transcriptObserver.observe(transcript, {
|
|
6160
|
+
childList: true,
|
|
6161
|
+
subtree: true,
|
|
6162
|
+
attributes: true,
|
|
6163
|
+
attributeFilter: ["loading"]
|
|
6164
|
+
});
|
|
6165
|
+
}
|
|
6166
|
+
}
|
|
6167
|
+
if (this.hasAttribute("empty-only")) this._applyEmptyOnly();
|
|
6168
|
+
}
|
|
6169
|
+
/** Set `hidden`, or lift it only when this element set it: a `hidden` the page wrote stays. */
|
|
6170
|
+
_setHidden(hide) {
|
|
6171
|
+
if (hide) {
|
|
6172
|
+
if (this.hidden) return;
|
|
6173
|
+
this.hidden = true;
|
|
6174
|
+
this._hidOwn = true;
|
|
6175
|
+
} else if (this._hidOwn) {
|
|
6176
|
+
this.hidden = false;
|
|
6177
|
+
this._hidOwn = false;
|
|
6178
|
+
}
|
|
5665
6179
|
}
|
|
5666
6180
|
_render() {
|
|
5667
6181
|
this.replaceChildren();
|
|
@@ -5778,6 +6292,17 @@ class AparteContext extends HTMLElement {
|
|
|
5778
6292
|
get window() {
|
|
5779
6293
|
return this._window();
|
|
5780
6294
|
}
|
|
6295
|
+
/**
|
|
6296
|
+
* Writes the `window` attribute; `null` gives the model's own window back.
|
|
6297
|
+
*
|
|
6298
|
+
* The setter has to exist: React 19 and Svelte assign the PROPERTY whenever the
|
|
6299
|
+
* element has one of that name, so `<aparte-context window={8000}>` — a spelling
|
|
6300
|
+
* both wrappers type as valid — hit a getter alone and threw.
|
|
6301
|
+
*/
|
|
6302
|
+
set window(value) {
|
|
6303
|
+
if (value === null || value === void 0) this.removeAttribute("window");
|
|
6304
|
+
else this.setAttribute("window", String(value));
|
|
6305
|
+
}
|
|
5781
6306
|
/** The current level, or `null` while nothing is shown. */
|
|
5782
6307
|
get level() {
|
|
5783
6308
|
return this._level;
|
|
@@ -6038,7 +6563,8 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6038
6563
|
_rebuild() {
|
|
6039
6564
|
const bubbles = this._bubbles();
|
|
6040
6565
|
const locale = resolveConfig(this).getLocale();
|
|
6041
|
-
|
|
6566
|
+
const railLabel = locale.scrollRailLabel ?? "Conversation outline";
|
|
6567
|
+
if (this.getAttribute("aria-label") !== railLabel) this.setAttribute("aria-label", railLabel);
|
|
6042
6568
|
if (bubbles.length < 2) {
|
|
6043
6569
|
this.setAttribute("data-empty", "");
|
|
6044
6570
|
this.replaceChildren();
|
|
@@ -6081,8 +6607,11 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6081
6607
|
tick.setAttribute("aria-label", label);
|
|
6082
6608
|
tick.title = label;
|
|
6083
6609
|
}
|
|
6084
|
-
if (id === this._currentId)
|
|
6085
|
-
|
|
6610
|
+
if (id === this._currentId) {
|
|
6611
|
+
if (tick.getAttribute("aria-current") !== "true") tick.setAttribute("aria-current", "true");
|
|
6612
|
+
} else {
|
|
6613
|
+
tick.removeAttribute("aria-current");
|
|
6614
|
+
}
|
|
6086
6615
|
const at = list.children[index] ?? null;
|
|
6087
6616
|
if (at !== item) list.insertBefore(item, at);
|
|
6088
6617
|
index++;
|
|
@@ -6264,7 +6793,7 @@ class AparteScrollRail extends HTMLElement {
|
|
|
6264
6793
|
_onKeydown = (e) => {
|
|
6265
6794
|
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
|
|
6266
6795
|
const ticks = Array.from(this.querySelectorAll(".aparte-scroll-rail__tick"));
|
|
6267
|
-
const index = ticks.indexOf(
|
|
6796
|
+
const index = ticks.indexOf(activeElementIn(this));
|
|
6268
6797
|
if (index === -1) return;
|
|
6269
6798
|
e.preventDefault();
|
|
6270
6799
|
const next = e.key === "Home" ? 0 : e.key === "End" ? ticks.length - 1 : e.key === "ArrowDown" ? Math.min(ticks.length - 1, index + 1) : Math.max(0, index - 1);
|
|
@@ -6521,7 +7050,10 @@ class AparteSidebar extends HTMLElement {
|
|
|
6521
7050
|
* lies. The row's own `⋯` button already did this; the sidebar's toggle did not.
|
|
6522
7051
|
*/
|
|
6523
7052
|
_syncToggles() {
|
|
6524
|
-
|
|
7053
|
+
const selector = "[data-aparte-sidebar-toggle]";
|
|
7054
|
+
const root = rootOf(this);
|
|
7055
|
+
const controls = root === document ? Array.from(document.querySelectorAll(selector)) : [...root.querySelectorAll(selector), ...document.querySelectorAll(selector)];
|
|
7056
|
+
for (const control of controls) {
|
|
6525
7057
|
if (!this._drives(control)) continue;
|
|
6526
7058
|
control.setAttribute("aria-expanded", String(!this.collapsed));
|
|
6527
7059
|
control.setAttribute("aria-controls", this.id);
|
|
@@ -6594,7 +7126,7 @@ class AparteSidebar extends HTMLElement {
|
|
|
6594
7126
|
if (focusable.length === 0) return;
|
|
6595
7127
|
const first = focusable[0];
|
|
6596
7128
|
const last = focusable[focusable.length - 1];
|
|
6597
|
-
const active =
|
|
7129
|
+
const active = activeElementIn(this);
|
|
6598
7130
|
if (!this.contains(active)) {
|
|
6599
7131
|
e.preventDefault();
|
|
6600
7132
|
first.focus();
|
|
@@ -6609,7 +7141,7 @@ class AparteSidebar extends HTMLElement {
|
|
|
6609
7141
|
};
|
|
6610
7142
|
/** A `[data-aparte-sidebar-toggle]` anywhere toggles this sidebar — no script in the host. */
|
|
6611
7143
|
_onDocumentClick = (e) => {
|
|
6612
|
-
const control = e
|
|
7144
|
+
const control = eventOrigin(e)?.closest?.("[data-aparte-sidebar-toggle]");
|
|
6613
7145
|
if (!control) return;
|
|
6614
7146
|
if (!this._drives(control)) return;
|
|
6615
7147
|
this.toggle(control);
|
|
@@ -6658,7 +7190,7 @@ function visibleFocusable(root) {
|
|
|
6658
7190
|
return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el2) => !el2.closest("[hidden]") && el2.checkVisibility?.() !== false);
|
|
6659
7191
|
}
|
|
6660
7192
|
function nearestSidebar(control) {
|
|
6661
|
-
return control.closest("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
|
|
7193
|
+
return control.closest("aparte-sidebar") ?? rootOf(control).querySelector("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
|
|
6662
7194
|
}
|
|
6663
7195
|
function fold(text) {
|
|
6664
7196
|
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
|
|
@@ -7385,15 +7917,16 @@ class AparteSplit extends HTMLElement {
|
|
|
7385
7917
|
* first on the page. Same shape as `[data-aparte-sidebar-toggle]`.
|
|
7386
7918
|
*/
|
|
7387
7919
|
_onDocumentClick = (e) => {
|
|
7388
|
-
const control = e
|
|
7920
|
+
const control = eventOrigin(e)?.closest?.("[data-aparte-split-pane]");
|
|
7389
7921
|
if (!control) return;
|
|
7390
7922
|
const value = (control.getAttribute("data-aparte-split-pane") ?? "").trim();
|
|
7391
7923
|
const which = value === "start" || value === "end" ? value : null;
|
|
7392
7924
|
const named = which ? "" : value;
|
|
7393
7925
|
if (named) {
|
|
7394
7926
|
if (named !== this.id) return;
|
|
7395
|
-
} else
|
|
7396
|
-
|
|
7927
|
+
} else {
|
|
7928
|
+
const nearest = control.closest("aparte-split") ?? rootOf(control).querySelector("aparte-split") ?? document.querySelector("aparte-split");
|
|
7929
|
+
if (nearest !== this) return;
|
|
7397
7930
|
}
|
|
7398
7931
|
this.showPane(which ?? (this.pane === "start" ? "end" : "start"));
|
|
7399
7932
|
};
|
|
@@ -7525,10 +8058,10 @@ export {
|
|
|
7525
8058
|
installDialogTriggersOnce,
|
|
7526
8059
|
isAwaitingReply,
|
|
7527
8060
|
R as isFormatAdapter,
|
|
7528
|
-
|
|
8061
|
+
isSafeUrl,
|
|
7529
8062
|
T as isSegmentSettled,
|
|
7530
8063
|
U as parseAparteEventStream,
|
|
7531
|
-
|
|
8064
|
+
parseMarkdownToSegments,
|
|
7532
8065
|
populateBubbleFromMessage,
|
|
7533
8066
|
X as readableToAsyncIterable,
|
|
7534
8067
|
registerAllComponents,
|