@aparte/core 0.3.0-alpha.0 → 0.5.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +26 -2
  2. package/dist/client/aparte-client.d.ts +7 -1
  3. package/dist/client/aparte-client.d.ts.map +1 -1
  4. package/dist/components/bubble/aparte-chat-bubble.d.ts +26 -2
  5. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  6. package/dist/components/chat/aparte-chat.d.ts +13 -0
  7. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  8. package/dist/components/composer/aparte-composer-attachments.d.ts +3 -2
  9. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  10. package/dist/components/viewport/aparte-chat-viewport.d.ts +17 -4
  11. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  12. package/dist/config/aparte-config.d.ts +64 -7
  13. package/dist/config/aparte-config.d.ts.map +1 -1
  14. package/dist/config/icon-provider.d.ts +21 -21
  15. package/dist/config/icon-provider.d.ts.map +1 -1
  16. package/dist/config/index.d.ts +1 -1
  17. package/dist/config/index.d.ts.map +1 -1
  18. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  19. package/dist/custom-elements.json +3435 -3000
  20. package/dist/host/aparte-chat-host.d.ts +58 -1
  21. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  22. package/dist/{index-CW2BCAqn.js → index-Dn7TH14G.js} +282 -81
  23. package/dist/index-Dn7TH14G.js.map +1 -0
  24. package/dist/index.css +75 -8
  25. package/dist/index.d.ts +11 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +216 -62
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.node.d.ts +3 -1
  30. package/dist/index.node.d.ts.map +1 -1
  31. package/dist/index.node.js +34 -30
  32. package/dist/index.node.js.map +1 -1
  33. package/dist/primitives/select/aparte-optgroup.d.ts +2 -0
  34. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  35. package/dist/primitives/select/aparte-select.d.ts +2 -0
  36. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  37. package/dist/renderers/index.d.ts +1 -1
  38. package/dist/renderers/index.d.ts.map +1 -1
  39. package/dist/renderers/segment-renderers.d.ts +23 -0
  40. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  41. package/dist/types/imperative-api.d.ts +13 -0
  42. package/dist/types/imperative-api.d.ts.map +1 -1
  43. package/dist/types/models.d.ts +56 -9
  44. package/dist/types/models.d.ts.map +1 -1
  45. package/dist/utils/files-to-attachments.d.ts +27 -0
  46. package/dist/utils/files-to-attachments.d.ts.map +1 -0
  47. package/dist/utils/is-awaiting-reply.d.ts +22 -0
  48. package/dist/utils/is-awaiting-reply.d.ts.map +1 -0
  49. package/package.json +8 -1
  50. package/dist/index-CW2BCAqn.js.map +0 -1
@@ -939,6 +939,18 @@ const defaultSanitizer = (html) => {
939
939
  sanitizeChildren(doc.body, container, doc);
940
940
  return container.innerHTML;
941
941
  };
942
+ const DEFAULT_BUBBLE_ACTIONS = {
943
+ copy: true,
944
+ retry: false,
945
+ edit: false,
946
+ feedback: false,
947
+ info: false
948
+ };
949
+ const DEFAULT_HOST_HANDLERS = {
950
+ attachmentPreview: false,
951
+ terminalRun: false,
952
+ artifactRedownload: false
953
+ };
942
954
  class AparteConfigClass {
943
955
  _markdownProvider;
944
956
  _streamingMarkdownProvider;
@@ -978,8 +990,11 @@ class AparteConfigClass {
978
990
  // Tool Registry
979
991
  _tools = /* @__PURE__ */ new Map();
980
992
  _toolRenderers = /* @__PURE__ */ new Map();
981
- // Bubble Actions
982
- _bubbleActionsConfig = { copy: true, retry: true, edit: true, feedback: false };
993
+ // Host handlers — what the app declares it can actually complete.
994
+ _hostHandlers = { ...DEFAULT_HOST_HANDLERS };
995
+ // Bubble Actions — DEFAULT_BUBBLE_ACTIONS is the single source of truth
996
+ // (init here, restored by reset(), and the fallback in getBubbleActions()).
997
+ _bubbleActionsConfig = { ...DEFAULT_BUBBLE_ACTIONS };
983
998
  // ─────────────────────────────────────────────────────────────────────────
984
999
  // Provider Setters (Dependency Injection)
985
1000
  // ─────────────────────────────────────────────────────────────────────────
@@ -1021,12 +1036,13 @@ class AparteConfigClass {
1021
1036
  }
1022
1037
  /**
1023
1038
  * Configure which action buttons appear in message bubbles.
1024
- * Unset keys keep their defaults (copy=true, retry=true, edit=true, feedback=false).
1039
+ * Unset keys keep their defaults see {@link DEFAULT_BUBBLE_ACTIONS}: `copy`
1040
+ * only, because every other button needs a host to honor it.
1025
1041
  *
1026
1042
  * @example
1027
- * AparteConfig.setBubbleActions({ feedback: true }) // enable feedback, keep rest
1028
- * AparteConfig.setBubbleActions({ retry: false }) // disable retry only
1029
- * AparteConfig.setBubbleActions({ copy: false, retry: false, edit: false }) // hide all
1043
+ * AparteConfig.setBubbleActions({ retry: true, edit: true }) // you run AparteClient
1044
+ * AparteConfig.setBubbleActions({ feedback: true }) // you listen for aparte-feedback
1045
+ * AparteConfig.setBubbleActions({ copy: false }) // hide everything
1030
1046
  * // Explicit per-role ordered sets (replace the flag defaults for that role):
1031
1047
  * AparteConfig.setBubbleActions({ user: ['edit', 'copy'], assistant: ['copy', 'thumbUp', 'thumbDown', 'retry'] })
1032
1048
  */
@@ -1034,13 +1050,33 @@ class AparteConfigClass {
1034
1050
  this._bubbleActionsConfig = { ...this._bubbleActionsConfig, ...config };
1035
1051
  this._notify();
1036
1052
  }
1053
+ /**
1054
+ * Declare which host-dependent affordances your app handles. Core renders the
1055
+ * trigger only for the ones you claim — see {@link AparteHostHandlersConfig}.
1056
+ *
1057
+ * @example
1058
+ * AparteConfig.setHostHandlers({ attachmentPreview: true, terminalRun: true });
1059
+ */
1060
+ setHostHandlers(config) {
1061
+ this._hostHandlers = { ...this._hostHandlers, ...config };
1062
+ this._notify();
1063
+ }
1064
+ /** Returns the resolved host-handler declarations (undeclared = false). */
1065
+ getHostHandlers() {
1066
+ return {
1067
+ attachmentPreview: this._hostHandlers.attachmentPreview ?? DEFAULT_HOST_HANDLERS.attachmentPreview,
1068
+ terminalRun: this._hostHandlers.terminalRun ?? DEFAULT_HOST_HANDLERS.terminalRun,
1069
+ artifactRedownload: this._hostHandlers.artifactRedownload ?? DEFAULT_HOST_HANDLERS.artifactRedownload
1070
+ };
1071
+ }
1037
1072
  /** Returns the resolved bubble actions config (flag defaults applied; per-role sets passed through). */
1038
1073
  getBubbleActions() {
1039
1074
  return {
1040
- copy: this._bubbleActionsConfig.copy ?? true,
1041
- retry: this._bubbleActionsConfig.retry ?? true,
1042
- edit: this._bubbleActionsConfig.edit ?? true,
1043
- feedback: this._bubbleActionsConfig.feedback ?? false,
1075
+ copy: this._bubbleActionsConfig.copy ?? DEFAULT_BUBBLE_ACTIONS.copy,
1076
+ retry: this._bubbleActionsConfig.retry ?? DEFAULT_BUBBLE_ACTIONS.retry,
1077
+ edit: this._bubbleActionsConfig.edit ?? DEFAULT_BUBBLE_ACTIONS.edit,
1078
+ feedback: this._bubbleActionsConfig.feedback ?? DEFAULT_BUBBLE_ACTIONS.feedback,
1079
+ info: this._bubbleActionsConfig.info ?? DEFAULT_BUBBLE_ACTIONS.info,
1044
1080
  user: this._bubbleActionsConfig.user,
1045
1081
  assistant: this._bubbleActionsConfig.assistant
1046
1082
  };
@@ -1165,13 +1201,24 @@ class AparteConfigClass {
1165
1201
  return this._artifactPreviewBuilder;
1166
1202
  }
1167
1203
  /**
1168
- * Get the current icon provider, or a proxy that falls back to DEFAULT_ICON_FALLBACKS
1204
+ * The icon set as a **complete** provider: every name resolves, falling back
1205
+ * to `DEFAULT_ICON_FALLBACKS` for anything the registered provider doesn't
1206
+ * implement. Callers (bubble action bar, composer controls) can therefore
1207
+ * invoke `icons.copy()` unconditionally.
1208
+ *
1209
+ * It used to hand back the registered provider verbatim, so a provider that
1210
+ * implemented a subset — which `getIcon()` has always supported, and which
1211
+ * the type now states — crashed every other icon with
1212
+ * "icons.retry is not a function".
1169
1213
  */
1170
1214
  getIconProvider() {
1171
- if (this._iconProvider) return this._iconProvider;
1172
- return Object.fromEntries(
1173
- Object.entries(DEFAULT_ICON_FALLBACKS).map(([k, v]) => [k, () => v])
1174
- );
1215
+ const registered = this._iconProvider;
1216
+ const complete = {};
1217
+ for (const name of Object.keys(DEFAULT_ICON_FALLBACKS)) {
1218
+ const fn = registered?.[name];
1219
+ complete[name] = fn ?? (() => DEFAULT_ICON_FALLBACKS[name]);
1220
+ }
1221
+ return complete;
1175
1222
  }
1176
1223
  /**
1177
1224
  * Set a custom avatar renderer. Lets framework consumers (Angular,
@@ -1296,9 +1343,8 @@ class AparteConfigClass {
1296
1343
  * Falls back to textual representation if no provider is set
1297
1344
  */
1298
1345
  getIcon(name) {
1299
- if (this._iconProvider && this._iconProvider[name]) {
1300
- return this._iconProvider[name]();
1301
- }
1346
+ const icon = this._iconProvider?.[name];
1347
+ if (icon) return icon();
1302
1348
  return DEFAULT_ICON_FALLBACKS[name];
1303
1349
  }
1304
1350
  /**
@@ -1671,7 +1717,8 @@ class AparteConfigClass {
1671
1717
  this._modelConfig = {};
1672
1718
  this._requireModelSelection = false;
1673
1719
  this._modelPreferenceProvider = void 0;
1674
- this._bubbleActionsConfig = { copy: true, retry: true, edit: true, feedback: false };
1720
+ this._bubbleActionsConfig = { ...DEFAULT_BUBBLE_ACTIONS };
1721
+ this._hostHandlers = { ...DEFAULT_HOST_HANDLERS };
1675
1722
  this._notify();
1676
1723
  }
1677
1724
  // ─────────────────────────────────────────────────────────────────────────
@@ -1808,10 +1855,23 @@ function findClosingFence(s) {
1808
1855
  }
1809
1856
  const renderers = /* @__PURE__ */ new Map();
1810
1857
  let styleElement = null;
1858
+ let defaultsInstalled = false;
1859
+ let defaultsDeclined = false;
1811
1860
  function registerSegmentRenderer(renderer) {
1812
1861
  renderers.set(renderer.type, renderer);
1813
1862
  injectRendererStyles();
1814
1863
  }
1864
+ function declineDefaultRenderers() {
1865
+ defaultsDeclined = true;
1866
+ }
1867
+ function installDefaultRenderersOnce() {
1868
+ if (defaultsInstalled || defaultsDeclined) return;
1869
+ defaultsInstalled = true;
1870
+ for (const renderer of DEFAULT_RENDERERS) {
1871
+ if (!renderers.has(renderer.type)) renderers.set(renderer.type, renderer);
1872
+ }
1873
+ injectRendererStyles();
1874
+ }
1815
1875
  function unregisterSegmentRenderer(type) {
1816
1876
  renderers.delete(type);
1817
1877
  }
@@ -1943,7 +2003,7 @@ const terminalRenderer = {
1943
2003
  </div>
1944
2004
  <code class="terminal-command">${escapeHtml(segment.command || "")}</code>
1945
2005
  <div class="terminal-actions">
1946
- ${segment.isRunning ? `<span class="terminal-running"><span class="spinner"></span>${contextConfig().t("running")}</span>` : `<button class="terminal-run-btn" data-action="run" aria-label="${contextConfig().t("run")}" title="${contextConfig().t("run")}">${contextConfig().t("run")}</button>`}
2006
+ ${segment.isRunning ? `<span class="terminal-running"><span class="spinner"></span>${contextConfig().t("running")}</span>` : contextConfig().getHostHandlers().terminalRun ? `<button class="terminal-run-btn" data-action="run" aria-label="${contextConfig().t("run")}" title="${contextConfig().t("run")}">${contextConfig().t("run")}</button>` : ""}
1947
2007
  <button class="terminal-copy-btn" data-action="copy" aria-label="${contextConfig().t("copy")}" title="${contextConfig().t("copy")}">
1948
2008
  ${contextConfig().getIcon("copy")}
1949
2009
  </button>
@@ -2201,16 +2261,8 @@ const pipelineWaitingRenderer = {
2201
2261
  `
2202
2262
  };
2203
2263
  function registerDefaultRenderers() {
2204
- registerSegmentRenderer(textRenderer);
2205
- registerSegmentRenderer(thinkingRenderer);
2206
- registerSegmentRenderer(codeRenderer);
2207
- registerSegmentRenderer(terminalRenderer);
2208
- registerSegmentRenderer(errorRenderer);
2209
- registerSegmentRenderer(progressRenderer);
2210
- registerSegmentRenderer(fileTreeRenderer);
2211
- registerSegmentRenderer(toolCallRenderer);
2212
- registerSegmentRenderer(artifactRenderer);
2213
- registerSegmentRenderer(pipelineWaitingRenderer);
2264
+ defaultsInstalled = true;
2265
+ for (const renderer of DEFAULT_RENDERERS) registerSegmentRenderer(renderer);
2214
2266
  }
2215
2267
  const PREVIEWABLE_KINDS = /* @__PURE__ */ new Set(["react", "html", "svg", "js", "css"]);
2216
2268
  const BINARY_FILE_KINDS = /* @__PURE__ */ new Set(["pdf", "xlsx", "docx"]);
@@ -2695,6 +2747,7 @@ function renderBinaryFileArtifact(segment, kind) {
2695
2747
  const title = segment.title?.trim() || labelForKind(kind);
2696
2748
  const iconLabel = FILE_ICON_LABEL[kind] ?? kind.toUpperCase();
2697
2749
  const isStreaming = !!segment.isStreaming;
2750
+ const canRedownload = contextConfig().getHostHandlers().artifactRedownload;
2698
2751
  const cached = _binaryArtifactCache.get(segment.id);
2699
2752
  if (cached && !isStreaming) {
2700
2753
  const previewBody = cached.previewHtml ? contextConfig().sanitizeHtml(cached.previewHtml) : `<div class="aparte-art-file__preview-empty">Preview not available for ${escapeHtml(kind)} yet</div>`;
@@ -2710,7 +2763,7 @@ function renderBinaryFileArtifact(segment, kind) {
2710
2763
  <div class="aparte-art-file__meta-sub" data-role="file-sub">${escapeHtml(formatBytes(cached.bytes))} · ${escapeHtml(kind.toUpperCase())}</div>
2711
2764
  </div>
2712
2765
  <div class="aparte-art-file__actions">
2713
- <button type="button" class="aparte-art-file__btn aparte-art-file__btn--primary" data-action="download">Download</button>
2766
+ ${canRedownload ? '<button type="button" class="aparte-art-file__btn aparte-art-file__btn--primary" data-action="download">Download</button>' : ""}
2714
2767
  </div>
2715
2768
  </div>
2716
2769
  <div class="aparte-art-file__body">
@@ -2736,7 +2789,7 @@ function renderBinaryFileArtifact(segment, kind) {
2736
2789
  <div class="aparte-art-file__meta-sub" data-role="file-sub">${escapeHtml(subText)}</div>
2737
2790
  </div>
2738
2791
  <div class="aparte-art-file__actions">
2739
- <button type="button" class="aparte-art-file__btn aparte-art-file__btn--primary" data-action="download" disabled>Download</button>
2792
+ ${canRedownload ? '<button type="button" class="aparte-art-file__btn aparte-art-file__btn--primary" data-action="download" disabled>Download</button>' : ""}
2740
2793
  </div>
2741
2794
  </div>
2742
2795
  <div class="aparte-art-file__body">
@@ -3068,6 +3121,18 @@ function escapeClosingScriptTag(body) {
3068
3121
  }
3069
3122
  return out;
3070
3123
  }
3124
+ const DEFAULT_RENDERERS = [
3125
+ textRenderer,
3126
+ thinkingRenderer,
3127
+ codeRenderer,
3128
+ terminalRenderer,
3129
+ errorRenderer,
3130
+ progressRenderer,
3131
+ fileTreeRenderer,
3132
+ toolCallRenderer,
3133
+ artifactRenderer,
3134
+ pipelineWaitingRenderer
3135
+ ];
3071
3136
  function populateBubbleFromMessage(bubble, message, siblingInfo) {
3072
3137
  if (message.segments && message.segments.length > 0) {
3073
3138
  const existing = bubble.getSegments?.() ?? [];
@@ -3319,6 +3384,19 @@ class ConversationManager {
3319
3384
  return text.trim() || "New Chat";
3320
3385
  }
3321
3386
  }
3387
+ function attachmentId() {
3388
+ return typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
3389
+ }
3390
+ function filesToAttachments(files) {
3391
+ return files.map((file) => ({
3392
+ id: attachmentId(),
3393
+ name: file.name,
3394
+ type: file.type || "application/octet-stream",
3395
+ url: URL.createObjectURL(file),
3396
+ size: file.size,
3397
+ blob: file
3398
+ }));
3399
+ }
3322
3400
  class AparteConversationController {
3323
3401
  _binding;
3324
3402
  _options;
@@ -3363,16 +3441,7 @@ class AparteConversationController {
3363
3441
  if (targetId && targetId !== this._binding.hostId) return;
3364
3442
  const content = evt.detail?.content ?? "";
3365
3443
  const files = evt.detail?.files;
3366
- const attachments = files?.length ? files.map((f) => ({
3367
- id: typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
3368
- name: f.name,
3369
- type: f.type || "application/octet-stream",
3370
- url: URL.createObjectURL(f),
3371
- size: f.size,
3372
- // Carry the raw File so the storage adapter can persist it
3373
- // to its attachments table; reload reconstructs `url`.
3374
- blob: f
3375
- })) : void 0;
3444
+ const attachments = files?.length ? filesToAttachments(files) : void 0;
3376
3445
  const userMsg = {
3377
3446
  id: typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
3378
3447
  role: "user",
@@ -3622,6 +3691,23 @@ class AparteChatHost {
3622
3691
  _pendingSiblings;
3623
3692
  /** Abort handle for an in-flight {@link streamTokens} loop. */
3624
3693
  _streamAbort;
3694
+ /** Segment chunks written to the bubble, awaiting one coalesced state sync. */
3695
+ /**
3696
+ * Buffered segment text, per segment id, as `base + text` — an **absolute**
3697
+ * target, never a delta.
3698
+ *
3699
+ * Why absolute: the immediate paint (`appendToSegment` below) writes through the
3700
+ * viewport into the very segment object the framework list holds — they are the
3701
+ * same object, handed to both by `addSegment`. A delta added at flush time would
3702
+ * therefore land on content that already contains it, and every chunk came out
3703
+ * doubled. `base` is captured before the paint, so whoever mutates the shared
3704
+ * object in between cannot change the result.
3705
+ */
3706
+ _segmentBuffer = /* @__PURE__ */ new Map();
3707
+ /** {@link streamTokens} text written to the bubble, awaiting the same sync. */
3708
+ _tokenBuffer = null;
3709
+ /** Handle of the scheduled coalesced sync, or null when none is pending. */
3710
+ _flushHandle = null;
3625
3711
  _conversationId = null;
3626
3712
  _controller;
3627
3713
  _teardown = [];
@@ -3734,6 +3820,9 @@ class AparteChatHost {
3734
3820
  }
3735
3821
  /** Drop transient streaming state so a new conversation starts clean. */
3736
3822
  _beginConversationSwap() {
3823
+ this._segmentBuffer.clear();
3824
+ this._tokenBuffer = null;
3825
+ this._flushStreamState();
3737
3826
  this.stopTokenStream();
3738
3827
  this._setStreamingId(null);
3739
3828
  this.binding.onTypingChange?.(false);
@@ -3755,12 +3844,14 @@ class AparteChatHost {
3755
3844
  * is the append-specific signal instead.
3756
3845
  */
3757
3846
  appendMessage(message) {
3847
+ this._flushStreamState();
3758
3848
  if (message.role === "user") this._vp()?.setAutoScroll?.(true);
3759
3849
  this.binding.setMessages([...this.binding.getMessages(), message]);
3760
3850
  this.binding.onMessageAppended?.(message);
3761
3851
  }
3762
3852
  /** Atomic partial update of a message by id. */
3763
3853
  updateMessage(messageId, updates) {
3854
+ this._flushStreamState();
3764
3855
  const msgs = this.binding.getMessages();
3765
3856
  const index = msgs.findIndex((m) => m.id === messageId);
3766
3857
  if (index === -1) return;
@@ -3786,6 +3877,7 @@ class AparteChatHost {
3786
3877
  }
3787
3878
  /** Add a segment to the last message (immediate bubble + state sync). */
3788
3879
  addSegment(segment) {
3880
+ this._flushStreamState();
3789
3881
  const msgs = this.binding.getMessages();
3790
3882
  const last = msgs[msgs.length - 1];
3791
3883
  if (last && this._isOrphan(last.id)) return;
@@ -3799,6 +3891,7 @@ class AparteChatHost {
3799
3891
  }
3800
3892
  /** Update a segment in the last message in place. */
3801
3893
  updateSegment(segmentId, updates) {
3894
+ this._flushStreamState();
3802
3895
  const msgs = this.binding.getMessages();
3803
3896
  const last = msgs[msgs.length - 1];
3804
3897
  if (last && this._isOrphan(last.id)) return;
@@ -3813,6 +3906,7 @@ class AparteChatHost {
3813
3906
  }
3814
3907
  /** Remove a transient segment (e.g. pipeline-waiting indicator). */
3815
3908
  removeSegment(segmentId) {
3909
+ this._flushStreamState();
3816
3910
  this._lastBubble()?.removeSegment?.(segmentId);
3817
3911
  const msgs = this.binding.getMessages();
3818
3912
  const last = msgs[msgs.length - 1];
@@ -3823,18 +3917,92 @@ class AparteChatHost {
3823
3917
  this.binding.setMessages(next);
3824
3918
  this.binding.onMessagesChange?.(next);
3825
3919
  }
3826
- /** Append text to a segment's content in the last message. */
3920
+ /**
3921
+ * Append text to a segment's content in the last message — the streaming path
3922
+ * for thinking blocks, tool pills and any segment that grows token by token.
3923
+ *
3924
+ * Two writes, deliberately asymmetric:
3925
+ * 1. straight into the bubble, every chunk, so the text appears immediately —
3926
+ * the same direct path `appendToken` uses;
3927
+ * 2. into the framework's message list, **coalesced to once per frame**.
3928
+ *
3929
+ * It used to do only (2), synchronously: a full list rebuild plus
3930
+ * `setMessages` + `onMessagesChange` for every chunk, i.e. one complete
3931
+ * framework render per token. At 60 tok/s from a local model that is unusable,
3932
+ * and nothing in the imperative API hinted that `appendToSegment` cost so much
3933
+ * more than `injectTokenStream`. Consumers had to write their own rAF batcher.
3934
+ *
3935
+ * Any structural change flushes the buffer first (see
3936
+ * {@link _flushStreamState}), so ordering is never observable.
3937
+ */
3827
3938
  appendToSegment(segmentId, content) {
3828
3939
  const msgs = this.binding.getMessages();
3829
3940
  const last = msgs[msgs.length - 1];
3830
3941
  if (!last || !last.segments) return;
3831
3942
  if (this._isOrphan(last.id)) return;
3832
- const segments = last.segments.map(
3833
- (s) => s.id === segmentId && "content" in s ? { ...s, content: s.content + content } : s
3834
- );
3835
- const next = [...msgs.slice(0, -1), { ...last, segments }];
3836
- this.binding.setMessages(next);
3837
- this.binding.onMessagesChange?.(next);
3943
+ let buffered = this._segmentBuffer.get(segmentId);
3944
+ if (!buffered) {
3945
+ const segment = last.segments.find((s) => s.id === segmentId);
3946
+ const base = segment && "content" in segment ? segment.content ?? "" : "";
3947
+ buffered = { base, text: "" };
3948
+ this._segmentBuffer.set(segmentId, buffered);
3949
+ }
3950
+ buffered.text += content;
3951
+ this._vp()?.appendToSegment?.(last.id, segmentId, content);
3952
+ this._scheduleStreamFlush();
3953
+ }
3954
+ /** Schedule the coalesced segment-state sync (idempotent within a frame). */
3955
+ _scheduleStreamFlush() {
3956
+ if (this._flushHandle !== null) return;
3957
+ const run = () => {
3958
+ this._flushHandle = null;
3959
+ this._flushStreamState();
3960
+ };
3961
+ this._flushHandle = typeof requestAnimationFrame === "function" ? requestAnimationFrame(run) : setTimeout(run, 0);
3962
+ }
3963
+ /**
3964
+ * Write every buffered stream chunk — segment chunks and {@link streamTokens}
3965
+ * text alike — into the framework's message list in **one** update. Called by
3966
+ * the scheduled frame, and eagerly by any mutation that would otherwise observe
3967
+ * a stale list (a new segment, a new message, a conversation swap): flushing
3968
+ * first keeps the emitted order identical to the call order.
3969
+ */
3970
+ _flushStreamState() {
3971
+ if (this._flushHandle !== null) {
3972
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this._flushHandle);
3973
+ else clearTimeout(this._flushHandle);
3974
+ this._flushHandle = null;
3975
+ }
3976
+ const tokens = this._tokenBuffer;
3977
+ const pending = this._segmentBuffer.size > 0 ? new Map(this._segmentBuffer) : null;
3978
+ if (!tokens && !pending) return;
3979
+ this._tokenBuffer = null;
3980
+ this._segmentBuffer.clear();
3981
+ let msgs = this.binding.getMessages();
3982
+ let changed = false;
3983
+ if (tokens) {
3984
+ const index = msgs.findIndex((m) => m.id === tokens.messageId);
3985
+ if (index !== -1) {
3986
+ const next = [...msgs];
3987
+ next[index] = { ...next[index], content: (next[index].content ?? "") + tokens.text };
3988
+ msgs = next;
3989
+ changed = true;
3990
+ }
3991
+ }
3992
+ if (pending) {
3993
+ const last = msgs[msgs.length - 1];
3994
+ if (last?.segments) {
3995
+ const segments = last.segments.map((s) => {
3996
+ const buffered = pending.get(s.id);
3997
+ return buffered !== void 0 && "content" in s ? { ...s, content: buffered.base + buffered.text } : s;
3998
+ });
3999
+ msgs = [...msgs.slice(0, -1), { ...last, segments }];
4000
+ changed = true;
4001
+ }
4002
+ }
4003
+ if (!changed) return;
4004
+ this.binding.setMessages(msgs);
4005
+ this.binding.onMessagesChange?.(msgs);
3838
4006
  }
3839
4007
  /** Read the current message list. */
3840
4008
  getMessages() {
@@ -3880,6 +4048,13 @@ class AparteChatHost {
3880
4048
  * appends each token, completes the message at the end. Per-framework
3881
4049
  * wrappers adapt their native stream type (RxJS Observable, AsyncIterable,
3882
4050
  * ReadableStream) into this.
4051
+ *
4052
+ * Each token reaches the bubble immediately and the framework's message list
4053
+ * is synced **once per frame** (same discipline as {@link appendToSegment}),
4054
+ * with a guaranteed flush before completion and on abort. Before that sync
4055
+ * existed the two disagreed: the DOM had the reply, the framework list still
4056
+ * held `content: ''` — so any re-render from state wiped it, and a custom
4057
+ * bubble (which renders from that state) showed nothing at all.
3883
4058
  */
3884
4059
  async streamTokens(messageId, tokens) {
3885
4060
  this.stopTokenStream();
@@ -3899,10 +4074,13 @@ class AparteChatHost {
3899
4074
  const res = await Promise.race([iterator.next(), aborted]);
3900
4075
  if (res === "aborted" || ac.signal.aborted || res.done) break;
3901
4076
  this._vp()?.appendToken?.(messageId, res.value);
4077
+ this._bufferToken(messageId, res.value);
3902
4078
  }
4079
+ this._flushStreamState();
3903
4080
  if (!ac.signal.aborted) this._vp()?.completeMessage?.(messageId);
3904
4081
  this._setStreamingId(null);
3905
4082
  } catch (err) {
4083
+ this._flushStreamState();
3906
4084
  this._setStreamingId(null);
3907
4085
  throw err;
3908
4086
  } finally {
@@ -3915,11 +4093,22 @@ class AparteChatHost {
3915
4093
  if (this._streamAbort === ac) this._streamAbort = void 0;
3916
4094
  }
3917
4095
  }
4096
+ /**
4097
+ * Buffer a streamed token for the coalesced state sync. A different message id
4098
+ * (a second stream started without stopping the first) flushes the previous
4099
+ * one, so text never migrates between messages.
4100
+ */
4101
+ _bufferToken(messageId, chunk) {
4102
+ if (this._tokenBuffer && this._tokenBuffer.messageId !== messageId) this._flushStreamState();
4103
+ this._tokenBuffer = this._tokenBuffer ? { messageId, text: this._tokenBuffer.text + chunk } : { messageId, text: chunk };
4104
+ this._scheduleStreamFlush();
4105
+ }
3918
4106
  /** Abort an in-flight {@link streamTokens} loop. */
3919
4107
  stopTokenStream() {
3920
4108
  if (this._streamAbort) {
3921
4109
  this._streamAbort.abort();
3922
4110
  this._streamAbort = void 0;
4111
+ this._flushStreamState();
3923
4112
  this._setStreamingId(null);
3924
4113
  }
3925
4114
  }
@@ -4407,6 +4596,8 @@ class AparteClient {
4407
4596
  this._config = options.config ?? AparteConfig;
4408
4597
  if (this.options.autoRegister) {
4409
4598
  registerDefaultRenderers();
4599
+ } else {
4600
+ declineDefaultRenderers();
4410
4601
  }
4411
4602
  this._setupListeners();
4412
4603
  }
@@ -4864,8 +5055,9 @@ ${summary}`,
4864
5055
  const targetId = event.detail?.targetId;
4865
5056
  if (targetId) {
4866
5057
  const byId = document.getElementById(targetId);
4867
- if (byId && typeof byId.appendMessage === "function") {
4868
- targetElement = byId;
5058
+ const resolved = this._asRenderTarget(byId);
5059
+ if (resolved) {
5060
+ targetElement = resolved;
4869
5061
  } else {
4870
5062
  console.warn("[AparteClient] ⚠️ targetId present but element not found or missing appendMessage:", targetId);
4871
5063
  }
@@ -6093,6 +6285,10 @@ function applyElementProps(el2, props, transformValue = (value) => value) {
6093
6285
  }
6094
6286
  }
6095
6287
  }
6288
+ function isAwaitingReply(message) {
6289
+ if (message.status === "streaming" || message.status === "pending") return true;
6290
+ return message.status === void 0 && message.role === "assistant" && !message.content?.trim() && !message.segments?.length;
6291
+ }
6096
6292
  function el(tag, className, text) {
6097
6293
  const node = document.createElement(tag);
6098
6294
  if (className) node.className = className;
@@ -6278,19 +6474,24 @@ export {
6278
6474
  APARTE_CONVERSATION_SCHEMA_VERSION as A,
6279
6475
  BackendTransport as B,
6280
6476
  ConversationManager as C,
6281
- DEFAULT_ICON_FALLBACKS as D,
6282
- isSafeUrl as E,
6283
- parseAparteEventStream as F,
6284
- parseMarkdownToSegments as G,
6285
- populateBubbleFromMessage as H,
6286
- readableToAsyncIterable as I,
6287
- registerDefaultRenderers as J,
6288
- registerSegmentRenderer as K,
6289
- requestUserInput as L,
6477
+ DEFAULT_BUBBLE_ACTIONS as D,
6478
+ filesToAttachments as E,
6479
+ getSegmentRenderer as F,
6480
+ isAwaitingReply as G,
6481
+ isFormatAdapter as H,
6482
+ isSafeUrl as I,
6483
+ parseAparteEventStream as J,
6484
+ parseMarkdownToSegments as K,
6485
+ populateBubbleFromMessage as L,
6290
6486
  MessageRepository as M,
6291
- resolveConfig as N,
6292
- runWithConfig as O,
6293
- unregisterSegmentRenderer as P,
6487
+ readableToAsyncIterable as N,
6488
+ registerDefaultRenderers as O,
6489
+ registerSegmentRenderer as P,
6490
+ requestUserInput as Q,
6491
+ resolveConfig as R,
6492
+ runWithConfig as S,
6493
+ unregisterSegmentRenderer as T,
6494
+ installDefaultRenderersOnce as U,
6294
6495
  APARTE_HOST_ATTR as a,
6295
6496
  AparteChatHost as b,
6296
6497
  AparteClient as c,
@@ -6300,22 +6501,22 @@ export {
6300
6501
  AparteError as g,
6301
6502
  AparteErrorCode as h,
6302
6503
  AparteStreamParser as i,
6303
- DEFAULT_LOCALE as j,
6304
- DEFAULT_SKELETON_FALLBACKS as k,
6305
- DEFAULT_UI_EVENTS as l,
6306
- DirectTransport as m,
6307
- applyElementProps as n,
6308
- attachConfig as o,
6309
- buildElicitationPanel as p,
6310
- collectRendererStyles as q,
6311
- contentToText as r,
6312
- contextConfig as s,
6313
- createAparteChatHandler as t,
6314
- createStreamAdapter as u,
6315
- defaultSanitizer as v,
6316
- deriveArtifactKind as w,
6317
- detachConfig as x,
6318
- getSegmentRenderer as y,
6319
- isFormatAdapter as z
6504
+ DEFAULT_HOST_HANDLERS as j,
6505
+ DEFAULT_ICON_FALLBACKS as k,
6506
+ DEFAULT_LOCALE as l,
6507
+ DEFAULT_SKELETON_FALLBACKS as m,
6508
+ DEFAULT_UI_EVENTS as n,
6509
+ DirectTransport as o,
6510
+ applyElementProps as p,
6511
+ attachConfig as q,
6512
+ buildElicitationPanel as r,
6513
+ collectRendererStyles as s,
6514
+ contentToText as t,
6515
+ contextConfig as u,
6516
+ createAparteChatHandler as v,
6517
+ createStreamAdapter as w,
6518
+ defaultSanitizer as x,
6519
+ deriveArtifactKind as y,
6520
+ detachConfig as z
6320
6521
  };
6321
- //# sourceMappingURL=index-CW2BCAqn.js.map
6522
+ //# sourceMappingURL=index-Dn7TH14G.js.map