@aparte/core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/client/aparte-client.d.ts +54 -33
  2. package/dist/client/aparte-client.d.ts.map +1 -1
  3. package/dist/client/stream-adapter.d.ts +6 -1
  4. package/dist/client/stream-adapter.d.ts.map +1 -1
  5. package/dist/components/bubble/aparte-chat-bubble.d.ts +49 -7
  6. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  7. package/dist/components/chat/aparte-chat.d.ts +8 -4
  8. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  9. package/dist/components/composer/aparte-composer-action.d.ts +5 -5
  10. package/dist/components/composer/aparte-composer-action.d.ts.map +1 -1
  11. package/dist/components/composer/aparte-composer-add-attachment.d.ts +16 -4
  12. package/dist/components/composer/aparte-composer-add-attachment.d.ts.map +1 -1
  13. package/dist/components/composer/aparte-composer-attachments.d.ts +5 -3
  14. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  15. package/dist/components/composer/aparte-composer-cancel.d.ts +10 -2
  16. package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -1
  17. package/dist/components/composer/aparte-composer-input.d.ts +8 -5
  18. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
  19. package/dist/components/composer/aparte-composer-send.d.ts +34 -2
  20. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  21. package/dist/components/composer/aparte-composer-toolbar.d.ts +3 -3
  22. package/dist/components/composer/aparte-composer.d.ts +54 -10
  23. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  24. package/dist/components/conversation-list/aparte-conversation-list.d.ts +19 -12
  25. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  26. package/dist/components/elicitation/aparte-elicitation.d.ts +22 -0
  27. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  28. package/dist/components/status/aparte-chat-status.d.ts +9 -0
  29. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  30. package/dist/components/viewport/aparte-chat-viewport.d.ts +18 -5
  31. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  32. package/dist/config/aparte-config.d.ts +54 -2
  33. package/dist/config/aparte-config.d.ts.map +1 -1
  34. package/dist/config/config-subscribe.d.ts +4 -0
  35. package/dist/config/config-subscribe.d.ts.map +1 -0
  36. package/dist/config/index.d.ts +1 -0
  37. package/dist/config/index.d.ts.map +1 -1
  38. package/dist/config/locale.d.ts +141 -0
  39. package/dist/config/locale.d.ts.map +1 -1
  40. package/dist/config/sanitize.d.ts.map +1 -1
  41. package/dist/custom-elements.json +5905 -4694
  42. package/dist/elicitation/approval-panel.d.ts +42 -0
  43. package/dist/elicitation/approval-panel.d.ts.map +1 -0
  44. package/dist/elicitation/index.d.ts +4 -1
  45. package/dist/elicitation/index.d.ts.map +1 -1
  46. package/dist/elicitation/panel.d.ts +14 -1
  47. package/dist/elicitation/panel.d.ts.map +1 -1
  48. package/dist/elicitation/types.d.ts +116 -9
  49. package/dist/elicitation/types.d.ts.map +1 -1
  50. package/dist/generated/element-attributes.d.ts +173 -0
  51. package/dist/generated/element-attributes.d.ts.map +1 -0
  52. package/dist/host/aparte-chat-host.d.ts +3 -1
  53. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  54. package/dist/index.css +752 -392
  55. package/dist/index.d.ts +5 -4
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +538 -290
  58. package/dist/index.js.map +1 -1
  59. package/dist/index.node.d.ts +5 -4
  60. package/dist/index.node.d.ts.map +1 -1
  61. package/dist/index.node.js +62 -57
  62. package/dist/index.node.js.map +1 -1
  63. package/dist/interop/element-props.d.ts +26 -1
  64. package/dist/interop/element-props.d.ts.map +1 -1
  65. package/dist/{index-hQQljwfX.js → is-awaiting-reply-Cb7H3YfN.js} +1159 -972
  66. package/dist/is-awaiting-reply-Cb7H3YfN.js.map +1 -0
  67. package/dist/parsers/aparte-stream-parser.d.ts.map +1 -1
  68. package/dist/primitives/select/aparte-optgroup.d.ts +3 -0
  69. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  70. package/dist/primitives/select/aparte-option.d.ts +1 -0
  71. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  72. package/dist/primitives/select/aparte-select.d.ts +22 -18
  73. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  74. package/dist/renderers/highlight-stream.d.ts +17 -0
  75. package/dist/renderers/highlight-stream.d.ts.map +1 -0
  76. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  77. package/dist/renderers/segments/artifact/binary-file.d.ts.map +1 -1
  78. package/dist/renderers/segments/artifact/card.d.ts.map +1 -1
  79. package/dist/renderers/segments/artifact/shared.d.ts +19 -2
  80. package/dist/renderers/segments/artifact/shared.d.ts.map +1 -1
  81. package/dist/renderers/segments/code.d.ts.map +1 -1
  82. package/dist/renderers/segments/error.d.ts.map +1 -1
  83. package/dist/renderers/segments/pipeline-waiting.d.ts.map +1 -1
  84. package/dist/renderers/segments/thinking.d.ts.map +1 -1
  85. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  86. package/dist/types/element-attributes.d.ts +42 -0
  87. package/dist/types/element-attributes.d.ts.map +1 -0
  88. package/dist/types/event-map.d.ts +2 -4
  89. package/dist/types/event-map.d.ts.map +1 -1
  90. package/dist/types/events.d.ts +0 -22
  91. package/dist/types/events.d.ts.map +1 -1
  92. package/dist/types/index.d.ts +4 -3
  93. package/dist/types/index.d.ts.map +1 -1
  94. package/dist/types/models.d.ts +1 -6
  95. package/dist/types/models.d.ts.map +1 -1
  96. package/dist/types/segments.d.ts +87 -74
  97. package/dist/types/segments.d.ts.map +1 -1
  98. package/dist/types/tools.d.ts +15 -21
  99. package/dist/types/tools.d.ts.map +1 -1
  100. package/dist/utils/segments.d.ts +83 -5
  101. package/dist/utils/segments.d.ts.map +1 -1
  102. package/package.json +3 -2
  103. package/dist/index-hQQljwfX.js.map +0 -1
  104. package/dist/renderers/segments/file-tree.d.ts +0 -3
  105. package/dist/renderers/segments/file-tree.d.ts.map +0 -1
  106. package/dist/renderers/segments/progress.d.ts +0 -3
  107. package/dist/renderers/segments/progress.d.ts.map +0 -1
  108. package/dist/renderers/segments/terminal.d.ts +0 -3
  109. package/dist/renderers/segments/terminal.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { H as escapeAttr, Y as resolveConfig, C as cssEscape, _ as runWithConfig, I as escapeHtml, L as getSegmentRenderer, M as installDefaultRenderersOnce, q as AparteMessageRepository, a2 as stampSegmentActivity, a3 as stampSegmentOnInsert, a4 as stampSegmentOnUpdate, a5 as renumberSegments, a6 as openSegmentIds, a7 as isTerminalStatus, N as isAwaitingReply, T as populateBubbleFromMessage, a1 as uuid, Z as revokeAttachmentUrls, v as buildElicitationPanel } from "./index-hQQljwfX.js";
2
- import { A, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, r, s, t, u, w, x, y, z, B, D, E, F, G, J, K, O, P, Q, R, S, U, V, W, X, $, a0 } from "./index-hQQljwfX.js";
1
+ import { a4 as subscribeConfigChange, $ as resolveConfig, a1 as runWithConfig, p as AparteElicitationAbortError, x as buildApprovalPanel, y as buildElicitationPanel, K as escapeAttr, F as cssEscape, a7 as mergeSegmentUpdate, L as escapeHtml, O as getSegmentRenderer, P as installDefaultRenderersOnce, s as AparteMessageRepository, a8 as stampSegmentActivity, a9 as stampSegmentOnInsert, aa as stampSegmentOnUpdate, ab as renumberSegments, ac as openSegmentIds, ad as isTerminalStatus, ae as adoptMessageSegments, Q as isAwaitingReply, W as populateBubbleFromMessage, a6 as uuid, a0 as revokeAttachmentUrls } from "./is-awaiting-reply-Cb7H3YfN.js";
2
+ import { A, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, q, r, t, u, v, w, z, B, C, D, E, G, H, I, J, M, N, R, S, T, U, V, X, Y, Z, _, a2, a3, a5 } from "./is-awaiting-reply-Cb7H3YfN.js";
3
3
  class AparteOption extends HTMLElement {
4
4
  static get observedAttributes() {
5
5
  return ["value", "disabled", "selected", "data-status"];
@@ -631,6 +631,222 @@ class AparteProgressSpinner extends HTMLElement {
631
631
  if (!customElements.get("aparte-progress-spinner")) {
632
632
  customElements.define("aparte-progress-spinner", AparteProgressSpinner);
633
633
  }
634
+ class AparteElicitation extends HTMLElement {
635
+ _pending = null;
636
+ /**
637
+ * A turn ended — cancel the open question only if it was OUR turn.
638
+ *
639
+ * These two listeners sit on `window` and had no instance filter at all, so a
640
+ * Stop (or an error) in one chat cancelled the question a DIFFERENT chat was
641
+ * waiting on — and that chat's model was told the user had refused a question
642
+ * the user was still looking at. Same defect as the `compact()` handler that
643
+ * emptied both chats, which its four sibling handlers in `AparteClient` already
644
+ * guarded against.
645
+ *
646
+ * The leniency rule is the composer's, deliberately: an event with no
647
+ * `targetId` is for everyone (a single-chat app never sets one), and a chat we
648
+ * cannot identify accepts everything rather than becoming deaf. Only a
649
+ * MISMATCH is ignored.
650
+ */
651
+ _onTurnEnd = (e2) => {
652
+ const evtTargetId = e2.detail?.targetId;
653
+ const own = this._pendingTargetId();
654
+ if (evtTargetId && own && evtTargetId !== own) return;
655
+ this._cancelPending();
656
+ };
657
+ _unsubscribeConfig = null;
658
+ connectedCallback() {
659
+ this.style.display = "none";
660
+ this._unsubscribeConfig = subscribeConfigChange(this, () => this._relabelPending());
661
+ resolveConfig(this).setElicitationPresenter(this._present);
662
+ window.addEventListener("aparte-message-aborted", this._onTurnEnd);
663
+ window.addEventListener("aparte-message-error", this._onTurnEnd);
664
+ }
665
+ /**
666
+ * The boundary above us appeared, changed, or went away — move the
667
+ * registration with it.
668
+ *
669
+ * `connectedCallback` alone is not enough and cannot be: registering is a
670
+ * WRITE, and under all four wrappers it happens before `attachConfig` runs, so
671
+ * it lands on the global singleton. `requestUserInput()` then resolves the
672
+ * instance config, finds nothing, and answers the model `cancel` — the model
673
+ * hears the user refuse a question the user never saw.
674
+ *
675
+ * See {@link AparteConfigAware}.
676
+ */
677
+ aparteConfigChanged(next, previous) {
678
+ if (previous.getElicitationPresenter() === this._present) previous.setElicitationPresenter(null);
679
+ next.setElicitationPresenter(this._present);
680
+ }
681
+ disconnectedCallback() {
682
+ const cfg = resolveConfig(this);
683
+ if (cfg.getElicitationPresenter() === this._present) cfg.setElicitationPresenter(null);
684
+ window.removeEventListener("aparte-message-aborted", this._onTurnEnd);
685
+ window.removeEventListener("aparte-message-error", this._onTurnEnd);
686
+ this._unsubscribeConfig?.();
687
+ this._unsubscribeConfig = null;
688
+ this._cancelPending();
689
+ }
690
+ /**
691
+ * Re-apply the open question's strings, in place.
692
+ *
693
+ * Two owners, and both have to move or the panel goes bilingual: the panel's own
694
+ * defaults (`relabel`), and the Skip button, which this file builds and this file
695
+ * therefore has to re-text. The composer's one button is a third, and it already
696
+ * follows — `aparte-composer-send` remembers the panel state it was given.
697
+ */
698
+ _relabelPending() {
699
+ if (!this._pending) return;
700
+ const cfg = resolveConfig(this);
701
+ runWithConfig(cfg, () => this._pending.relabel());
702
+ }
703
+ _present = (request) => {
704
+ const composer = this._getComposer();
705
+ if (!composer) return Promise.reject(new AparteElicitationAbortError("no-presenter"));
706
+ return new Promise((resolve, reject) => {
707
+ let done = false;
708
+ const slot = {};
709
+ const close = () => {
710
+ if (done) return false;
711
+ done = true;
712
+ this._pending = null;
713
+ composer.hidePanel(slot.token);
714
+ return true;
715
+ };
716
+ const settle = (result) => {
717
+ if (close()) resolve(result);
718
+ };
719
+ const fail = (reason = "aborted") => {
720
+ if (close()) reject(new AparteElicitationAbortError(reason));
721
+ };
722
+ if (request.signal) {
723
+ if (request.signal.aborted) {
724
+ fail();
725
+ return;
726
+ }
727
+ request.signal.addEventListener("abort", () => fail(), { once: true });
728
+ }
729
+ const cfg = resolveConfig(this);
730
+ if (request.kind === "approval") {
731
+ const panel2 = runWithConfig(cfg, () => buildApprovalPanel(request.message, request.options ?? [], () => {
732
+ composer.setPanelSubmitEnabled(panel2.isComplete(), "submit");
733
+ }));
734
+ panel2.onSettle((answer) => settle({ action: "accept", content: answer }));
735
+ this._pending = { abort: () => fail(), composer, relabel: () => panel2.relabel() };
736
+ slot.token = composer.showPanel(panel2.el, {
737
+ submitEnabled: panel2.isComplete(),
738
+ mode: "submit",
739
+ // The composer's button carries the INSTRUCTION, which is written
740
+ // text — exactly the act that button already means. The options
741
+ // never route through it: a decision is its own click.
742
+ onSubmit: () => {
743
+ if (panel2.isComplete()) settle({ action: "accept", content: panel2.getContent() });
744
+ },
745
+ onEvict: () => fail()
746
+ });
747
+ panel2.focus();
748
+ return;
749
+ }
750
+ const schema = request.schema ?? { type: "string" };
751
+ const panel = runWithConfig(cfg, () => buildElicitationPanel(request.message, schema, () => {
752
+ composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
753
+ }));
754
+ const skip = document.createElement("button");
755
+ skip.type = "button";
756
+ skip.className = "aparte-elic-skip";
757
+ skip.textContent = cfg.t("elicitationSkip");
758
+ skip.addEventListener("click", () => settle({ action: "decline" }));
759
+ panel.dismiss.appendChild(skip);
760
+ this._pending = {
761
+ abort: () => fail(),
762
+ composer,
763
+ relabel: () => {
764
+ panel.relabel();
765
+ skip.textContent = resolveConfig(this).t("elicitationSkip");
766
+ }
767
+ };
768
+ slot.token = composer.showPanel(panel.el, {
769
+ submitEnabled: panel.canProceed(),
770
+ mode: panel.mode(),
771
+ /*
772
+ * Something else took the slot — another request, a conversation switch,
773
+ * or a turn ending. The composer tears the panel down either way; only
774
+ * this callback can settle the promise, and without it the request hung
775
+ * AND `_pending` stayed set, so every later question was short-circuited
776
+ * for the life of the page. `cancel`, not `decline`: nobody declined
777
+ * anything, the question was taken away.
778
+ */
779
+ onEvict: () => fail(),
780
+ onSubmit: () => {
781
+ if (panel.mode() === "advance") {
782
+ panel.proceed();
783
+ composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
784
+ return;
785
+ }
786
+ if (panel.isComplete()) settle({ action: "accept", content: panel.getContent() });
787
+ }
788
+ });
789
+ panel.focus();
790
+ });
791
+ };
792
+ _cancelPending() {
793
+ this._pending?.abort();
794
+ }
795
+ /**
796
+ * The host id of the chat whose composer holds the open panel.
797
+ *
798
+ * Walks up from the composer rather than from `this`, because the panel lives in
799
+ * the composer and that is what the turn belongs to. Matches the hosts
800
+ * `aparte-chat-bubble._resolveTargetId()` matches, for the reason written there:
801
+ * Angular's wrapper root IS the `<aparte-chat>` element, while the plain-root
802
+ * wrappers render a `[data-aparte-chat]` div instead, so matching only the tag
803
+ * resolves `undefined` on three wrappers out of four.
804
+ */
805
+ _pendingTargetId() {
806
+ let el = this._pending?.composer ?? null;
807
+ while (el) {
808
+ const tag = el.tagName?.toLowerCase();
809
+ const isHost = tag === "aparte-chat" || tag === "aparte-chat-component" || el.hasAttribute?.("data-aparte-chat");
810
+ if (isHost && el.id) return el.id;
811
+ el = el.parentElement;
812
+ }
813
+ return void 0;
814
+ }
815
+ /**
816
+ * The composer to present in: the nearest one in an ancestor subtree, and
817
+ * nothing else.
818
+ *
819
+ * There used to be a `document.querySelector('aparte-composer')` fallback, which
820
+ * is the "first chat on the page" bug this repo has now fixed in four other
821
+ * places: on a page with two chats, an elicitation that could not find its own
822
+ * composer mounted its panel in the OTHER chat's — so one conversation's question
823
+ * appeared under the other conversation, and answering it resolved a tool call
824
+ * belonging to a chat the user was not looking at.
825
+ *
826
+ * Returning `null` instead resolves `cancel`, which is honest: nothing was
827
+ * shown, so nothing was answered. The warning names the fix, because this is a
828
+ * setup mistake and only the developer can correct it — the guide's own example
829
+ * puts `<aparte-elicitation>` inside `<aparte-chat>`.
830
+ */
831
+ _getComposer() {
832
+ let node = this.parentElement;
833
+ while (node) {
834
+ const composer = node.querySelector("aparte-composer");
835
+ if (composer && typeof composer.showPanel === "function") return composer;
836
+ const tag = node.tagName?.toLowerCase();
837
+ const isChatBoundary = tag === "aparte-chat" || tag === "aparte-chat-component" || node.hasAttribute?.("data-aparte-chat");
838
+ if (isChatBoundary) break;
839
+ node = node.parentElement;
840
+ }
841
+ console.warn(
842
+ "[aparte-elicitation] No <aparte-composer> in this element's subtree, so the request was cancelled — the model will read that as a refusal. Move <aparte-elicitation> inside the <aparte-chat> it belongs to. It is deliberately NOT borrowing another chat's composer: on a page with two chats that put the question under the wrong one."
843
+ );
844
+ return null;
845
+ }
846
+ }
847
+ if (typeof customElements !== "undefined" && !customElements.get("aparte-elicitation")) {
848
+ customElements.define("aparte-elicitation", AparteElicitation);
849
+ }
634
850
  class AparteChat extends HTMLElement {
635
851
  static get observedAttributes() {
636
852
  return ["placeholder", "disabled", "center-empty", "attachments"];
@@ -683,6 +899,7 @@ class AparteChat extends HTMLElement {
683
899
  const attachments = this.hasAttribute("attachments");
684
900
  this.innerHTML = `
685
901
  <aparte-chat-viewport></aparte-chat-viewport>
902
+ <aparte-elicitation></aparte-elicitation>
686
903
  <aparte-composer${composerAttrs}>
687
904
  <div class="aparte-composer-shell">
688
905
  ${attachments ? "<aparte-composer-attachments></aparte-composer-attachments>" : ""}
@@ -804,7 +1021,38 @@ class AparteChatBubble extends HTMLElement {
804
1021
  this._updateName();
805
1022
  this._updateLocalizedLabels();
806
1023
  this._updateWaiting();
1024
+ this._renderAvatar();
1025
+ this._relabelSegments();
1026
+ this._updateTimestamp(this.getAttribute("timestamp"));
807
1027
  };
1028
+ /**
1029
+ * Ask every rendered segment to re-read its config-derived text.
1030
+ *
1031
+ * Not `_renderSegments()`, which wipes the container and rebuilds: that destroys a
1032
+ * mounted artifact preview, reverts a reasoning block the reader expanded by
1033
+ * clicking `<summary>` (the DOM's real state is never written back to `collapsed`),
1034
+ * resets scroll inside long terminal panes, drops focus from an Approve/Reject
1035
+ * gate, and throws away the incremental Markdown parser's buffered lookahead
1036
+ * mid-stream — for a change that added no content. It also fires container-wide
1037
+ * childList mutations, which is what the viewport's observer reads as "scroll to
1038
+ * the bottom".
1039
+ *
1040
+ * `relabel` is the narrow alternative, bound by the same no-child-node rule as
1041
+ * `update()`. A renderer that has no config-derived text does not implement it,
1042
+ * and this loop simply skips it.
1043
+ */
1044
+ _relabelSegments() {
1045
+ if (!this._segmentsEl) return;
1046
+ for (const segment of this._segments) {
1047
+ const renderer = resolveSegmentRenderer(segment.type, this._cfg);
1048
+ if (!renderer?.relabel) continue;
1049
+ const el = this._segmentsEl.querySelector(
1050
+ `:scope > [data-segment-id="${cssEscape(segment.id)}"]`
1051
+ );
1052
+ if (!el) continue;
1053
+ runWithConfig(this._cfg, () => renderer.relabel(el, segment));
1054
+ }
1055
+ }
808
1056
  /**
809
1057
  * Re-apply the locale strings written straight into the markup by `_render()` —
810
1058
  * the accessible names a screen reader reads, which nothing else refreshes.
@@ -892,7 +1140,7 @@ class AparteChatBubble extends HTMLElement {
892
1140
  }
893
1141
  /** Set segments for rich content */
894
1142
  setSegments(segments) {
895
- this._segments = segments;
1143
+ this._segments = [...segments];
896
1144
  this._renderSegments();
897
1145
  this._updateWaiting();
898
1146
  }
@@ -904,16 +1152,16 @@ class AparteChatBubble extends HTMLElement {
904
1152
  }
905
1153
  /** Update a specific segment */
906
1154
  updateSegment(segmentId, updates) {
907
- const index = this._segments.findIndex((s2) => s2.id === segmentId);
1155
+ const index = this._segments.findIndex((s) => s.id === segmentId);
908
1156
  if (index !== -1) {
909
- const updated = { ...this._segments[index], ...updates };
1157
+ const updated = mergeSegmentUpdate(this._segments[index], updates);
910
1158
  this._segments[index] = updated;
911
1159
  this._applySegmentUpdate(segmentId, updated, updates);
912
1160
  }
913
1161
  }
914
1162
  /** Append content to a segment */
915
1163
  appendToSegment(segmentId, content) {
916
- const segment = this._segments.find((s2) => s2.id === segmentId);
1164
+ const segment = this._segments.find((s) => s.id === segmentId);
917
1165
  if (segment && "content" in segment) {
918
1166
  segment.content += content;
919
1167
  this._applySegmentUpdate(segmentId, segment, { content: segment.content });
@@ -939,7 +1187,7 @@ class AparteChatBubble extends HTMLElement {
939
1187
  * the human-in-the-loop control.
940
1188
  */
941
1189
  removeSegment(segmentId) {
942
- const index = this._segments.findIndex((s2) => s2.id === segmentId);
1190
+ const index = this._segments.findIndex((s) => s.id === segmentId);
943
1191
  if (index !== -1) {
944
1192
  this._segments.splice(index, 1);
945
1193
  }
@@ -1022,7 +1270,7 @@ class AparteChatBubble extends HTMLElement {
1022
1270
  } else {
1023
1271
  warnMissingRenderer(segment.type);
1024
1272
  const fallback = document.createElement("div");
1025
- fallback.className = "segment segment-unknown";
1273
+ fallback.className = "segment aparte-segment-unknown";
1026
1274
  fallback.textContent = `[Unknown segment type: ${segment.type}]`;
1027
1275
  this._segmentsEl.appendChild(fallback);
1028
1276
  }
@@ -1135,9 +1383,12 @@ class AparteChatBubble extends HTMLElement {
1135
1383
  * (same null-guarded degradation as the other region hooks).
1136
1384
  */
1137
1385
  _updateWaiting() {
1386
+ const empty = this._segments.length === 0 && !this._content.trim();
1387
+ const waiting = this._streaming && this._role !== "user" && empty;
1388
+ const box = this.querySelector(".aparte-message-content");
1389
+ if (box) box.hidden = empty && !waiting;
1138
1390
  const el = this.querySelector(".aparte-waiting");
1139
1391
  if (!el) return;
1140
- const waiting = this._streaming && this._role !== "user" && this._segments.length === 0 && !this._content.trim();
1141
1392
  el.hidden = !waiting;
1142
1393
  if (!waiting) return;
1143
1394
  const label = this._cfg.getLocale().typing;
@@ -1145,9 +1396,13 @@ class AparteChatBubble extends HTMLElement {
1145
1396
  if (sr && sr.textContent !== label) sr.textContent = label;
1146
1397
  }
1147
1398
  /**
1148
- * Hand the avatar host element off to the registered AvatarProvider, if
1149
- * any. Falls back to the default initial / image rendered by `_render()`
1150
- * when no provider is set.
1399
+ * Hand the avatar host element off to the registered AvatarProvider, if any.
1400
+ *
1401
+ * With no provider the slot is left exactly as the shell rendered it — which for
1402
+ * the default shell means EMPTY, and hidden by `.aparte-avatar:empty`. This used
1403
+ * to claim it "falls back to the default initial rendered by `_render()`"; there
1404
+ * is no such initial, and believing there was is what made `_updateRole` write
1405
+ * one.
1151
1406
  */
1152
1407
  _renderAvatar() {
1153
1408
  const avatar = this.querySelector(".aparte-avatar");
@@ -1175,7 +1430,7 @@ class AparteChatBubble extends HTMLElement {
1175
1430
  }
1176
1431
  if (avatar) {
1177
1432
  avatar.setAttribute("data-role", this._role);
1178
- avatar.textContent = this._getAvatarInitial();
1433
+ if (avatar.textContent) avatar.textContent = this._getAvatarInitial();
1179
1434
  }
1180
1435
  if (nameEl) {
1181
1436
  nameEl.textContent = this._getDisplayName();
@@ -1186,7 +1441,9 @@ class AparteChatBubble extends HTMLElement {
1186
1441
  _updateName() {
1187
1442
  const avatar = this.querySelector(".aparte-avatar");
1188
1443
  const nameEl = this.querySelector(".aparte-name");
1189
- if (avatar && !this._cfg.getAvatarProvider()) avatar.textContent = this._getAvatarInitial();
1444
+ if (avatar && avatar.textContent && !this._cfg.getAvatarProvider()) {
1445
+ avatar.textContent = this._getAvatarInitial();
1446
+ }
1190
1447
  if (nameEl) nameEl.textContent = this._getDisplayName();
1191
1448
  }
1192
1449
  _updateContent() {
@@ -1242,7 +1499,7 @@ class AparteChatBubble extends HTMLElement {
1242
1499
  } else {
1243
1500
  warnMissingRenderer(segment.type);
1244
1501
  const fallback = document.createElement("div");
1245
- fallback.className = "segment segment-unknown";
1502
+ fallback.className = "segment aparte-segment-unknown";
1246
1503
  fallback.textContent = `[Unknown segment type: ${segment.type}]`;
1247
1504
  this._segmentsEl.appendChild(fallback);
1248
1505
  }
@@ -1262,7 +1519,7 @@ class AparteChatBubble extends HTMLElement {
1262
1519
  _reflectError() {
1263
1520
  const message = this.querySelector(".aparte-message");
1264
1521
  if (!message) return;
1265
- const hasError = this._segments.some((s2) => s2.type === "error");
1522
+ const hasError = this._segments.some((s) => s.type === "error");
1266
1523
  if (hasError) message.setAttribute("data-error", "");
1267
1524
  else message.removeAttribute("data-error");
1268
1525
  }
@@ -1271,7 +1528,7 @@ class AparteChatBubble extends HTMLElement {
1271
1528
  if (!timestampEl || !value) return;
1272
1529
  try {
1273
1530
  const date = new Date(isNaN(Number(value)) ? value : Number(value));
1274
- timestampEl.textContent = date.toLocaleTimeString(void 0, {
1531
+ timestampEl.textContent = date.toLocaleTimeString(this._cfg.getLocale().tag || void 0, {
1275
1532
  hour: "2-digit",
1276
1533
  minute: "2-digit"
1277
1534
  });
@@ -1297,18 +1554,18 @@ class AparteChatBubble extends HTMLElement {
1297
1554
  const customAttachment = this._cfg.getAttachmentRenderer?.();
1298
1555
  if (customAttachment) {
1299
1556
  this._attachmentsEl.replaceChildren();
1300
- for (const a2 of this._attachments) {
1301
- const el = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a2)));
1557
+ for (const a4 of this._attachments) {
1558
+ const el = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a4)));
1302
1559
  if (el) this._attachmentsEl.appendChild(el);
1303
1560
  }
1304
1561
  return;
1305
1562
  }
1306
- this._attachmentsEl.innerHTML = this._attachments.map((a2) => {
1307
- const name = escapeHtml(a2.name);
1308
- if (a2.type.startsWith("image/")) {
1309
- return `<div class="aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeHtml(a2.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1563
+ this._attachmentsEl.innerHTML = this._attachments.map((a4) => {
1564
+ const name = escapeHtml(a4.name);
1565
+ if (a4.type.startsWith("image/")) {
1566
+ return `<div class="aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeHtml(a4.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1310
1567
  }
1311
- return `<div class="aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._fileExt(a2.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
1568
+ return `<div class="aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._fileExt(a4.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
1312
1569
  }).join("");
1313
1570
  if (!this._cfg.getHostHandlers().attachmentPreview) return;
1314
1571
  this._attachmentsEl.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
@@ -1413,14 +1670,14 @@ class AparteChatBubble extends HTMLElement {
1413
1670
  const buttons = [];
1414
1671
  if (this._role === "user") {
1415
1672
  if (config.user) {
1416
- for (const a2 of config.user) buttons.push(this._actionButtonHtml(a2, icons, locale));
1673
+ for (const a4 of config.user) buttons.push(this._actionButtonHtml(a4, icons, locale));
1417
1674
  } else {
1418
1675
  if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
1419
1676
  if (config.edit) buttons.push(this._actionButtonHtml("edit", icons, locale));
1420
1677
  }
1421
1678
  } else if (this._role === "assistant") {
1422
1679
  if (config.assistant) {
1423
- for (const a2 of config.assistant) buttons.push(this._actionButtonHtml(a2, icons, locale));
1680
+ for (const a4 of config.assistant) buttons.push(this._actionButtonHtml(a4, icons, locale));
1424
1681
  } else {
1425
1682
  if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
1426
1683
  if (config.retry) buttons.push(this._actionButtonHtml("retry", icons, locale));
@@ -1455,16 +1712,16 @@ class AparteChatBubble extends HTMLElement {
1455
1712
  /** Append the registered custom action buttons for this bubble's role. */
1456
1713
  _appendCustomActions(icons) {
1457
1714
  if (!this._actionBarEl) return;
1458
- for (const a2 of this._cfg.getActions("bubble")) {
1459
- const roles = a2.bubble?.roles ?? ["user", "assistant"];
1715
+ for (const a4 of this._cfg.getActions("bubble")) {
1716
+ const roles = a4.bubble?.roles ?? ["user", "assistant"];
1460
1717
  if (!roles.includes(this._role)) continue;
1461
1718
  const btn = document.createElement("button");
1462
1719
  btn.className = "aparte-action-btn aparte-action-custom";
1463
- btn.dataset["action"] = `custom:${a2.id}`;
1464
- btn.setAttribute("aria-label", a2.label);
1465
- btn.setAttribute("title", a2.label);
1466
- const fromProvider = icons[a2.icon];
1467
- btn.innerHTML = a2.icon.startsWith("<") ? a2.icon : typeof fromProvider === "function" ? fromProvider() : a2.iconFallback ?? "";
1720
+ btn.dataset["action"] = `custom:${a4.id}`;
1721
+ btn.setAttribute("aria-label", a4.label);
1722
+ btn.setAttribute("title", a4.label);
1723
+ const fromProvider = icons[a4.icon];
1724
+ btn.innerHTML = a4.icon.startsWith("<") ? a4.icon : typeof fromProvider === "function" ? fromProvider() : a4.iconFallback ?? "";
1468
1725
  this._actionBarEl.appendChild(btn);
1469
1726
  }
1470
1727
  }
@@ -1530,12 +1787,12 @@ class AparteChatBubble extends HTMLElement {
1530
1787
  composed: true,
1531
1788
  detail
1532
1789
  }));
1533
- this._cfg.getActions("bubble").find((x2) => x2.id === actionId)?.onClick?.(e2);
1790
+ this._cfg.getActions("bubble").find((x) => x.id === actionId)?.onClick?.(e2);
1534
1791
  return;
1535
1792
  }
1536
1793
  switch (action) {
1537
1794
  case "copy": {
1538
- const text = this._content || this._segments.map((s2) => s2.content ?? "").join("\n");
1795
+ const text = this._content || this._segments.map((s) => s.content ?? "").join("\n");
1539
1796
  const icons = this._cfg.getIconProvider();
1540
1797
  const locale = this._cfg.getLocale();
1541
1798
  navigator.clipboard.writeText(text).then(() => {
@@ -1973,7 +2230,7 @@ class AparteChatViewport extends HTMLElement {
1973
2230
  if (!message.segments) {
1974
2231
  message.segments = [];
1975
2232
  }
1976
- const index = message.segments.findIndex((s2) => s2.id === segmentId);
2233
+ const index = message.segments.findIndex((s) => s.id === segmentId);
1977
2234
  const segment = index === -1 ? void 0 : message.segments[index];
1978
2235
  if (segment && "content" in segment) {
1979
2236
  message.segments[index] = {
@@ -2023,38 +2280,42 @@ class AparteChatViewport extends HTMLElement {
2023
2280
  if (!message.segments) {
2024
2281
  message.segments = [];
2025
2282
  }
2026
- const stamped = stampSegmentOnInsert(message.segments, segment, messageId);
2283
+ const stamped = stampSegmentOnInsert(
2284
+ message.segments,
2285
+ segment,
2286
+ messageId,
2287
+ // THIS chat's defaults, not the page's: two chats on one page can be
2288
+ // configured differently, and the config seam is per instance.
2289
+ resolveConfig(this).getSegmentDefaults(segment.type)
2290
+ );
2027
2291
  message.segments.push(stamped);
2028
2292
  this._notifyBubble(messageId, "addSegment", stamped);
2029
2293
  this._autoScroll();
2030
2294
  }
2031
- updateSegment(a2, b2, c2) {
2295
+ updateSegment(a4, b2, c2) {
2032
2296
  const clientForm = c2 === void 0 && typeof b2 === "object";
2033
- const messageId = clientForm ? this._activeMessageId() : a2;
2034
- const segmentId = clientForm ? a2 : b2;
2297
+ const messageId = clientForm ? this._activeMessageId() : a4;
2298
+ const segmentId = clientForm ? a4 : b2;
2035
2299
  const updates = clientForm ? b2 : c2;
2036
2300
  if (!messageId) return;
2037
2301
  const message = this._repo.getMessageById(messageId);
2038
2302
  if (!message?.segments) return;
2039
- const segmentIndex = message.segments.findIndex((s2) => s2.id === segmentId);
2303
+ const segmentIndex = message.segments.findIndex((s) => s.id === segmentId);
2040
2304
  if (segmentIndex !== -1) {
2041
2305
  const current = message.segments[segmentIndex];
2042
2306
  const stamped = stampSegmentOnUpdate(current, updates);
2043
- message.segments[segmentIndex] = {
2044
- ...current,
2045
- ...stamped
2046
- };
2307
+ message.segments[segmentIndex] = mergeSegmentUpdate(current, stamped);
2047
2308
  this._notifyBubble(messageId, "updateSegment", { segmentId, updates: stamped });
2048
2309
  }
2049
2310
  }
2050
- removeSegment(a2, b2) {
2311
+ removeSegment(a4, b2) {
2051
2312
  const clientForm = b2 === void 0;
2052
- const messageId = clientForm ? this._activeMessageId() : a2;
2053
- const segmentId = clientForm ? a2 : b2;
2313
+ const messageId = clientForm ? this._activeMessageId() : a4;
2314
+ const segmentId = clientForm ? a4 : b2;
2054
2315
  if (!messageId || !segmentId) return;
2055
2316
  const message = this._repo.getMessageById(messageId);
2056
2317
  if (message?.segments) {
2057
- const idx = message.segments.findIndex((s2) => s2.id === segmentId);
2318
+ const idx = message.segments.findIndex((s) => s.id === segmentId);
2058
2319
  if (idx !== -1) {
2059
2320
  message.segments.splice(idx, 1);
2060
2321
  renumberSegments(message.segments);
@@ -2143,7 +2404,7 @@ class AparteChatViewport extends HTMLElement {
2143
2404
  * which both records the message and creates its bubble element.
2144
2405
  */
2145
2406
  addMessage(message) {
2146
- this._repo.addOrUpdateMessage(this._repo.headId, { ...message });
2407
+ this._repo.addOrUpdateMessage(this._repo.headId, adoptMessageSegments({ ...message }));
2147
2408
  this._pruneRenderedBubbles();
2148
2409
  this._autoScroll();
2149
2410
  }
@@ -2154,8 +2415,20 @@ class AparteChatViewport extends HTMLElement {
2154
2415
  * When `_frameworkManagedDOM` is true, only the internal repo is updated —
2155
2416
  * the framework owns the DOM and will create the bubble element itself.
2156
2417
  */
2157
- appendMessage(message) {
2158
- this._repo.addOrUpdateMessage(this._repo.headId, { ...message });
2418
+ appendMessage(message, options) {
2419
+ const stored = options?.historical ? adoptMessageSegments(message) : message.segments?.length ? { ...message, segments: message.segments.reduce(
2420
+ (acc, segment) => {
2421
+ acc.push(stampSegmentOnInsert(
2422
+ acc,
2423
+ segment,
2424
+ message.id,
2425
+ resolveConfig(this).getSegmentDefaults(segment.type)
2426
+ ));
2427
+ return acc;
2428
+ },
2429
+ []
2430
+ ) } : { ...message };
2431
+ this._repo.addOrUpdateMessage(this._repo.headId, stored);
2159
2432
  if (!this._frameworkManagedDOM) {
2160
2433
  const wrapper = this.querySelector(".aparte-messages-wrapper");
2161
2434
  if (wrapper) {
@@ -2172,7 +2445,7 @@ class AparteChatViewport extends HTMLElement {
2172
2445
  } else {
2173
2446
  wrapper.appendChild(bubble);
2174
2447
  }
2175
- populateBubbleFromMessage(bubble, message);
2448
+ populateBubbleFromMessage(bubble, stored);
2176
2449
  }
2177
2450
  }
2178
2451
  this._pruneRenderedBubbles();
@@ -2322,7 +2595,13 @@ class AparteChatViewport extends HTMLElement {
2322
2595
  */
2323
2596
  importTree(tree) {
2324
2597
  this.clearAll({ revokeAttachments: false });
2325
- this._repo.import(tree);
2598
+ this._repo.import({
2599
+ ...tree,
2600
+ messages: tree.messages.map((entry) => ({
2601
+ ...entry,
2602
+ message: adoptMessageSegments(entry.message)
2603
+ }))
2604
+ });
2326
2605
  this._reRenderActivePath();
2327
2606
  }
2328
2607
  /**
@@ -2370,7 +2649,7 @@ class AparteChatViewport extends HTMLElement {
2370
2649
  setMessages(messages) {
2371
2650
  this.clearAll({ revokeAttachments: false });
2372
2651
  for (const m2 of messages) {
2373
- this.appendMessage(m2);
2652
+ this.appendMessage(m2, { historical: true });
2374
2653
  }
2375
2654
  }
2376
2655
  /**
@@ -2868,6 +3147,20 @@ class AparteComposer extends HTMLElement {
2868
3147
  _panelMode = "submit";
2869
3148
  _panelSubmitEnabled = false;
2870
3149
  _panelOnSubmit = null;
3150
+ /**
3151
+ * Who owns the one panel slot, and how to tell them they lost it.
3152
+ *
3153
+ * There is exactly one slot, `showPanel` empties it unconditionally, and nothing
3154
+ * used to say whose it was. Three paths therefore closed a panel whose owner was
3155
+ * still awaiting an answer, and none of them told the owner: a second `showPanel`,
3156
+ * the owner-of-record's own `hidePanel`, and — the one that actually bit —
3157
+ * `_handleMessageDone`, which fires on EVERY turn end. A question still open when a
3158
+ * turn finished lost its panel while `<aparte-elicitation>` kept `_pending` set,
3159
+ * so the request never settled AND every later request was short-circuited for the
3160
+ * life of the page.
3161
+ */
3162
+ _panelToken = null;
3163
+ _panelOnEvict = null;
2871
3164
  // Internal bus events that represent an observable STATE change — these are
2872
3165
  // mirrored to the public `aparte-composer-change` DOM event. `submit`/`cancel`
2873
3166
  // are actions, not state, and are covered by `aparte-send`/`aparte-cancel`.
@@ -3035,9 +3328,14 @@ class AparteComposer extends HTMLElement {
3035
3328
  * used to set on a child: an attribute is themeable, is visible to a consumer's
3036
3329
  * own rules, and does not clobber a `display` the consumer had set (the restore
3037
3330
  * wrote `''`, not the previous value).
3331
+ *
3332
+ * Returns the TOKEN for this panel. Pass it back to `hidePanel` so a presenter
3333
+ * that has already lost the slot cannot close the panel that replaced it, and
3334
+ * supply `onEvict` to be told when that happens — an owner that is not told is an
3335
+ * owner whose promise nobody can settle.
3038
3336
  */
3039
3337
  showPanel(panel, options) {
3040
- this.hidePanel();
3338
+ this._evictPanel();
3041
3339
  const inputEl = this.querySelector("aparte-composer-input");
3042
3340
  this.setAttribute("data-panel-active", "");
3043
3341
  panel.dataset["apartePanel"] = "true";
@@ -3050,10 +3348,31 @@ class AparteComposer extends HTMLElement {
3050
3348
  this._panelSubmitEnabled = options?.submitEnabled ?? false;
3051
3349
  this._panelMode = options?.mode ?? "submit";
3052
3350
  this._panelOnSubmit = options?.onSubmit ?? null;
3351
+ this._panelOnEvict = options?.onEvict ?? null;
3352
+ const token = Symbol("aparte-composer-panel");
3353
+ this._panelToken = token;
3053
3354
  this._emit("panel-change", { active: true, submitEnabled: this._panelSubmitEnabled, mode: this._panelMode });
3355
+ return token;
3054
3356
  }
3055
- /** Remove the panel and restore the composer's own controls. */
3056
- hidePanel() {
3357
+ /**
3358
+ * Remove the panel and restore the composer's own controls.
3359
+ *
3360
+ * With a `token`, this closes the panel only if that token still owns the slot —
3361
+ * so a presenter settling late cannot tear down the panel that replaced it. With
3362
+ * no token it closes whatever is there, which is what a consumer driving the
3363
+ * composer directly means, and what `reset()` needs.
3364
+ */
3365
+ hidePanel(token) {
3366
+ if (token !== void 0 && token !== this._panelToken) return;
3367
+ this._teardownPanel();
3368
+ }
3369
+ /** Close the panel AND tell its owner, so a pending request never orphans. */
3370
+ _evictPanel() {
3371
+ const onEvict = this._panelOnEvict;
3372
+ this._teardownPanel();
3373
+ onEvict?.();
3374
+ }
3375
+ _teardownPanel() {
3057
3376
  const existing = this.querySelector("[data-aparte-panel]");
3058
3377
  if (existing) existing.remove();
3059
3378
  this.removeAttribute("data-panel-active");
@@ -3061,6 +3380,8 @@ class AparteComposer extends HTMLElement {
3061
3380
  this._panelSubmitEnabled = false;
3062
3381
  this._panelMode = "submit";
3063
3382
  this._panelOnSubmit = null;
3383
+ this._panelOnEvict = null;
3384
+ this._panelToken = null;
3064
3385
  this._emit("panel-change", { active: false, submitEnabled: false, mode: "submit" });
3065
3386
  this.focus();
3066
3387
  }
@@ -3138,7 +3459,7 @@ class AparteComposer extends HTMLElement {
3138
3459
  reset() {
3139
3460
  this.setValue("");
3140
3461
  this.clearAttachments();
3141
- if (this._panelActive) this.hidePanel();
3462
+ if (this._panelActive) this._evictPanel();
3142
3463
  }
3143
3464
  /** Focus the input primitive inside this composer. */
3144
3465
  focus() {
@@ -3207,7 +3528,7 @@ class AparteComposer extends HTMLElement {
3207
3528
  if (!this._isForThisComposer(e2)) return;
3208
3529
  this._streaming = false;
3209
3530
  this._emit("streaming-change", { streaming: false });
3210
- if (this._panelActive) this.hidePanel();
3531
+ if (this._panelActive) this._evictPanel();
3211
3532
  }
3212
3533
  }
3213
3534
  if (!customElements.get("aparte-composer")) {
@@ -3231,6 +3552,7 @@ class AparteComposerInput extends HTMLElement {
3231
3552
  this._render();
3232
3553
  this._connectToRoot();
3233
3554
  this._scheduleInitialReflow();
3555
+ this._unsubscribes.push(subscribeConfigChange(this, () => this._updatePlaceholder()));
3234
3556
  }
3235
3557
  disconnectedCallback() {
3236
3558
  this._editor?.removeEventListener("input", this._onInput);
@@ -3368,10 +3690,10 @@ class AparteComposerInput extends HTMLElement {
3368
3690
  }
3369
3691
  }
3370
3692
  _handleFocus() {
3371
- this.classList.add("is-focused");
3693
+ this.classList.add("aparte-is-focused");
3372
3694
  }
3373
3695
  _handleBlur() {
3374
- this.classList.remove("is-focused");
3696
+ this.classList.remove("aparte-is-focused");
3375
3697
  }
3376
3698
  _handlePaste(e2) {
3377
3699
  e2.preventDefault();
@@ -3414,9 +3736,9 @@ class AparteComposerInput extends HTMLElement {
3414
3736
  }
3415
3737
  _updatePlaceholder() {
3416
3738
  if (this._editor) {
3417
- const p2 = this._getPlaceholder();
3418
- this._editor.setAttribute("data-placeholder", p2);
3419
- this._editor.setAttribute("aria-label", p2);
3739
+ const p = this._getPlaceholder();
3740
+ this._editor.setAttribute("data-placeholder", p);
3741
+ this._editor.setAttribute("aria-label", p);
3420
3742
  }
3421
3743
  }
3422
3744
  _updatePlaceholderVisibility() {
@@ -3434,6 +3756,16 @@ if (!customElements.get("aparte-composer-input")) {
3434
3756
  class AparteComposerSend extends HTMLElement {
3435
3757
  _button = null;
3436
3758
  _unsubscribes = [];
3759
+ /**
3760
+ * The last `panel-change` payload.
3761
+ *
3762
+ * This button has four meanings — send, stop, submit an answer, advance to the
3763
+ * next question — and three of them are decided by state it does not own: the
3764
+ * root's `streaming`, and this payload. It was read straight out of the event's
3765
+ * arguments and thrown away, so nothing could recompute the button's chrome
3766
+ * afterwards; a config change had no way to know which of the four to write.
3767
+ */
3768
+ _panel = null;
3437
3769
  // Bound handler
3438
3770
  _onClick = this._handleClick.bind(this);
3439
3771
  connectedCallback() {
@@ -3483,27 +3815,56 @@ class AparteComposerSend extends HTMLElement {
3483
3815
  root._on("attachments-change", () => this._syncState())
3484
3816
  );
3485
3817
  this._unsubscribes.push(
3486
- root._on("panel-change", ({ active, submitEnabled, mode }) => {
3487
- if (!this._button) return;
3488
- if (active) {
3489
- this._button.disabled = !submitEnabled;
3490
- const cfg = resolveConfig(this);
3491
- const advancing = mode === "advance";
3492
- this._button.innerHTML = advancing ? cfg.getIcon("nextBranch") : this._getSubmitIcon();
3493
- const label = advancing ? cfg.t("elicitationNext") || "Next" : cfg.t("submitButton") || "Submit";
3494
- this._button.setAttribute("aria-label", label);
3495
- this._button.setAttribute("title", label);
3496
- this._button.classList.remove("is-streaming");
3497
- } else {
3498
- const root2 = this._getRoot();
3499
- if (root2?.streaming) {
3500
- this._syncStreamingState(true);
3501
- } else {
3502
- this._syncState();
3503
- }
3504
- }
3818
+ root._on("panel-change", (payload) => {
3819
+ this._panel = payload;
3820
+ this._refreshChrome();
3505
3821
  })
3506
3822
  );
3823
+ this._unsubscribes.push(subscribeConfigChange(this, () => this._refreshChrome()));
3824
+ }
3825
+ /**
3826
+ * Write the chrome for the mode the button is IN, deciding before writing.
3827
+ *
3828
+ * Never `_render()`: it returns early once the button exists, and its own
3829
+ * disabled/icon computation consults neither `root.streaming` nor the panel — so
3830
+ * rebuilding mid-turn would put a paper plane back while a reply was still
3831
+ * streaming, and rebuilding with the question panel open would silently drop out
3832
+ * of answer mode. It would also take the focus off the one control in this
3833
+ * composer most likely to be holding it.
3834
+ */
3835
+ _refreshChrome() {
3836
+ if (!this._button) return;
3837
+ if (this._panel?.active) {
3838
+ this._syncPanelState();
3839
+ return;
3840
+ }
3841
+ if (this._getRoot()?.streaming) {
3842
+ this._syncStreamingState(true);
3843
+ return;
3844
+ }
3845
+ this._syncState();
3846
+ }
3847
+ /**
3848
+ * Panel open: this one button now means "answer", and WHICH answer depends on
3849
+ * where you are in the form.
3850
+ *
3851
+ * The icon has to move with the meaning: it drew a paper plane while the label
3852
+ * already said "Submit", so it read as "send a message" while it meant "answer
3853
+ * this question". And a check on a form with three questions left was just as
3854
+ * wrong — hence a chevron while there is more ahead. The visual is what a user
3855
+ * reads.
3856
+ */
3857
+ _syncPanelState() {
3858
+ const panel = this._panel;
3859
+ if (!this._button || !panel?.active) return;
3860
+ const cfg = resolveConfig(this);
3861
+ const advancing = panel.mode === "advance";
3862
+ this._button.disabled = !panel.submitEnabled;
3863
+ this._button.innerHTML = advancing ? cfg.getIcon("nextBranch") : this._getSubmitIcon();
3864
+ const label = advancing ? cfg.t("elicitationNext") || "Next" : cfg.t("submitButton") || "Submit";
3865
+ this._button.setAttribute("aria-label", label);
3866
+ this._button.setAttribute("title", label);
3867
+ this._button.classList.remove("aparte-is-streaming");
3507
3868
  }
3508
3869
  _handleClick(e2) {
3509
3870
  e2.preventDefault();
@@ -3519,16 +3880,17 @@ class AparteComposerSend extends HTMLElement {
3519
3880
  const label = resolveConfig(this).t("sendButton") || "Send";
3520
3881
  this._button.setAttribute("aria-label", label);
3521
3882
  this._button.setAttribute("title", label);
3522
- this._button.classList.remove("is-streaming");
3883
+ this._button.classList.remove("aparte-is-streaming");
3523
3884
  }
3524
3885
  _syncStreamingState(streaming) {
3525
3886
  if (!this._button) return;
3526
3887
  if (streaming) {
3527
3888
  this._button.disabled = false;
3528
3889
  this._button.innerHTML = this._getStopIcon();
3529
- this._button.setAttribute("aria-label", "Stop");
3530
- this._button.setAttribute("title", "Stop");
3531
- this._button.classList.add("is-streaming");
3890
+ const label = resolveConfig(this).t("stopButton") || "Stop";
3891
+ this._button.setAttribute("aria-label", label);
3892
+ this._button.setAttribute("title", label);
3893
+ this._button.classList.add("aparte-is-streaming");
3532
3894
  } else {
3533
3895
  this._syncState();
3534
3896
  }
@@ -3558,6 +3920,7 @@ class AparteComposerCancel extends HTMLElement {
3558
3920
  connectedCallback() {
3559
3921
  this._render();
3560
3922
  this._connectToRoot();
3923
+ this._unsubscribes.push(subscribeConfigChange(this, () => this._refreshChrome()));
3561
3924
  }
3562
3925
  disconnectedCallback() {
3563
3926
  this._button?.removeEventListener("click", this._onClick);
@@ -3595,6 +3958,20 @@ class AparteComposerCancel extends HTMLElement {
3595
3958
  e2.preventDefault();
3596
3959
  this._getRoot()?.cancel();
3597
3960
  }
3961
+ /**
3962
+ * Re-read the accessible name and the icon in place.
3963
+ *
3964
+ * `hidden` is NOT touched: `_render()` always renders this button hidden and only
3965
+ * the root's `streaming-change` listener ever un-hides it, so a rebuild would make
3966
+ * the stop button vanish in the middle of a turn.
3967
+ */
3968
+ _refreshChrome() {
3969
+ if (!this._button) return;
3970
+ const label = resolveConfig(this).t("stopButton") || "Stop";
3971
+ this._button.setAttribute("aria-label", label);
3972
+ this._button.setAttribute("title", label);
3973
+ this._button.innerHTML = this._getStopIcon();
3974
+ }
3598
3975
  _getStopIcon() {
3599
3976
  return resolveConfig(this).getIcon("stop");
3600
3977
  }
@@ -3700,6 +4077,7 @@ class AparteComposerAddAttachment extends HTMLElement {
3700
4077
  this._render();
3701
4078
  this._connectToRoot();
3702
4079
  this._setupDragDrop();
4080
+ this._unsubscribes.push(subscribeConfigChange(this, () => this._refreshChrome()));
3703
4081
  }
3704
4082
  disconnectedCallback() {
3705
4083
  this._button?.removeEventListener("click", this._onClick);
@@ -3716,6 +4094,24 @@ class AparteComposerAddAttachment extends HTMLElement {
3716
4094
  _getRoot() {
3717
4095
  return this.closest("aparte-composer");
3718
4096
  }
4097
+ /**
4098
+ * Re-read the label and the icon on the button that already exists.
4099
+ *
4100
+ * Deliberately not a re-render: `_render()` returns early once the button is
4101
+ * there, and its `disabled` computation ignores `root.streaming` while the
4102
+ * streaming listener sets `disabled` directly — so rebuilding would silently
4103
+ * re-enable the attach button mid-turn, and drop focus if the user were on it.
4104
+ * The native file input and the drag listeners live outside this element
4105
+ * (on `document.body` and on the composer root), so they are untouched either way.
4106
+ */
4107
+ _refreshChrome() {
4108
+ if (!this._button) return;
4109
+ const cfg = resolveConfig(this);
4110
+ const label = cfg.t("actionUpload") || "Attach file";
4111
+ this._button.setAttribute("aria-label", label);
4112
+ this._button.setAttribute("title", label);
4113
+ this._button.innerHTML = cfg.getIcon("paperclip") || this._defaultIcon();
4114
+ }
3719
4115
  _render() {
3720
4116
  if (this.querySelector(".aparte-caa-button")) return;
3721
4117
  const label = resolveConfig(this).t("actionUpload") || "Attach file";
@@ -3769,15 +4165,15 @@ class AparteComposerAddAttachment extends HTMLElement {
3769
4165
  const onDragOver = (e2) => {
3770
4166
  if (root.disabled) return;
3771
4167
  prevent(e2);
3772
- root.classList.add("is-dragover");
4168
+ root.classList.add("aparte-is-dragover");
3773
4169
  };
3774
4170
  const onDragLeave = (e2) => {
3775
4171
  prevent(e2);
3776
- root.classList.remove("is-dragover");
4172
+ root.classList.remove("aparte-is-dragover");
3777
4173
  };
3778
4174
  const onDrop = (e2) => {
3779
4175
  prevent(e2);
3780
- root.classList.remove("is-dragover");
4176
+ root.classList.remove("aparte-is-dragover");
3781
4177
  if (root.disabled) return;
3782
4178
  const files = e2.dataTransfer?.files;
3783
4179
  if (files?.length) this._getRoot()?.addAttachments(files);
@@ -3809,6 +4205,9 @@ class AparteComposerAction extends HTMLElement {
3809
4205
  connectedCallback() {
3810
4206
  this._render();
3811
4207
  this._connectToRoot();
4208
+ this._unsubscribes.push(subscribeConfigChange(this, () => {
4209
+ if (this._button) this._button.innerHTML = this._resolveIcon(this.getAttribute("icon") ?? "");
4210
+ }));
3812
4211
  }
3813
4212
  disconnectedCallback() {
3814
4213
  this._button?.removeEventListener("click", this._onClick);
@@ -4067,162 +4466,6 @@ class AparteConversationList extends HTMLElement {
4067
4466
  }
4068
4467
  }
4069
4468
  if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
4070
- class AparteElicitation extends HTMLElement {
4071
- _pending = null;
4072
- /**
4073
- * A turn ended — cancel the open question only if it was OUR turn.
4074
- *
4075
- * These two listeners sit on `window` and had no instance filter at all, so a
4076
- * Stop (or an error) in one chat cancelled the question a DIFFERENT chat was
4077
- * waiting on — and that chat's model was told the user had refused a question
4078
- * the user was still looking at. Same defect as the `compact()` handler that
4079
- * emptied both chats, which its four sibling handlers in `AparteClient` already
4080
- * guarded against.
4081
- *
4082
- * The leniency rule is the composer's, deliberately: an event with no
4083
- * `targetId` is for everyone (a single-chat app never sets one), and a chat we
4084
- * cannot identify accepts everything rather than becoming deaf. Only a
4085
- * MISMATCH is ignored.
4086
- */
4087
- _onTurnEnd = (e2) => {
4088
- const evtTargetId = e2.detail?.targetId;
4089
- const own = this._pendingTargetId();
4090
- if (evtTargetId && own && evtTargetId !== own) return;
4091
- this._cancelPending();
4092
- };
4093
- connectedCallback() {
4094
- this.style.display = "none";
4095
- resolveConfig(this).setElicitationPresenter(this._present);
4096
- window.addEventListener("aparte-message-aborted", this._onTurnEnd);
4097
- window.addEventListener("aparte-message-error", this._onTurnEnd);
4098
- }
4099
- /**
4100
- * The boundary above us appeared, changed, or went away — move the
4101
- * registration with it.
4102
- *
4103
- * `connectedCallback` alone is not enough and cannot be: registering is a
4104
- * WRITE, and under all four wrappers it happens before `attachConfig` runs, so
4105
- * it lands on the global singleton. `requestUserInput()` then resolves the
4106
- * instance config, finds nothing, and answers the model `cancel` — the model
4107
- * hears the user refuse a question the user never saw.
4108
- *
4109
- * See {@link AparteConfigAware}.
4110
- */
4111
- aparteConfigChanged(next, previous) {
4112
- if (previous.getElicitationPresenter() === this._present) previous.setElicitationPresenter(null);
4113
- next.setElicitationPresenter(this._present);
4114
- }
4115
- disconnectedCallback() {
4116
- const cfg = resolveConfig(this);
4117
- if (cfg.getElicitationPresenter() === this._present) cfg.setElicitationPresenter(null);
4118
- window.removeEventListener("aparte-message-aborted", this._onTurnEnd);
4119
- window.removeEventListener("aparte-message-error", this._onTurnEnd);
4120
- this._cancelPending();
4121
- }
4122
- _present = (request) => {
4123
- if (this._pending) return Promise.resolve({ action: "cancel" });
4124
- const composer = this._getComposer();
4125
- if (!composer) return Promise.resolve({ action: "cancel" });
4126
- return new Promise((resolve) => {
4127
- let done = false;
4128
- const settle = (result) => {
4129
- if (done) return;
4130
- done = true;
4131
- this._pending = null;
4132
- composer.hidePanel();
4133
- resolve(result);
4134
- };
4135
- if (request.signal) {
4136
- if (request.signal.aborted) {
4137
- settle({ action: "cancel" });
4138
- return;
4139
- }
4140
- request.signal.addEventListener("abort", () => settle({ action: "cancel" }), { once: true });
4141
- }
4142
- const cfg = resolveConfig(this);
4143
- const panel = runWithConfig(cfg, () => buildElicitationPanel(request.message, request.schema, () => {
4144
- composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
4145
- }));
4146
- const skip = document.createElement("button");
4147
- skip.type = "button";
4148
- skip.className = "aparte-elic-skip";
4149
- skip.textContent = cfg.t("elicitationSkip");
4150
- skip.addEventListener("click", () => settle({ action: "decline" }));
4151
- panel.dismiss.appendChild(skip);
4152
- this._pending = { settle, composer };
4153
- composer.showPanel(panel.el, {
4154
- submitEnabled: panel.canProceed(),
4155
- mode: panel.mode(),
4156
- onSubmit: () => {
4157
- if (panel.mode() === "advance") {
4158
- panel.proceed();
4159
- composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
4160
- return;
4161
- }
4162
- if (panel.isComplete()) settle({ action: "accept", content: panel.getContent() });
4163
- }
4164
- });
4165
- panel.focus();
4166
- });
4167
- };
4168
- _cancelPending() {
4169
- this._pending?.settle({ action: "cancel" });
4170
- }
4171
- /**
4172
- * The host id of the chat whose composer holds the open panel.
4173
- *
4174
- * Walks up from the composer rather than from `this`, because the panel lives in
4175
- * the composer and that is what the turn belongs to. Matches the hosts
4176
- * `aparte-chat-bubble._resolveTargetId()` matches, for the reason written there:
4177
- * Angular's wrapper root IS the `<aparte-chat>` element, while the plain-root
4178
- * wrappers render a `[data-aparte-chat]` div instead, so matching only the tag
4179
- * resolves `undefined` on three wrappers out of four.
4180
- */
4181
- _pendingTargetId() {
4182
- let el = this._pending?.composer ?? null;
4183
- while (el) {
4184
- const tag = el.tagName?.toLowerCase();
4185
- const isHost = tag === "aparte-chat" || tag === "aparte-chat-component" || el.hasAttribute?.("data-aparte-chat");
4186
- if (isHost && el.id) return el.id;
4187
- el = el.parentElement;
4188
- }
4189
- return void 0;
4190
- }
4191
- /**
4192
- * The composer to present in: the nearest one in an ancestor subtree, and
4193
- * nothing else.
4194
- *
4195
- * There used to be a `document.querySelector('aparte-composer')` fallback, which
4196
- * is the "first chat on the page" bug this repo has now fixed in four other
4197
- * places: on a page with two chats, an elicitation that could not find its own
4198
- * composer mounted its panel in the OTHER chat's — so one conversation's question
4199
- * appeared under the other conversation, and answering it resolved a tool call
4200
- * belonging to a chat the user was not looking at.
4201
- *
4202
- * Returning `null` instead resolves `cancel`, which is honest: nothing was
4203
- * shown, so nothing was answered. The warning names the fix, because this is a
4204
- * setup mistake and only the developer can correct it — the guide's own example
4205
- * puts `<aparte-elicitation>` inside `<aparte-chat>`.
4206
- */
4207
- _getComposer() {
4208
- let node = this.parentElement;
4209
- while (node) {
4210
- const composer = node.querySelector("aparte-composer");
4211
- if (composer && typeof composer.showPanel === "function") return composer;
4212
- const tag = node.tagName?.toLowerCase();
4213
- const isChatBoundary = tag === "aparte-chat" || tag === "aparte-chat-component" || node.hasAttribute?.("data-aparte-chat");
4214
- if (isChatBoundary) break;
4215
- node = node.parentElement;
4216
- }
4217
- console.warn(
4218
- "[aparte-elicitation] No <aparte-composer> in this element's subtree, so the request was cancelled — the model will read that as a refusal. Move <aparte-elicitation> inside the <aparte-chat> it belongs to. It is deliberately NOT borrowing another chat's composer: on a page with two chats that put the question under the wrong one."
4219
- );
4220
- return null;
4221
- }
4222
- }
4223
- if (typeof customElements !== "undefined" && !customElements.get("aparte-elicitation")) {
4224
- customElements.define("aparte-elicitation", AparteElicitation);
4225
- }
4226
4469
  function registerAllComponents() {
4227
4470
  const _chat = customElements.get("aparte-chat");
4228
4471
  const _viewport = customElements.get("aparte-chat-viewport");
@@ -4233,21 +4476,22 @@ function registerAllComponents() {
4233
4476
  }
4234
4477
  }
4235
4478
  export {
4236
- A as APARTE_CONVERSATION_SCHEMA_VERSION,
4237
- a as APARTE_DEFAULT_BUBBLE_ACTIONS,
4238
- b as APARTE_DEFAULT_HOST_HANDLERS,
4239
- c as APARTE_DEFAULT_ICON_FALLBACKS,
4240
- d as APARTE_DEFAULT_LOCALE,
4241
- e as APARTE_DEFAULT_SKELETON_FALLBACKS,
4242
- f as APARTE_DEFAULT_UI_EVENTS,
4243
- g as APARTE_HOST_ATTR,
4244
- h as AparteBackendTransport,
4479
+ A as APARTE_CONFIG_CHANGE,
4480
+ a as APARTE_CONVERSATION_SCHEMA_VERSION,
4481
+ b as APARTE_DEFAULT_BUBBLE_ACTIONS,
4482
+ c as APARTE_DEFAULT_HOST_HANDLERS,
4483
+ d as APARTE_DEFAULT_ICON_FALLBACKS,
4484
+ e as APARTE_DEFAULT_LOCALE,
4485
+ f as APARTE_DEFAULT_SKELETON_FALLBACKS,
4486
+ g as APARTE_DEFAULT_UI_EVENTS,
4487
+ h as APARTE_HOST_ATTR,
4488
+ i as AparteBackendTransport,
4245
4489
  AparteChat,
4246
4490
  AparteChatBubble,
4247
- i as AparteChatHost,
4491
+ j as AparteChatHost,
4248
4492
  AparteChatStatus,
4249
4493
  AparteChatViewport,
4250
- j as AparteClient,
4494
+ k as AparteClient,
4251
4495
  AparteComposer,
4252
4496
  AparteComposerAction,
4253
4497
  AparteComposerAddAttachment,
@@ -4256,57 +4500,61 @@ export {
4256
4500
  AparteComposerInput,
4257
4501
  AparteComposerSend,
4258
4502
  AparteComposerToolbar,
4259
- k as AparteConfig,
4260
- l as AparteConversationController,
4503
+ l as AparteConfig,
4504
+ m as AparteConversationController,
4261
4505
  AparteConversationList,
4262
- m as AparteConversationManager,
4263
- n as AparteDirectTransport,
4506
+ n as AparteConversationManager,
4507
+ o as AparteDirectTransport,
4264
4508
  AparteElicitation,
4265
- o as AparteError,
4266
- p as AparteErrorCode,
4509
+ AparteElicitationAbortError,
4510
+ q as AparteError,
4511
+ r as AparteErrorCode,
4267
4512
  AparteMessageRepository,
4268
4513
  AparteOptgroup,
4269
4514
  AparteOption,
4270
4515
  AparteProgressSpinner,
4271
4516
  AparteSelect,
4272
- r as AparteStreamParser,
4273
- s as aparteGlobalConfig,
4274
- t as applyElementProps,
4275
- u as attachConfig,
4517
+ t as AparteStreamParser,
4518
+ u as aparteGlobalConfig,
4519
+ v as applyElementProps,
4520
+ w as attachConfig,
4521
+ buildApprovalPanel,
4276
4522
  buildElicitationPanel,
4277
- w as collectRendererStyles,
4278
- x as contentToText,
4279
- y as contextConfig,
4280
- z as createAparteChatHandler,
4281
- B as createStreamAdapter,
4523
+ z as collectRendererStyles,
4524
+ B as contentToText,
4525
+ C as contextConfig,
4526
+ D as createAparteChatHandler,
4527
+ E as createStreamAdapter,
4282
4528
  cssEscape,
4283
- D as declineDefaultRenderers,
4284
- E as defaultSanitizer,
4285
- F as deriveArtifactKind,
4286
- G as detachConfig,
4529
+ G as declineDefaultRenderers,
4530
+ H as defaultSanitizer,
4531
+ I as deriveArtifactKind,
4532
+ J as detachConfig,
4287
4533
  escapeAttr,
4288
4534
  escapeHtml,
4289
- J as filesToAttachments,
4290
- K as getAllRenderers,
4535
+ M as filesToAttachments,
4536
+ N as getAllRenderers,
4291
4537
  getSegmentRenderer,
4292
4538
  installDefaultRenderersOnce,
4293
4539
  isAwaitingReply,
4294
- O as isFormatAdapter,
4295
- P as isSafeUrl,
4296
- Q as isSegmentSettled,
4297
- R as parseAparteEventStream,
4298
- S as parseMarkdownToSegments,
4540
+ R as isFormatAdapter,
4541
+ S as isSafeUrl,
4542
+ T as isSegmentSettled,
4543
+ U as parseAparteEventStream,
4544
+ V as parseMarkdownToSegments,
4299
4545
  populateBubbleFromMessage,
4300
- U as readableToAsyncIterable,
4546
+ X as readableToAsyncIterable,
4301
4547
  registerAllComponents,
4302
- V as registerDefaultRenderers,
4303
- W as registerSegmentRenderer,
4304
- X as requestUserInput,
4548
+ Y as registerDefaultRenderers,
4549
+ Z as registerSegmentRenderer,
4550
+ _ as requestUserInput,
4305
4551
  resolveConfig,
4306
4552
  revokeAttachmentUrls,
4307
4553
  runWithConfig,
4308
- $ as segmentDuration,
4309
- a0 as unregisterSegmentRenderer,
4554
+ a2 as segmentDuration,
4555
+ a3 as segmentTiming,
4556
+ subscribeConfigChange,
4557
+ a5 as unregisterSegmentRenderer,
4310
4558
  uuid
4311
4559
  };
4312
4560
  //# sourceMappingURL=index.js.map