@aparte/core 0.7.1 → 0.8.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 (171) hide show
  1. package/README.md +20 -6
  2. package/dist/client/aparte-client.d.ts +113 -47
  3. package/dist/client/aparte-client.d.ts.map +1 -1
  4. package/dist/client/lifecycle-events.d.ts +55 -0
  5. package/dist/client/lifecycle-events.d.ts.map +1 -0
  6. package/dist/client/stream-adapter.d.ts +11 -5
  7. package/dist/client/stream-adapter.d.ts.map +1 -1
  8. package/dist/client/xml-artifact-feed.d.ts +76 -0
  9. package/dist/client/xml-artifact-feed.d.ts.map +1 -0
  10. package/dist/components/bubble/aparte-chat-bubble.d.ts +35 -8
  11. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  12. package/dist/components/chat/aparte-chat.d.ts +0 -6
  13. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  14. package/dist/components/composer/aparte-composer-action.d.ts +18 -3
  15. package/dist/components/composer/aparte-composer-action.d.ts.map +1 -1
  16. package/dist/components/composer/aparte-composer-add-attachment.d.ts.map +1 -1
  17. package/dist/components/composer/aparte-composer-attachments.d.ts +1 -1
  18. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  19. package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -1
  20. package/dist/components/composer/aparte-composer-input.d.ts +0 -2
  21. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
  22. package/dist/components/composer/aparte-composer-send.d.ts +5 -0
  23. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  24. package/dist/components/composer/aparte-composer-toolbar.d.ts +4 -0
  25. package/dist/components/composer/aparte-composer-toolbar.d.ts.map +1 -1
  26. package/dist/components/composer/aparte-composer.d.ts +64 -6
  27. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  28. package/dist/components/composer/index.d.ts +1 -1
  29. package/dist/components/composer/index.d.ts.map +1 -1
  30. package/dist/components/conversation-list/aparte-conversation-list.d.ts +2 -5
  31. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  32. package/dist/components/conversation-list/index.d.ts +1 -1
  33. package/dist/components/conversation-list/index.d.ts.map +1 -1
  34. package/dist/components/elicitation/aparte-elicitation.d.ts +63 -3
  35. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  36. package/dist/components/index.d.ts +2 -2
  37. package/dist/components/index.d.ts.map +1 -1
  38. package/dist/components/status/aparte-chat-status.d.ts +8 -6
  39. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  40. package/dist/components/viewport/aparte-chat-viewport.d.ts +35 -9
  41. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  42. package/dist/config/action-provider.d.ts +4 -4
  43. package/dist/config/aparte-config.d.ts +124 -41
  44. package/dist/config/aparte-config.d.ts.map +1 -1
  45. package/dist/config/attachment-renderer.d.ts +1 -1
  46. package/dist/config/avatar-provider.d.ts +1 -1
  47. package/dist/config/config-context.d.ts +66 -11
  48. package/dist/config/config-context.d.ts.map +1 -1
  49. package/dist/config/elicitation-field-renderer.d.ts +73 -0
  50. package/dist/config/elicitation-field-renderer.d.ts.map +1 -0
  51. package/dist/config/error-renderer.d.ts +1 -1
  52. package/dist/config/icon-provider.d.ts +2 -2
  53. package/dist/config/icon-provider.d.ts.map +1 -1
  54. package/dist/config/index.d.ts +7 -5
  55. package/dist/config/index.d.ts.map +1 -1
  56. package/dist/config/locale.d.ts +43 -2
  57. package/dist/config/locale.d.ts.map +1 -1
  58. package/dist/config/sanitize.d.ts +1 -7
  59. package/dist/config/sanitize.d.ts.map +1 -1
  60. package/dist/config/sibling-nav-renderer.d.ts +1 -1
  61. package/dist/config/skeleton-provider.d.ts +2 -2
  62. package/dist/config/skeleton-provider.d.ts.map +1 -1
  63. package/dist/config/status-renderer.d.ts +1 -1
  64. package/dist/conversations/conversation-controller.d.ts +19 -5
  65. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  66. package/dist/conversations/conversation-manager.d.ts +2 -2
  67. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  68. package/dist/conversations/index.d.ts +1 -1
  69. package/dist/conversations/index.d.ts.map +1 -1
  70. package/dist/conversations/types.d.ts +1 -1
  71. package/dist/custom-elements.json +6664 -4877
  72. package/dist/elicitation/index.d.ts +1 -1
  73. package/dist/elicitation/panel.d.ts +32 -0
  74. package/dist/elicitation/panel.d.ts.map +1 -1
  75. package/dist/elicitation/types.d.ts +29 -2
  76. package/dist/elicitation/types.d.ts.map +1 -1
  77. package/dist/host/aparte-chat-host.d.ts +3 -3
  78. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  79. package/dist/{index-Dn7TH14G.js → index-BieFbKeW.js} +1795 -1238
  80. package/dist/index-BieFbKeW.js.map +1 -0
  81. package/dist/index.css +299 -21
  82. package/dist/index.d.ts +21 -13
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +447 -200
  85. package/dist/index.js.map +1 -1
  86. package/dist/index.node.d.ts +22 -13
  87. package/dist/index.node.d.ts.map +1 -1
  88. package/dist/index.node.js +54 -46
  89. package/dist/index.node.js.map +1 -1
  90. package/dist/interop/element-props.d.ts +1 -1
  91. package/dist/interop/element-props.d.ts.map +1 -1
  92. package/dist/parsers/aparte-stream-parser.d.ts.map +1 -1
  93. package/dist/primitives/index.d.ts +1 -1
  94. package/dist/primitives/index.d.ts.map +1 -1
  95. package/dist/primitives/select/aparte-optgroup.d.ts +15 -4
  96. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  97. package/dist/primitives/select/aparte-option.d.ts +0 -5
  98. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  99. package/dist/primitives/select/aparte-select.d.ts +17 -5
  100. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  101. package/dist/primitives/select/index.d.ts +1 -1
  102. package/dist/primitives/select/index.d.ts.map +1 -1
  103. package/dist/renderers/markdown-stream.d.ts +42 -0
  104. package/dist/renderers/markdown-stream.d.ts.map +1 -0
  105. package/dist/renderers/segment-renderers.d.ts +20 -14
  106. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  107. package/dist/renderers/segments/artifact/binary-file.d.ts +45 -0
  108. package/dist/renderers/segments/artifact/binary-file.d.ts.map +1 -0
  109. package/dist/renderers/segments/artifact/card.d.ts +3 -0
  110. package/dist/renderers/segments/artifact/card.d.ts.map +1 -0
  111. package/dist/renderers/segments/artifact/preview-document.d.ts +27 -0
  112. package/dist/renderers/segments/artifact/preview-document.d.ts.map +1 -0
  113. package/dist/renderers/segments/artifact/shared.d.ts +12 -0
  114. package/dist/renderers/segments/artifact/shared.d.ts.map +1 -0
  115. package/dist/renderers/segments/code.d.ts +3 -0
  116. package/dist/renderers/segments/code.d.ts.map +1 -0
  117. package/dist/renderers/segments/error.d.ts +3 -0
  118. package/dist/renderers/segments/error.d.ts.map +1 -0
  119. package/dist/renderers/segments/file-tree.d.ts +3 -0
  120. package/dist/renderers/segments/file-tree.d.ts.map +1 -0
  121. package/dist/renderers/segments/pipeline-waiting.d.ts +3 -0
  122. package/dist/renderers/segments/pipeline-waiting.d.ts.map +1 -0
  123. package/dist/renderers/segments/progress.d.ts +3 -0
  124. package/dist/renderers/segments/progress.d.ts.map +1 -0
  125. package/dist/renderers/segments/terminal.d.ts +3 -0
  126. package/dist/renderers/segments/terminal.d.ts.map +1 -0
  127. package/dist/renderers/segments/text.d.ts +10 -0
  128. package/dist/renderers/segments/text.d.ts.map +1 -0
  129. package/dist/renderers/segments/thinking.d.ts +3 -0
  130. package/dist/renderers/segments/thinking.d.ts.map +1 -0
  131. package/dist/renderers/segments/tool-call.d.ts +3 -0
  132. package/dist/renderers/segments/tool-call.d.ts.map +1 -0
  133. package/dist/runtime/message-repository.d.ts +3 -3
  134. package/dist/runtime/message-repository.d.ts.map +1 -1
  135. package/dist/transport/backend-handler.d.ts +19 -7
  136. package/dist/transport/backend-handler.d.ts.map +1 -1
  137. package/dist/transport/backend-transport.d.ts +1 -1
  138. package/dist/transport/backend-transport.d.ts.map +1 -1
  139. package/dist/transport/direct-transport.d.ts +4 -4
  140. package/dist/transport/direct-transport.d.ts.map +1 -1
  141. package/dist/transport/index.d.ts +2 -2
  142. package/dist/transport/index.d.ts.map +1 -1
  143. package/dist/transport/types.d.ts +3 -3
  144. package/dist/types/element-map.d.ts +69 -0
  145. package/dist/types/element-map.d.ts.map +1 -0
  146. package/dist/types/event-map.d.ts +84 -23
  147. package/dist/types/event-map.d.ts.map +1 -1
  148. package/dist/types/events.d.ts +206 -64
  149. package/dist/types/events.d.ts.map +1 -1
  150. package/dist/types/index.d.ts +4 -5
  151. package/dist/types/index.d.ts.map +1 -1
  152. package/dist/types/model-provider.d.ts +100 -54
  153. package/dist/types/model-provider.d.ts.map +1 -1
  154. package/dist/types/models.d.ts +29 -7
  155. package/dist/types/models.d.ts.map +1 -1
  156. package/dist/types/segments.d.ts +1 -8
  157. package/dist/types/segments.d.ts.map +1 -1
  158. package/dist/types/theming.d.ts +25 -37
  159. package/dist/types/theming.d.ts.map +1 -1
  160. package/dist/types/tools.d.ts +60 -15
  161. package/dist/types/tools.d.ts.map +1 -1
  162. package/dist/utils/escape.d.ts +46 -0
  163. package/dist/utils/escape.d.ts.map +1 -0
  164. package/dist/utils/files-to-attachments.d.ts +18 -1
  165. package/dist/utils/files-to-attachments.d.ts.map +1 -1
  166. package/dist/utils/uuid.d.ts +26 -0
  167. package/dist/utils/uuid.d.ts.map +1 -0
  168. package/package.json +3 -5
  169. package/dist/index-Dn7TH14G.js.map +0 -1
  170. package/dist/types/providers.d.ts +0 -93
  171. package/dist/types/providers.d.ts.map +0 -1
@@ -35,7 +35,7 @@ class AparteError extends Error {
35
35
  return new AparteError(message, defaultCode, void 0, error, status);
36
36
  }
37
37
  }
38
- const DEFAULT_ICON_FALLBACKS = {
38
+ const APARTE_DEFAULT_ICON_FALLBACKS = {
39
39
  copy: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
40
40
  check: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
41
41
  send: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`,
@@ -58,13 +58,22 @@ const DEFAULT_ICON_FALLBACKS = {
58
58
  close: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
59
59
  stop: `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="2" y="2" width="12" height="12" rx="2"/></svg>`
60
60
  };
61
- const DEFAULT_LOCALE = {
61
+ const APARTE_DEFAULT_LOCALE = {
62
62
  inputPlaceholder: "Type a message...",
63
63
  sendButton: "Send",
64
64
  copy: "Copy",
65
65
  copied: "Copied!",
66
66
  retry: "Retry",
67
67
  thinking: "Thinking...",
68
+ elicitationOther: "Other…",
69
+ elicitationOtherPlaceholder: "Type your answer…",
70
+ elicitationOtherLabel: "Custom answer",
71
+ submitButton: "Submit",
72
+ elicitationNext: "Next",
73
+ elicitationSkip: "Skip",
74
+ elicitationYes: "Yes",
75
+ elicitationNo: "No",
76
+ elicitationAnswerLabel: "Your answer",
68
77
  typing: "Typing...",
69
78
  error: "Error",
70
79
  running: "Running...",
@@ -114,7 +123,7 @@ async function parseNonStreamText(provider, response, providerId) {
114
123
  }
115
124
  return provider.parseText(json);
116
125
  }
117
- class DirectTransport {
126
+ class AparteDirectTransport {
118
127
  byok;
119
128
  warnedBrowserKey = false;
120
129
  constructor(options = {}) {
@@ -161,10 +170,16 @@ class DirectTransport {
161
170
  if (this.warnedBrowserKey) return;
162
171
  this.warnedBrowserKey = true;
163
172
  console.warn(
164
- `[Aparte] DirectTransport is sending the "${providerId}" API key straight from the browser — it is visible to anyone who opens devtools. Fine for BYOK or local models: pass new DirectTransport({ byok: true }) to silence this. For a server-held key, use BackendTransport so the key never reaches the client.`
173
+ `[Aparte] AparteDirectTransport is sending the "${providerId}" API key straight from the browser — it is visible to anyone who opens devtools. Fine for BYOK or local models: pass new AparteDirectTransport({ byok: true }) to silence this. For a server-held key, use AparteBackendTransport so the key never reaches the client.`
165
174
  );
166
175
  }
167
176
  }
177
+ function uuid() {
178
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
179
+ return crypto.randomUUID();
180
+ }
181
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
182
+ }
168
183
  const DEFAULT_THINKING_DELIMITERS = [
169
184
  { start: "<think>", end: "</think>" },
170
185
  { start: "<thinking>", end: "</thinking>" }
@@ -301,10 +316,18 @@ class AparteStreamParser {
301
316
  }
302
317
  if (firstPattern.type === "code") {
303
318
  const res = this._startCodeBlock(buffer);
304
- return res ? { segment: segmentToEmit, remaining: res.remaining } : null;
319
+ if (!res) {
320
+ if (segmentToEmit) this._state.activeSegment = segmentToEmit;
321
+ return null;
322
+ }
323
+ return { segment: segmentToEmit, remaining: res.remaining };
305
324
  } else if (firstPattern.type === "thinking") {
306
325
  const res = this._startThinkingBlock(buffer);
307
- return res ? { segment: segmentToEmit, remaining: res.remaining } : null;
326
+ if (!res) {
327
+ if (segmentToEmit) this._state.activeSegment = segmentToEmit;
328
+ return null;
329
+ }
330
+ return { segment: segmentToEmit, remaining: res.remaining };
308
331
  } else {
309
332
  const res = this._startArtifactBlock(buffer);
310
333
  if (!res) {
@@ -420,9 +443,10 @@ class AparteStreamParser {
420
443
  if (tagEnd === -1) return null;
421
444
  const tag = buffer.slice(0, tagEnd + 1);
422
445
  const inner = tag.slice("<artifact".length, -1);
446
+ const mimeMatch = inner.match(/\bmimeType\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
423
447
  const typeMatch = inner.match(/\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
424
448
  const titleMatch = inner.match(/\btitle\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
425
- const mimeType = (typeMatch && (typeMatch[1] ?? typeMatch[2])) ?? "text/plain";
449
+ const mimeType = (mimeMatch && (mimeMatch[1] ?? mimeMatch[2])) ?? (typeMatch && (typeMatch[1] ?? typeMatch[2])) ?? "text/plain";
426
450
  const title = titleMatch ? titleMatch[1] ?? titleMatch[2] : void 0;
427
451
  this._state.mode = "artifact";
428
452
  this._state.activeSegment = this._createArtifactSegment(mimeType, title);
@@ -477,7 +501,7 @@ class AparteStreamParser {
477
501
  }
478
502
  _generateId() {
479
503
  ++this._state.segmentCounter;
480
- return `${this._options.idPrefix}-${crypto.randomUUID()}`;
504
+ return `${this._options.idPrefix}-${uuid()}`;
481
505
  }
482
506
  _createTextSegment(content) {
483
507
  return {
@@ -597,7 +621,7 @@ function parseAparteEventStream(stream) {
597
621
  }
598
622
  });
599
623
  }
600
- class BackendTransport {
624
+ class AparteBackendTransport {
601
625
  constructor(options) {
602
626
  this.options = options;
603
627
  }
@@ -626,11 +650,9 @@ class BackendTransport {
626
650
  function createAparteChatHandler(options) {
627
651
  const doFetch = options.fetchImpl ?? fetch;
628
652
  return async function handler(req) {
629
- if (options.authorize) {
630
- const verdict = await options.authorize(req);
631
- if (verdict instanceof Response) return verdict;
632
- if (!verdict) return jsonError(401, "Unauthorized.");
633
- }
653
+ const verdict = await options.authorize(req);
654
+ if (verdict instanceof Response) return verdict;
655
+ if (!verdict) return jsonError(401, "Unauthorized.");
634
656
  let providerId;
635
657
  let request;
636
658
  try {
@@ -675,11 +697,25 @@ function createAparteChatHandler(options) {
675
697
  return jsonError(502, `Vendor request failed: ${err?.message ?? "network error"}`);
676
698
  }
677
699
  if (!vendor.ok) {
678
- const text = await vendor.text().catch(() => "");
679
- return new Response(text || JSON.stringify({ error: { message: `HTTP ${vendor.status}` } }), {
680
- status: vendor.status,
681
- headers: { "Content-Type": vendor.headers.get("Content-Type") ?? "application/json" }
682
- });
700
+ const raw = await vendor.text().catch(() => "");
701
+ let code;
702
+ let type;
703
+ try {
704
+ const parsed = JSON.parse(raw);
705
+ code = parsed?.error?.code;
706
+ type = parsed?.error?.type;
707
+ } catch {
708
+ }
709
+ return new Response(
710
+ JSON.stringify({
711
+ error: {
712
+ message: `Vendor request failed (HTTP ${vendor.status}).`,
713
+ ...typeof code === "string" ? { code } : {},
714
+ ...typeof type === "string" ? { type } : {}
715
+ }
716
+ }),
717
+ { status: vendor.status, headers: { "Content-Type": "application/json" } }
718
+ );
683
719
  }
684
720
  if (request.stream === false) {
685
721
  const json = await vendor.json().catch(() => ({}));
@@ -843,8 +879,15 @@ const TAG_ATTRS = {
843
879
  const URL_ATTRS = /* @__PURE__ */ new Set(["href", "src"]);
844
880
  const SAFE_URL = /^(?:https?:|mailto:|tel:|ftp:|sms:)/i;
845
881
  const RELATIVE_URL = /^(?:[#/.?]|[a-z0-9._~%+-]+(?:[/?#]|$))/i;
846
- const SAFE_DATA_IMG = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|svg\+xml)?[;,]/i;
882
+ const SAFE_DATA_IMG = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|svg\+xml)[;,]/i;
847
883
  const CONTROL_WS = /[\u0000-\u0020]+/g;
884
+ function isSafeSrcset(value, tag) {
885
+ const urlTag = tag === "source" ? "img" : tag;
886
+ for (const m of value.matchAll(/[a-z][a-z0-9+.-]*:[^\s,]*/gi)) {
887
+ if (!isSafeUrl(m[0], urlTag)) return false;
888
+ }
889
+ return true;
890
+ }
848
891
  function isSafeUrl(value, tag) {
849
892
  const v = value.replace(CONTROL_WS, "");
850
893
  if (!v) return true;
@@ -877,6 +920,7 @@ function scrubStyle(value) {
877
920
  const prop = trimmed.slice(0, idx).trim().toLowerCase();
878
921
  const val = trimmed.slice(idx + 1);
879
922
  if (!val.trim() || !SAFE_STYLE_PROPS.has(prop)) continue;
923
+ if (val.includes("\\")) continue;
880
924
  if (/url\s*\(|expression\s*\(|javascript:|vbscript:|[<>]/i.test(val)) continue;
881
925
  kept.push(trimmed);
882
926
  }
@@ -890,7 +934,7 @@ function copyAttributes(src, dest, tag) {
890
934
  if (name.startsWith("on") || name.startsWith("formaction")) continue;
891
935
  if (!name.startsWith("data-") && !GLOBAL_ATTRS.has(name) && !(extra && extra.has(name))) continue;
892
936
  if (URL_ATTRS.has(name) && !isSafeUrl(value, tag)) continue;
893
- if (name === "srcset" && /javascript:|vbscript:/i.test(value)) continue;
937
+ if (name === "srcset" && !isSafeSrcset(value, tag)) continue;
894
938
  if (name === "style") {
895
939
  const scrubbed = scrubStyle(value);
896
940
  if (scrubbed === null) continue;
@@ -939,19 +983,34 @@ const defaultSanitizer = (html) => {
939
983
  sanitizeChildren(doc.body, container, doc);
940
984
  return container.innerHTML;
941
985
  };
942
- const DEFAULT_BUBBLE_ACTIONS = {
986
+ function escapeHtml(value) {
987
+ let out = "";
988
+ for (let i = 0; i < value.length; i++) {
989
+ const ch = value[i];
990
+ if (ch === "&") out += "&amp;";
991
+ else if (ch === "<") out += "&lt;";
992
+ else if (ch === ">") out += "&gt;";
993
+ else if (ch === '"') out += "&quot;";
994
+ else if (ch === "'") out += "&#039;";
995
+ else out += ch;
996
+ }
997
+ return out;
998
+ }
999
+ const escapeAttr = escapeHtml;
1000
+ const APARTE_DEFAULT_BUBBLE_ACTIONS = {
943
1001
  copy: true,
944
1002
  retry: false,
945
1003
  edit: false,
946
1004
  feedback: false,
947
1005
  info: false
948
1006
  };
949
- const DEFAULT_HOST_HANDLERS = {
1007
+ const APARTE_DEFAULT_HOST_HANDLERS = {
950
1008
  attachmentPreview: false,
951
1009
  terminalRun: false,
952
- artifactRedownload: false
1010
+ artifactRedownload: false,
1011
+ artifactRehydrate: false
953
1012
  };
954
- class AparteConfigClass {
1013
+ class AparteConfig {
955
1014
  _markdownProvider;
956
1015
  _streamingMarkdownProvider;
957
1016
  _highlightProvider;
@@ -971,30 +1030,50 @@ class AparteConfigClass {
971
1030
  _avatarProvider;
972
1031
  _keyProvider;
973
1032
  _artifactPreviewBuilder;
974
- _locale = DEFAULT_LOCALE;
1033
+ _locale = APARTE_DEFAULT_LOCALE;
975
1034
  _actions = [];
976
1035
  _listeners = /* @__PURE__ */ new Set();
977
1036
  // AI Provider Management (BYORK)
978
1037
  _aiProviders = /* @__PURE__ */ new Map();
1038
+ /**
1039
+ * What `refreshProviderModels()` last brought back, per provider.
1040
+ *
1041
+ * Without it, `getCurrentModel()` read `provider.getModels()` only — the
1042
+ * SYNCHRONOUS, hand-declared list, which every preset of
1043
+ * `@aparte/provider-openai-compat` leaves empty because a compat endpoint's
1044
+ * list is fetched at runtime. So the current model resolved to `undefined`
1045
+ * for the documented primary path, and everything that reads a capability off
1046
+ * it silently got nothing: `AparteClient._toolsForCurrentModel()` gates tools
1047
+ * on `capabilities.includes('function_calling')`, so NO tool was ever sent to
1048
+ * the model. A registered tool, an approval gate, `<aparte-elicitation>` — all
1049
+ * dead, with the model answering, correctly, that it had been given no tools.
1050
+ */
1051
+ _fetchedModels = /* @__PURE__ */ new Map();
979
1052
  _modelConfig = {};
980
1053
  /** Opt-in: gate the composer (block send + grey out) until a model is selected. */
981
1054
  _requireModelSelection = false;
982
- // Transport: where chat requests go + how auth is handled (DirectTransport = browser-direct).
983
- _transport = new DirectTransport();
1055
+ /** Host policy for the elicitation panel's free-text escape see {@link setElicitationOptions}. */
1056
+ _elicitationAllowOther = true;
1057
+ _elicitationLayout = "stepped";
1058
+ _elicitationFieldRenderer;
1059
+ // Transport: where chat requests go + how auth is handled (AparteDirectTransport = browser-direct).
1060
+ _transport = new AparteDirectTransport();
984
1061
  _modelPreferenceProvider;
985
1062
  // Conversation persistence (optional, agnostic)
986
1063
  _conversationManager;
987
- // Human-in-the-loop: presents typed input requests (ask_question,
1064
+ // Human-in-the-loop: presents typed input requests (ask_user,
988
1065
  // tool approval, forms). Set by the <aparte-elicitation> Web Component.
989
1066
  _elicitationPresenter;
1067
+ /** Warn once per config, not once per request — a tool loop can ask repeatedly. */
1068
+ _warnedNoPresenter = false;
990
1069
  // Tool Registry
991
1070
  _tools = /* @__PURE__ */ new Map();
992
1071
  _toolRenderers = /* @__PURE__ */ new Map();
993
1072
  // 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
1073
+ _hostHandlers = { ...APARTE_DEFAULT_HOST_HANDLERS };
1074
+ // Bubble Actions — APARTE_DEFAULT_BUBBLE_ACTIONS is the single source of truth
996
1075
  // (init here, restored by reset(), and the fallback in getBubbleActions()).
997
- _bubbleActionsConfig = { ...DEFAULT_BUBBLE_ACTIONS };
1076
+ _bubbleActionsConfig = { ...APARTE_DEFAULT_BUBBLE_ACTIONS };
998
1077
  // ─────────────────────────────────────────────────────────────────────────
999
1078
  // Provider Setters (Dependency Injection)
1000
1079
  // ─────────────────────────────────────────────────────────────────────────
@@ -1036,15 +1115,15 @@ class AparteConfigClass {
1036
1115
  }
1037
1116
  /**
1038
1117
  * Configure which action buttons appear in message bubbles.
1039
- * Unset keys keep their defaults — see {@link DEFAULT_BUBBLE_ACTIONS}: `copy`
1118
+ * Unset keys keep their defaults — see {@link APARTE_DEFAULT_BUBBLE_ACTIONS}: `copy`
1040
1119
  * only, because every other button needs a host to honor it.
1041
1120
  *
1042
1121
  * @example
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
1122
+ * aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }) // you run AparteClient
1123
+ * aparteGlobalConfig.setBubbleActions({ feedback: true }) // you listen for aparte-feedback
1124
+ * aparteGlobalConfig.setBubbleActions({ copy: false }) // hide everything
1046
1125
  * // Explicit per-role ordered sets (replace the flag defaults for that role):
1047
- * AparteConfig.setBubbleActions({ user: ['edit', 'copy'], assistant: ['copy', 'thumbUp', 'thumbDown', 'retry'] })
1126
+ * aparteGlobalConfig.setBubbleActions({ user: ['edit', 'copy'], assistant: ['copy', 'thumbUp', 'thumbDown', 'retry'] })
1048
1127
  */
1049
1128
  setBubbleActions(config) {
1050
1129
  this._bubbleActionsConfig = { ...this._bubbleActionsConfig, ...config };
@@ -1055,28 +1134,34 @@ class AparteConfigClass {
1055
1134
  * trigger only for the ones you claim — see {@link AparteHostHandlersConfig}.
1056
1135
  *
1057
1136
  * @example
1058
- * AparteConfig.setHostHandlers({ attachmentPreview: true, terminalRun: true });
1137
+ * aparteGlobalConfig.setHostHandlers({ attachmentPreview: true, terminalRun: true });
1059
1138
  */
1060
1139
  setHostHandlers(config) {
1061
1140
  this._hostHandlers = { ...this._hostHandlers, ...config };
1062
1141
  this._notify();
1063
1142
  }
1064
1143
  /** Returns the resolved host-handler declarations (undeclared = false). */
1144
+ // `Required<...>` rather than a hand-written shape: the previous signature
1145
+ // listed the three keys literally, so adding a fourth to the interface left
1146
+ // this method silently returning a type without it — the caller's read did not
1147
+ // even typecheck. Derived from the interface, a new declaration cannot be
1148
+ // forgotten here.
1065
1149
  getHostHandlers() {
1066
1150
  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
1151
+ attachmentPreview: this._hostHandlers.attachmentPreview ?? APARTE_DEFAULT_HOST_HANDLERS.attachmentPreview,
1152
+ terminalRun: this._hostHandlers.terminalRun ?? APARTE_DEFAULT_HOST_HANDLERS.terminalRun,
1153
+ artifactRedownload: this._hostHandlers.artifactRedownload ?? APARTE_DEFAULT_HOST_HANDLERS.artifactRedownload,
1154
+ artifactRehydrate: this._hostHandlers.artifactRehydrate ?? APARTE_DEFAULT_HOST_HANDLERS.artifactRehydrate
1070
1155
  };
1071
1156
  }
1072
1157
  /** Returns the resolved bubble actions config (flag defaults applied; per-role sets passed through). */
1073
1158
  getBubbleActions() {
1074
1159
  return {
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,
1160
+ copy: this._bubbleActionsConfig.copy ?? APARTE_DEFAULT_BUBBLE_ACTIONS.copy,
1161
+ retry: this._bubbleActionsConfig.retry ?? APARTE_DEFAULT_BUBBLE_ACTIONS.retry,
1162
+ edit: this._bubbleActionsConfig.edit ?? APARTE_DEFAULT_BUBBLE_ACTIONS.edit,
1163
+ feedback: this._bubbleActionsConfig.feedback ?? APARTE_DEFAULT_BUBBLE_ACTIONS.feedback,
1164
+ info: this._bubbleActionsConfig.info ?? APARTE_DEFAULT_BUBBLE_ACTIONS.info,
1080
1165
  user: this._bubbleActionsConfig.user,
1081
1166
  assistant: this._bubbleActionsConfig.assistant
1082
1167
  };
@@ -1129,9 +1214,14 @@ class AparteConfigClass {
1129
1214
  * hardened coverage, or `null` to DISABLE sanitization. Disabling exposes
1130
1215
  * you to XSS from LLM-authored content — only do so for content you fully
1131
1216
  * trust and have already sanitized upstream.
1132
- * @example AparteConfig.setHtmlSanitizer((html) => DOMPurify.sanitize(html));
1217
+ * @example aparteGlobalConfig.setHtmlSanitizer((html) => DOMPurify.sanitize(html));
1133
1218
  */
1134
1219
  setHtmlSanitizer(sanitizer) {
1220
+ if (sanitizer === null) {
1221
+ console.warn(
1222
+ "[aparte] HTML sanitization is now DISABLED (setHtmlSanitizer(null)). Model-authored HTML will be inserted verbatim. Pass a sanitizer (e.g. DOMPurify.sanitize) unless you sanitize upstream yourself."
1223
+ );
1224
+ }
1135
1225
  this._sanitizer = sanitizer;
1136
1226
  }
1137
1227
  /**
@@ -1202,7 +1292,7 @@ class AparteConfigClass {
1202
1292
  }
1203
1293
  /**
1204
1294
  * The icon set as a **complete** provider: every name resolves, falling back
1205
- * to `DEFAULT_ICON_FALLBACKS` for anything the registered provider doesn't
1295
+ * to `APARTE_DEFAULT_ICON_FALLBACKS` for anything the registered provider doesn't
1206
1296
  * implement. Callers (bubble action bar, composer controls) can therefore
1207
1297
  * invoke `icons.copy()` unconditionally.
1208
1298
  *
@@ -1214,9 +1304,9 @@ class AparteConfigClass {
1214
1304
  getIconProvider() {
1215
1305
  const registered = this._iconProvider;
1216
1306
  const complete = {};
1217
- for (const name of Object.keys(DEFAULT_ICON_FALLBACKS)) {
1307
+ for (const name of Object.keys(APARTE_DEFAULT_ICON_FALLBACKS)) {
1218
1308
  const fn = registered?.[name];
1219
- complete[name] = fn ?? (() => DEFAULT_ICON_FALLBACKS[name]);
1309
+ complete[name] = fn ?? (() => APARTE_DEFAULT_ICON_FALLBACKS[name]);
1220
1310
  }
1221
1311
  return complete;
1222
1312
  }
@@ -1315,12 +1405,12 @@ class AparteConfigClass {
1315
1405
  this._notify();
1316
1406
  }
1317
1407
  /**
1318
- * Restore the built-in English locale (the same `DEFAULT_LOCALE` core ships
1408
+ * Restore the built-in English locale (the same `APARTE_DEFAULT_LOCALE` core ships
1319
1409
  * with). Counterpart of {@link setLocale} for language toggles — avoids
1320
- * having to import `DEFAULT_LOCALE` yourself.
1410
+ * having to import `APARTE_DEFAULT_LOCALE` yourself.
1321
1411
  */
1322
1412
  resetLocale() {
1323
- this._locale = DEFAULT_LOCALE;
1413
+ this._locale = APARTE_DEFAULT_LOCALE;
1324
1414
  this._notify();
1325
1415
  }
1326
1416
  /**
@@ -1345,7 +1435,7 @@ class AparteConfigClass {
1345
1435
  getIcon(name) {
1346
1436
  const icon = this._iconProvider?.[name];
1347
1437
  if (icon) return icon();
1348
- return DEFAULT_ICON_FALLBACKS[name];
1438
+ return APARTE_DEFAULT_ICON_FALLBACKS[name];
1349
1439
  }
1350
1440
  /**
1351
1441
  * Set a custom Key provider (e.g., AparteVault override)
@@ -1372,7 +1462,9 @@ class AparteConfigClass {
1372
1462
  if (!provider || !provider.fetchModels) return [];
1373
1463
  try {
1374
1464
  const apiKey = await this.getKey(providerId);
1375
- return await provider.fetchModels(apiKey);
1465
+ const models = await provider.fetchModels(apiKey);
1466
+ this._fetchedModels.set(providerId, models);
1467
+ return models;
1376
1468
  } catch (error) {
1377
1469
  console.warn(`[AparteConfig] Failed to refresh models for ${providerId}`, error);
1378
1470
  return [];
@@ -1385,8 +1477,8 @@ class AparteConfigClass {
1385
1477
  * Register one or more AI providers (e.g., OpenRouter, Gemini, Anthropic)
1386
1478
  * @param providers AparteAIProvider implementations
1387
1479
  * @example
1388
- * AparteConfig.registerAIProvider(OpenRouterProvider);
1389
- * AparteConfig.registerAIProvider(GeminiProvider, AnthropicProvider);
1480
+ * aparteGlobalConfig.registerAIProvider(OpenRouterProvider);
1481
+ * aparteGlobalConfig.registerAIProvider(GeminiProvider, AnthropicProvider);
1390
1482
  */
1391
1483
  registerAIProvider(...providers) {
1392
1484
  for (const provider of providers) {
@@ -1426,14 +1518,14 @@ class AparteConfigClass {
1426
1518
  }
1427
1519
  /**
1428
1520
  * Set the transport that decides where chat requests go and how auth is
1429
- * handled. Defaults to {@link DirectTransport} (browser-direct — BYOK/local).
1430
- * Use a `BackendTransport` to keep API keys server-side (recommended for
1521
+ * handled. Defaults to {@link AparteDirectTransport} (browser-direct — BYOK/local).
1522
+ * Use a `AparteBackendTransport` to keep API keys server-side (recommended for
1431
1523
  * production).
1432
1524
  */
1433
1525
  setTransport(transport) {
1434
1526
  this._transport = transport;
1435
1527
  }
1436
- /** Get the active transport (DirectTransport by default). */
1528
+ /** Get the active transport (AparteDirectTransport by default). */
1437
1529
  getTransport() {
1438
1530
  return this._transport;
1439
1531
  }
@@ -1441,7 +1533,7 @@ class AparteConfigClass {
1441
1533
  * Register a model preference provider for agnostic persistence.
1442
1534
  * The host app decides how/where to store the selected provider & model.
1443
1535
  * @example
1444
- * AparteConfig.setModelPreferenceProvider({
1536
+ * aparteGlobalConfig.setModelPreferenceProvider({
1445
1537
  * save: (p, m) => localStorage.setItem('model', JSON.stringify({p, m})),
1446
1538
  * load: () => JSON.parse(localStorage.getItem('model') ?? 'null')
1447
1539
  * });
@@ -1515,6 +1607,53 @@ class AparteConfigClass {
1515
1607
  * still fetching its list). Off by default so single-model / backend setups
1516
1608
  * that never select a model are unaffected.
1517
1609
  */
1610
+ /**
1611
+ * Elicitation policy: whether a choice offers a free-text "Other…" escape.
1612
+ *
1613
+ * This is the HOST's decision, not the model's. `ask_user` used to carry an
1614
+ * `allow_other` field in the schema it hands the model, which meant the model
1615
+ * decided your UX — and a small model fills a field it does not understand: one
1616
+ * sent two questions with `allow_other: true` and no options at all, so the
1617
+ * panel rendered a radio list whose only entry was "Other…", twice.
1618
+ *
1619
+ * Every serious implementation of this pattern makes the escape hatch a property
1620
+ * of the surface rather than of the request. Default `true`, which is what the
1621
+ * panel did before, so nothing a user sees changes — only who gets to say so.
1622
+ * A direct `requestUserInput` caller can still set `allowOther` per field; that
1623
+ * is the app talking, and it wins.
1624
+ *
1625
+ * `layout` decides how a form of SEVERAL questions is presented.
1626
+ * `'stepped'` (default) asks them one at a time with a chip per question;
1627
+ * `'stacked'` puts them all in the panel at once. Stacked was the only shape,
1628
+ * inherited unexamined from MCP elicitation — which describes a FORM for
1629
+ * collecting structured data, not two different questions asked mid-conversation.
1630
+ * No product asks a person two questions by stacking them in one box; it is kept
1631
+ * as an option because the form case is real, not because it was the right default.
1632
+ */
1633
+ setElicitationOptions(options) {
1634
+ if (options.allowOther !== void 0) this._elicitationAllowOther = options.allowOther;
1635
+ if (options.layout !== void 0) this._elicitationLayout = options.layout;
1636
+ this._notify();
1637
+ }
1638
+ /** The elicitation policy (see {@link setElicitationOptions}). */
1639
+ getElicitationOptions() {
1640
+ return { allowOther: this._elicitationAllowOther, layout: this._elicitationLayout };
1641
+ }
1642
+ /**
1643
+ * Render one field of the question panel yourself.
1644
+ *
1645
+ * See {@link AparteElicitationFieldRenderer}. Return `null` for a field to let
1646
+ * the built-in render it, which is what makes overriding a single kind practical.
1647
+ * Pass `null` here to remove the renderer.
1648
+ */
1649
+ setElicitationFieldRenderer(fn) {
1650
+ this._elicitationFieldRenderer = fn ?? void 0;
1651
+ this._notify();
1652
+ }
1653
+ /** The custom field renderer, if one is registered. */
1654
+ getElicitationFieldRenderer() {
1655
+ return this._elicitationFieldRenderer;
1656
+ }
1518
1657
  setRequireModelSelection(required) {
1519
1658
  if (this._requireModelSelection === required) return;
1520
1659
  this._requireModelSelection = required;
@@ -1534,10 +1673,13 @@ class AparteConfigClass {
1534
1673
  if (!defaultProvider || !defaultModel) return void 0;
1535
1674
  const provider = this._aiProviders.get(defaultProvider);
1536
1675
  if (!provider) return void 0;
1676
+ const fetched = this._fetchedModels.get(defaultProvider);
1677
+ const fromFetch = fetched?.find((m) => m.id === defaultModel);
1678
+ if (fromFetch) return fromFetch;
1537
1679
  const models = provider.getModels();
1538
1680
  if (models instanceof Promise) {
1539
1681
  console.warn(
1540
- `[AparteConfig] Provider "${defaultProvider}".getModels() returned a Promise — it is ignored here, so model capabilities (e.g. function_calling) resolve to none and tools are disabled. getModels() must return the list synchronously; implement fetchModels() for async fetching.`
1682
+ `[AparteConfig] Provider "${defaultProvider}".getModels() returned a Promise — it is ignored here, so this model resolves to undefined and nothing can read its capabilities. getModels() must return the list synchronously; implement fetchModels() for async fetching.`
1541
1683
  );
1542
1684
  return void 0;
1543
1685
  }
@@ -1550,7 +1692,7 @@ class AparteConfigClass {
1550
1692
  * Get translated string by key
1551
1693
  */
1552
1694
  t(key) {
1553
- const val = this._locale[key] || DEFAULT_LOCALE[key];
1695
+ const val = this._locale[key] || APARTE_DEFAULT_LOCALE[key];
1554
1696
  return val === void 0 ? "" : val;
1555
1697
  }
1556
1698
  /**
@@ -1601,7 +1743,7 @@ class AparteConfigClass {
1601
1743
  * Register a tool and its handler together.
1602
1744
  * The handler is called when the AI invokes the tool during streaming.
1603
1745
  * @example
1604
- * AparteConfig.registerTool(askQuestionTool, askQuestionHandler);
1746
+ * aparteGlobalConfig.registerTool(askUserTool, askUserHandler);
1605
1747
  */
1606
1748
  registerTool(tool, handler) {
1607
1749
  this._tools.set(tool.name, { tool, handler });
@@ -1624,12 +1766,12 @@ class AparteConfigClass {
1624
1766
  * Use this instead of the generic `tool_call` segment renderer for tool-specific UI.
1625
1767
  *
1626
1768
  * @example
1627
- * // Hide the segment entirely (UI-only tool like ask_question)
1628
- * AparteConfig.registerToolRenderer('ask_question', { render: () => '' });
1769
+ * // Hide the segment entirely (UI-only tool like ask_user)
1770
+ * aparteGlobalConfig.registerToolRenderer('ask_user', { render: () => '' });
1629
1771
  *
1630
1772
  * @example
1631
1773
  * // Custom pill for a web-search tool
1632
- * AparteConfig.registerToolRenderer('web_search', { render: (seg) => `<div class="tool-pill">Searching...</div>` });
1774
+ * aparteGlobalConfig.registerToolRenderer('web_search', { render: (seg) => `<div class="tool-pill">Searching...</div>` });
1633
1775
  */
1634
1776
  registerToolRenderer(toolName, renderer) {
1635
1777
  this._toolRenderers.set(toolName, renderer);
@@ -1646,13 +1788,13 @@ class AparteConfigClass {
1646
1788
  // Conversation Manager (optional, agnostic persistence layer)
1647
1789
  // ─────────────────────────────────────────────────────────────────────────
1648
1790
  /**
1649
- * Register a ConversationManager so any UI controller can persist & load
1791
+ * Register a AparteConversationManager so any UI controller can persist & load
1650
1792
  * conversations without coupling to a framework wrapper.
1651
1793
  */
1652
1794
  setConversationManager(manager) {
1653
1795
  this._conversationManager = manager;
1654
1796
  }
1655
- /** Returns the registered ConversationManager, or undefined if none. */
1797
+ /** Returns the registered AparteConversationManager, or undefined if none. */
1656
1798
  getConversationManager() {
1657
1799
  return this._conversationManager;
1658
1800
  }
@@ -1674,14 +1816,29 @@ class AparteConfigClass {
1674
1816
  }
1675
1817
  /**
1676
1818
  * Ask the user for typed input mid-run and await their response. This is the
1677
- * generic primitive behind `ask_question` and tool approval — the KIND of
1819
+ * generic primitive behind `ask_user` and tool approval — the KIND of
1678
1820
  * question is the schema, not a bespoke tool. Resolves `accept` with the
1679
1821
  * value, `decline` when the user declines, or `cancel` when the turn is
1680
- * cancelled. With no presenter registered it resolves `cancel` (nothing can
1681
- * present it) rather than hanging.
1822
+ * cancelled.
1823
+ *
1824
+ * With NO presenter registered it resolves `cancel` rather than hanging — and
1825
+ * warns, once, because that `cancel` is otherwise a lie told quietly. The
1826
+ * model reads it as "the user refused"; the user was never asked, and the
1827
+ * developer sees nothing at all. The default presenter is
1828
+ * `<aparte-elicitation>`, which registers itself from its `connectedCallback`
1829
+ * — so it has to be IN THE DOM. A docs page of ours claimed it "installs
1830
+ * itself — nothing to register", which is how this failure mode was found.
1682
1831
  */
1683
1832
  requestUserInput(request) {
1684
- if (!this._elicitationPresenter) return Promise.resolve({ action: "cancel" });
1833
+ if (!this._elicitationPresenter) {
1834
+ if (!this._warnedNoPresenter) {
1835
+ this._warnedNoPresenter = true;
1836
+ console.warn(
1837
+ "[aparte] requestUserInput() was called with no elicitation presenter, so it resolved `cancel` — the model will read that as the user refusing, but nothing was ever shown. Add <aparte-elicitation></aparte-elicitation> inside your <aparte-chat>, or register your own by calling setElicitationPresenter() on the config this chat resolves — the scoped one if you passed a `config`, aparteGlobalConfig otherwise."
1838
+ );
1839
+ }
1840
+ return Promise.resolve({ action: "cancel" });
1841
+ }
1685
1842
  return this._elicitationPresenter(request);
1686
1843
  }
1687
1844
  /**
@@ -1708,28 +1865,32 @@ class AparteConfigClass {
1708
1865
  this._keyProvider = void 0;
1709
1866
  this._conversationManager = void 0;
1710
1867
  this._elicitationPresenter = void 0;
1711
- this._locale = DEFAULT_LOCALE;
1868
+ this._locale = APARTE_DEFAULT_LOCALE;
1712
1869
  this._actions = [];
1713
1870
  this._sanitizer = defaultSanitizer;
1714
1871
  this._aiProviders.clear();
1872
+ this._fetchedModels.clear();
1715
1873
  this._tools.clear();
1716
1874
  this._toolRenderers.clear();
1717
1875
  this._modelConfig = {};
1718
1876
  this._requireModelSelection = false;
1877
+ this._elicitationAllowOther = true;
1878
+ this._elicitationLayout = "stepped";
1879
+ this._elicitationFieldRenderer = void 0;
1719
1880
  this._modelPreferenceProvider = void 0;
1720
- this._bubbleActionsConfig = { ...DEFAULT_BUBBLE_ACTIONS };
1721
- this._hostHandlers = { ...DEFAULT_HOST_HANDLERS };
1881
+ this._bubbleActionsConfig = { ...APARTE_DEFAULT_BUBBLE_ACTIONS };
1882
+ this._hostHandlers = { ...APARTE_DEFAULT_HOST_HANDLERS };
1722
1883
  this._notify();
1723
1884
  }
1724
1885
  // ─────────────────────────────────────────────────────────────────────────
1725
1886
  // Default Fallbacks (Zero-dependency)
1726
1887
  // ─────────────────────────────────────────────────────────────────────────
1727
1888
  _defaultMarkdownRenderer(raw) {
1728
- const escaped = this._escapeHtml(raw);
1889
+ const escaped = escapeHtml(raw);
1729
1890
  return escaped.replace(/\n/g, "<br>");
1730
1891
  }
1731
1892
  _defaultHighlightRenderer(code) {
1732
- return `<pre><code>${this._escapeHtml(code)}</code></pre>`;
1893
+ return `<pre><code>${escapeHtml(code)}</code></pre>`;
1733
1894
  }
1734
1895
  _defaultSkeletonRenderer(type) {
1735
1896
  const fallbacks = {
@@ -1742,34 +1903,44 @@ class AparteConfigClass {
1742
1903
  };
1743
1904
  return fallbacks[type] || fallbacks.text;
1744
1905
  }
1745
- _escapeHtml(text) {
1746
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1747
- }
1748
1906
  }
1749
- const GLOBAL_CONFIG_KEY = "__APARTE_CONFIG_SINGLETON__";
1907
+ const GLOBAL_CONFIG_KEY = Symbol.for("aparte.globalConfig.1");
1750
1908
  function getGlobalConfig() {
1751
1909
  if (typeof window !== "undefined") {
1752
1910
  if (!window[GLOBAL_CONFIG_KEY]) {
1753
- window[GLOBAL_CONFIG_KEY] = new AparteConfigClass();
1911
+ window[GLOBAL_CONFIG_KEY] = new AparteConfig();
1754
1912
  }
1755
1913
  return window[GLOBAL_CONFIG_KEY];
1756
1914
  }
1757
- return new AparteConfigClass();
1915
+ return new AparteConfig();
1758
1916
  }
1759
- const AparteConfig = getGlobalConfig();
1917
+ const aparteGlobalConfig = getGlobalConfig();
1760
1918
  const APARTE_HOST_ATTR = "data-aparte-host";
1761
1919
  const CONFIG_SLOT = Symbol.for("aparte.instanceConfig");
1920
+ function notifyConfigChanged(root, next, previous) {
1921
+ if (next === previous) return;
1922
+ const call = (el2) => {
1923
+ const hook = el2.aparteConfigChanged;
1924
+ if (typeof hook === "function") hook.call(el2, next, previous);
1925
+ };
1926
+ call(root);
1927
+ for (const el2 of root.querySelectorAll("*")) call(el2);
1928
+ }
1762
1929
  function attachConfig(el2, config) {
1930
+ const previous = el2[CONFIG_SLOT] ?? resolveConfig(el2.parentElement);
1763
1931
  el2[CONFIG_SLOT] = config;
1764
1932
  el2.setAttribute(APARTE_HOST_ATTR, "");
1933
+ notifyConfigChanged(el2, config, previous);
1765
1934
  }
1766
1935
  function detachConfig(el2) {
1936
+ const previous = el2[CONFIG_SLOT];
1767
1937
  delete el2[CONFIG_SLOT];
1768
1938
  el2.removeAttribute(APARTE_HOST_ATTR);
1939
+ if (previous) notifyConfigChanged(el2, resolveConfig(el2.parentElement), previous);
1769
1940
  }
1770
1941
  function resolveConfig(el2) {
1771
1942
  const host = el2?.closest?.(`[${APARTE_HOST_ATTR}]`);
1772
- return host?.[CONFIG_SLOT] ?? AparteConfig;
1943
+ return host?.[CONFIG_SLOT] ?? aparteGlobalConfig;
1773
1944
  }
1774
1945
  let _renderConfig = null;
1775
1946
  function runWithConfig(config, fn) {
@@ -1784,7 +1955,7 @@ function runWithConfig(config, fn) {
1784
1955
  function contextConfig(el2) {
1785
1956
  return _renderConfig ?? resolveConfig(el2);
1786
1957
  }
1787
- const DEFAULT_SKELETON_FALLBACKS = {
1958
+ const APARTE_DEFAULT_SKELETON_FALLBACKS = {
1788
1959
  message: '<div class="aparte-skeleton-fallback" style="padding:16px;color:#9ca3af;">Loading...</div>',
1789
1960
  code: '<div class="aparte-skeleton-fallback" style="padding:16px;background:#1e293b;color:#64748b;border-radius:8px;">Loading code...</div>',
1790
1961
  thinking: '<div class="aparte-skeleton-fallback" style="padding:8px;color:#9ca3af;">Thinking...</div>',
@@ -1792,103 +1963,45 @@ const DEFAULT_SKELETON_FALLBACKS = {
1792
1963
  list: '<div class="aparte-skeleton-fallback" style="padding:16px;color:#9ca3af;">Loading items...</div>',
1793
1964
  text: '<div class="aparte-skeleton-fallback" style="padding:8px;color:#9ca3af;">...</div>'
1794
1965
  };
1795
- function escapeHtml(str) {
1796
- let out = "";
1797
- for (let i = 0; i < str.length; i++) {
1798
- const ch = str[i];
1799
- if (ch === "&") out += "&amp;";
1800
- else if (ch === "<") out += "&lt;";
1801
- else if (ch === ">") out += "&gt;";
1802
- else if (ch === '"') out += "&quot;";
1803
- else if (ch === "'") out += "&#039;";
1804
- else out += ch;
1805
- }
1806
- return out;
1807
- }
1808
- function stripCodeFences(content) {
1809
- let s = content;
1810
- if (s.startsWith("```") || s.startsWith("~~~")) {
1811
- const fenceChar = s[0];
1812
- let i = 0;
1813
- while (i < s.length && s[i] === fenceChar) i++;
1814
- while (i < s.length && s[i] !== "\n") i++;
1815
- if (i < s.length && s[i] === "\n") i++;
1816
- s = s.slice(i);
1817
- }
1818
- const closeAt = findClosingFence(s);
1819
- if (closeAt !== -1) s = s.slice(0, closeAt);
1820
- return s.trim();
1821
- }
1822
- function findClosingFence(s) {
1823
- let i = 0;
1824
- while (i < s.length) {
1825
- const lineStart = i;
1826
- let k = lineStart;
1827
- while (k < s.length && (s[k] === " " || s[k] === " ")) k++;
1828
- if (k < s.length && (s[k] === "`" || s[k] === "~")) {
1829
- const fenceChar = s[k];
1830
- let runs = 0;
1831
- while (k < s.length && s[k] === fenceChar) {
1832
- runs++;
1833
- k++;
1966
+ function writeStreamedMarkdown(host, contentEl, content, streaming) {
1967
+ if (streaming) {
1968
+ if (host._aparteSmd === void 0) {
1969
+ const renderer = contextConfig().createStreamingMarkdownRenderer(contentEl);
1970
+ if (renderer) {
1971
+ contentEl.textContent = "";
1972
+ host._aparteSmd = { renderer, written: 0 };
1973
+ } else {
1974
+ host._aparteSmd = null;
1834
1975
  }
1835
- if (runs >= 3) {
1836
- let onlyWs = true;
1837
- while (k < s.length && s[k] !== "\n") {
1838
- if (s[k] !== " " && s[k] !== " " && s[k] !== "\r") {
1839
- onlyWs = false;
1840
- break;
1841
- }
1842
- k++;
1843
- }
1844
- if (onlyWs) {
1845
- let cut = lineStart;
1846
- if (cut > 0 && s[cut - 1] === "\n") cut--;
1847
- return cut;
1848
- }
1976
+ }
1977
+ const smd = host._aparteSmd;
1978
+ if (smd) {
1979
+ const delta = content.slice(smd.written);
1980
+ if (delta) {
1981
+ smd.renderer.write(delta);
1982
+ smd.written = content.length;
1849
1983
  }
1984
+ return;
1850
1985
  }
1851
- while (i < s.length && s[i] !== "\n") i++;
1852
- if (i < s.length) i++;
1853
- }
1854
- return -1;
1855
- }
1856
- const renderers = /* @__PURE__ */ new Map();
1857
- let styleElement = null;
1858
- let defaultsInstalled = false;
1859
- let defaultsDeclined = false;
1860
- function registerSegmentRenderer(renderer) {
1861
- renderers.set(renderer.type, renderer);
1862
- injectRendererStyles();
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);
1986
+ } else if (host._aparteSmd) {
1987
+ host._aparteSmd.renderer.end();
1988
+ host._aparteSmd = void 0;
1872
1989
  }
1873
- injectRendererStyles();
1990
+ contentEl.innerHTML = contextConfig().renderMarkdown(content);
1874
1991
  }
1875
- function unregisterSegmentRenderer(type) {
1876
- renderers.delete(type);
1877
- }
1878
- function getSegmentRenderer(type) {
1879
- return renderers.get(type);
1880
- }
1881
- function collectRendererStyles() {
1882
- return Array.from(renderers.values()).map((r) => r.getStyles?.() || "").filter(Boolean).join("\n");
1883
- }
1884
- function injectRendererStyles() {
1885
- if (typeof document === "undefined") return;
1886
- if (!styleElement) {
1887
- styleElement = document.createElement("style");
1888
- styleElement.id = "aparte-renderer-styles";
1889
- document.head.appendChild(styleElement);
1992
+ function highlightMarkdownFences(contentEl) {
1993
+ const blocks = contentEl.querySelectorAll("pre > code:not([data-aparte-highlighted])");
1994
+ for (const block of blocks) {
1995
+ const pre = block.parentElement;
1996
+ if (!pre) continue;
1997
+ const lang = Array.from(block.classList).find((c) => c.startsWith("language-"))?.slice("language-".length) ?? "";
1998
+ const source = block.textContent ?? "";
1999
+ block.setAttribute("data-aparte-highlighted", "");
2000
+ void contextConfig().highlightCode(source, lang).then((html) => {
2001
+ if (pre.isConnected || pre.parentElement) pre.outerHTML = html;
2002
+ }).catch(() => {
2003
+ });
1890
2004
  }
1891
- styleElement.textContent = collectRendererStyles();
1892
2005
  }
1893
2006
  const textRenderer = {
1894
2007
  type: "text",
@@ -1896,41 +2009,41 @@ const textRenderer = {
1896
2009
  update: (el2, segment) => {
1897
2010
  const contentEl = el2.querySelector(".segment-content");
1898
2011
  if (!contentEl) return;
1899
- const host = el2;
1900
- const streaming = segment.isStreaming !== false;
1901
- if (streaming) {
1902
- if (host._aparteSmd === void 0) {
1903
- const renderer = contextConfig().createStreamingMarkdownRenderer(contentEl);
1904
- if (renderer) {
1905
- contentEl.textContent = "";
1906
- host._aparteSmd = { renderer, written: 0 };
1907
- } else {
1908
- host._aparteSmd = null;
1909
- }
1910
- }
1911
- const smd = host._aparteSmd;
1912
- if (smd) {
1913
- const delta = segment.content.slice(smd.written);
1914
- if (delta) {
1915
- smd.renderer.write(delta);
1916
- smd.written = segment.content.length;
1917
- }
1918
- return;
1919
- }
1920
- } else if (host._aparteSmd) {
1921
- host._aparteSmd.renderer.end();
1922
- host._aparteSmd = void 0;
1923
- }
1924
- contentEl.innerHTML = contextConfig().renderMarkdown(segment.content);
2012
+ writeStreamedMarkdown(
2013
+ el2,
2014
+ contentEl,
2015
+ segment.content,
2016
+ segment.isStreaming !== false
2017
+ );
1925
2018
  },
1926
2019
  getStyles: () => ``
1927
2020
  };
2021
+ const THINKING_ANCHOR_SLACK = 24;
1928
2022
  const thinkingRenderer = {
1929
2023
  type: "thinking",
1930
- render: (segment) => `<details class="segment segment-thinking" data-segment-id="${escapeHtml(segment.id)}" ${segment.collapsed ? "" : "open"}><summary class="thinking-header"><span class="thinking-label">${escapeHtml(segment.label || contextConfig().t("thinking"))}</span><span class="thinking-toggle"></span></summary><div class="thinking-content">${escapeHtml(segment.content)}</div></details>`,
2024
+ /*
2025
+ * Markdown, like every other prose surface.
2026
+ *
2027
+ * The reasoning was rendered as escaped plain text in a `white-space: pre-wrap`
2028
+ * box, so a model that formats its thinking — headings, lists, `**bold**`, a
2029
+ * fenced snippet, which is most of them — showed its own syntax as literal
2030
+ * characters. Nothing said that was deliberate; it read as one, because the
2031
+ * `pre-wrap` made it look like a chosen preformatted block.
2032
+ *
2033
+ * `renderMarkdown` sanitizes its provider's output before it reaches innerHTML,
2034
+ * so this is exactly the text renderer's path, with the same guarantees and no
2035
+ * new class of risk. With no Markdown provider registered it degrades to the
2036
+ * zero-dependency default (escape + `<br>`), which is what it used to be.
2037
+ */
2038
+ render: (segment) => `<details class="segment segment-thinking" data-segment-id="${escapeHtml(segment.id)}" ${segment.collapsed ? "" : "open"}><summary class="thinking-header"><span class="thinking-label">${escapeHtml(segment.label || contextConfig().t("thinking"))}</span><span class="thinking-toggle"></span></summary><div class="thinking-content">${contextConfig().renderMarkdown(segment.content)}</div></details>`,
1931
2039
  update: (el2, segment) => {
1932
2040
  const contentEl = el2.querySelector(".thinking-content");
1933
- if (contentEl) contentEl.textContent = segment.content;
2041
+ if (!contentEl) return;
2042
+ const wasAtBottom = contentEl.scrollHeight - contentEl.scrollTop - contentEl.clientHeight <= THINKING_ANCHOR_SLACK;
2043
+ const streaming = segment.isStreaming !== false;
2044
+ writeStreamedMarkdown(el2, contentEl, segment.content, streaming);
2045
+ if (!streaming) highlightMarkdownFences(contentEl);
2046
+ if (wasAtBottom) contentEl.scrollTop = contentEl.scrollHeight;
1934
2047
  },
1935
2048
  getStyles: () => ``
1936
2049
  };
@@ -1941,7 +2054,7 @@ const codeRenderer = {
1941
2054
  <div class="code-header">
1942
2055
  ${segment.filename ? `<span class="code-filename">${escapeHtml(segment.filename)}</span>` : `<span class="code-header-filler"></span>`}
1943
2056
  <span class="code-language">${escapeHtml(segment.language || "")}</span>
1944
- <button class="code-copy" data-action="copy" title="${contextConfig().t("copy")}">
2057
+ <button class="code-copy" data-action="copy" title="${escapeAttr(contextConfig().t("copy"))}">
1945
2058
  ${contextConfig().getIcon("copy")}
1946
2059
  </button>
1947
2060
  </div>
@@ -2003,8 +2116,8 @@ const terminalRenderer = {
2003
2116
  </div>
2004
2117
  <code class="terminal-command">${escapeHtml(segment.command || "")}</code>
2005
2118
  <div class="terminal-actions">
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>` : ""}
2007
- <button class="terminal-copy-btn" data-action="copy" aria-label="${contextConfig().t("copy")}" title="${contextConfig().t("copy")}">
2119
+ ${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="${escapeAttr(contextConfig().t("run"))}" title="${escapeAttr(contextConfig().t("run"))}">${contextConfig().t("run")}</button>` : ""}
2120
+ <button class="terminal-copy-btn" data-action="copy" aria-label="${escapeAttr(contextConfig().t("copy"))}" title="${escapeAttr(contextConfig().t("copy"))}">
2008
2121
  ${contextConfig().getIcon("copy")}
2009
2122
  </button>
2010
2123
  </div>
@@ -2131,13 +2244,13 @@ const toolCallRenderer = {
2131
2244
  }
2132
2245
  const customRenderer = contextConfig().getToolRenderer(segment.toolCall?.name);
2133
2246
  if (customRenderer) {
2134
- const html = customRenderer.render(segment);
2135
- if (html) return html;
2247
+ const out = customRenderer.render(segment);
2248
+ if (out) return out;
2136
2249
  }
2137
2250
  const statusIcon = status === "resolved" ? contextConfig().getIcon("check") : status === "aborted" || status === "rejected" ? contextConfig().getIcon("close") : "";
2138
2251
  const spinner = status === "pending" ? `<span class="tool-pill-spinner" aria-hidden="true"></span>` : "";
2139
2252
  return `
2140
- <div class="segment segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${status}">
2253
+ <div class="segment segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(status)}">
2141
2254
  <span class="tool-pill">
2142
2255
  <span class="tool-pill-icon">${contextConfig().getIcon("tool")}</span>
2143
2256
  <span class="tool-pill-name">${escapeHtml(name)}</span>
@@ -2151,10 +2264,11 @@ const toolCallRenderer = {
2151
2264
  if (segment.status === "awaiting-approval") {
2152
2265
  const toolCallId = segment.toolCall?.id;
2153
2266
  if (!toolCallId) return;
2267
+ const host = element.closest("[data-aparte-host], aparte-chat, aparte-chat-viewport");
2154
2268
  const decide = (approved) => element.dispatchEvent(new CustomEvent("aparte-tool-decision", {
2155
2269
  bubbles: true,
2156
2270
  composed: true,
2157
- detail: { toolCallId, approved }
2271
+ detail: { toolCallId, approved, targetId: host?.id || void 0 }
2158
2272
  }));
2159
2273
  element.querySelector('[data-tool-decision="approve"]')?.addEventListener("click", () => decide(true));
2160
2274
  element.querySelector('[data-tool-decision="reject"]')?.addEventListener("click", () => decide(false));
@@ -2260,464 +2374,55 @@ const pipelineWaitingRenderer = {
2260
2374
  }
2261
2375
  `
2262
2376
  };
2263
- function registerDefaultRenderers() {
2264
- defaultsInstalled = true;
2265
- for (const renderer of DEFAULT_RENDERERS) registerSegmentRenderer(renderer);
2377
+ function stripCodeFences(content) {
2378
+ let s = content;
2379
+ if (s.startsWith("```") || s.startsWith("~~~")) {
2380
+ const fenceChar = s[0];
2381
+ let i = 0;
2382
+ while (i < s.length && s[i] === fenceChar) i++;
2383
+ while (i < s.length && s[i] !== "\n") i++;
2384
+ if (i < s.length && s[i] === "\n") i++;
2385
+ s = s.slice(i);
2386
+ }
2387
+ const closeAt = findClosingFence(s);
2388
+ if (closeAt !== -1) s = s.slice(0, closeAt);
2389
+ return s.trim();
2266
2390
  }
2267
- const PREVIEWABLE_KINDS = /* @__PURE__ */ new Set(["react", "html", "svg", "js", "css"]);
2268
- const BINARY_FILE_KINDS = /* @__PURE__ */ new Set(["pdf", "xlsx", "docx"]);
2269
- const artifactRenderer = {
2270
- type: "artifact",
2271
- render: (segment) => {
2272
- const kind = (segment.artifactType || "unknown").toLowerCase();
2273
- if (BINARY_FILE_KINDS.has(kind)) {
2274
- return renderBinaryFileArtifact(segment, kind);
2275
- }
2276
- const title = segment.title?.trim() || labelForKind(kind);
2277
- const displayLang = languageForKind(kind);
2278
- const isStreaming = !!segment.isStreaming;
2279
- const previewable = PREVIEWABLE_KINDS.has(kind);
2280
- const isBinary = BINARY_FILE_KINDS.has(kind);
2281
- const cleanContent = stripCodeFences(segment.content || "");
2282
- const initialTab = isStreaming || !previewable ? "code" : "preview";
2283
- const buildPreview = contextConfig().getArtifactPreviewBuilder() ?? buildSafePreviewDocument;
2284
- const previewSrcdoc = !isStreaming && previewable ? buildPreview(kind, cleanContent, title) : "";
2285
- return `
2286
- <div class="segment segment-artifact-card"
2287
- data-segment-id="${escapeHtml(segment.id)}"
2288
- data-artifact-type="${escapeHtml(kind)}"
2289
- data-streaming="${isStreaming ? "true" : "false"}"
2290
- data-tab="${initialTab}"
2291
- data-previewable="${previewable ? "true" : "false"}"
2292
- data-binary="${isBinary ? "true" : "false"}">
2293
- <header class="aparte-art-card__header">
2294
- <div class="aparte-art-card__title-block">
2295
- <span class="aparte-art-card__kind" data-kind="${escapeHtml(kind)}">${escapeHtml(displayLang)}</span>
2296
- <span class="aparte-art-card__title">${escapeHtml(title)}</span>
2297
- ${isStreaming ? '<span class="aparte-art-card__pulse" aria-label="Streaming"></span>' : ""}
2298
- </div>
2299
- <div class="aparte-art-card__actions">
2300
- <button type="button" class="aparte-art-card__btn" data-action="copy" title="${contextConfig().t("copy")}" aria-label="Copy">
2301
- ${contextConfig().getIcon("copy")}
2302
- </button>
2303
- <button type="button" class="aparte-art-card__btn" data-action="download" title="Download" aria-label="Download" ${isStreaming ? "disabled" : ""}>
2304
- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v9m0 0l-3-3m3 3l3-3M2 13h12"/></svg>
2305
- </button>
2306
- </div>
2307
- </header>
2308
- <nav class="aparte-art-card__tabs" role="tablist">
2309
- ${previewable ? `<button type="button" role="tab" data-tab-target="preview" ${initialTab === "preview" ? 'aria-selected="true"' : ""} ${isStreaming ? "disabled" : ""}>Preview</button>` : ""}
2310
- <button type="button" role="tab" data-tab-target="code" ${initialTab === "code" ? 'aria-selected="true"' : ""}>Code</button>
2311
- </nav>
2312
- <div class="aparte-art-card__body">
2313
- <div class="aparte-art-card__pane" data-pane="code">
2314
- <div class="code-content-wrapper">
2315
- <pre><code class="language-${escapeHtml(displayLang)}">${escapeHtml(cleanContent)}</code></pre>
2316
- </div>
2317
- </div>
2318
- ${previewable ? `
2319
- <div class="aparte-art-card__pane" data-pane="preview">
2320
- ${previewSrcdoc ? `<iframe class="aparte-art-card__frame" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" title="${escapeHtml(title)}" srcdoc="${escapeAttr(previewSrcdoc)}"></iframe>` : '<div class="aparte-art-card__pending">Generating preview…</div>'}
2321
- </div>
2322
- ` : ""}
2323
- </div>
2324
- </div>
2325
- `;
2326
- },
2327
- setup: (element, segment) => {
2328
- const kind = (segment.artifactType || "").toLowerCase();
2329
- if (BINARY_FILE_KINDS.has(kind)) {
2330
- setupBinaryFileArtifact(element, segment, kind);
2331
- return;
2332
- }
2333
- const wrapper = element.querySelector(".code-content-wrapper");
2334
- if (wrapper) {
2335
- const displayLang = languageForKind(kind);
2336
- const cleanContent = stripCodeFences(segment.content || "");
2337
- void contextConfig().highlightCode(cleanContent, displayLang).then((html) => {
2338
- wrapper.innerHTML = html;
2339
- }).catch(() => {
2340
- });
2391
+ function findClosingFence(s) {
2392
+ let i = 0;
2393
+ while (i < s.length) {
2394
+ const lineStart = i;
2395
+ let k = lineStart;
2396
+ while (k < s.length && (s[k] === " " || s[k] === " ")) k++;
2397
+ if (k < s.length && (s[k] === "`" || s[k] === "~")) {
2398
+ const fenceChar = s[k];
2399
+ let runs = 0;
2400
+ while (k < s.length && s[k] === fenceChar) {
2401
+ runs++;
2402
+ k++;
2403
+ }
2404
+ if (runs >= 3) {
2405
+ let onlyWs = true;
2406
+ while (k < s.length && s[k] !== "\n") {
2407
+ if (s[k] !== " " && s[k] !== " " && s[k] !== "\r") {
2408
+ onlyWs = false;
2409
+ break;
2410
+ }
2411
+ k++;
2412
+ }
2413
+ if (onlyWs) {
2414
+ let cut = lineStart;
2415
+ if (cut > 0 && s[cut - 1] === "\n") cut--;
2416
+ return cut;
2417
+ }
2418
+ }
2341
2419
  }
2342
- element.querySelectorAll("[data-tab-target]").forEach((btn) => {
2343
- btn.addEventListener("click", () => {
2344
- const target = btn.getAttribute("data-tab-target");
2345
- if (!target) return;
2346
- element.setAttribute("data-tab", target);
2347
- element.querySelectorAll("[data-tab-target]").forEach((b) => {
2348
- b.setAttribute("aria-selected", b === btn ? "true" : "false");
2349
- });
2350
- });
2351
- });
2352
- const copyBtn = element.querySelector('[data-action="copy"]');
2353
- if (copyBtn) {
2354
- copyBtn.addEventListener("click", () => {
2355
- const code = stripCodeFences(segment.content || "");
2356
- void navigator.clipboard.writeText(code).catch(() => {
2357
- });
2358
- const original = copyBtn.innerHTML;
2359
- copyBtn.innerHTML = contextConfig(copyBtn).getIcon("check");
2360
- copyBtn.setAttribute("title", contextConfig(copyBtn).t("copied"));
2361
- setTimeout(() => {
2362
- copyBtn.innerHTML = original;
2363
- copyBtn.setAttribute("title", contextConfig(copyBtn).t("copy"));
2364
- }, 1500);
2365
- });
2366
- }
2367
- const dlBtn = element.querySelector('[data-action="download"]');
2368
- if (dlBtn) {
2369
- dlBtn.addEventListener("click", () => {
2370
- if (dlBtn.disabled) return;
2371
- const kind2 = (segment.artifactType || "").toLowerCase();
2372
- const isBinary = BINARY_FILE_KINDS.has(kind2);
2373
- if (isBinary) {
2374
- element.dispatchEvent(new CustomEvent("aparte-artifact-redownload", {
2375
- bubbles: true,
2376
- composed: true,
2377
- detail: {
2378
- segmentId: segment.id,
2379
- mimeType: segment.mimeType,
2380
- artifactType: segment.artifactType,
2381
- title: segment.title,
2382
- content: stripCodeFences(segment.content || "")
2383
- }
2384
- }));
2385
- return;
2386
- }
2387
- downloadTextArtifact(segment);
2388
- });
2389
- }
2390
- },
2391
- update: (element, segment) => {
2392
- const isStreaming = !!segment.isStreaming;
2393
- const kind = (segment.artifactType || "").toLowerCase();
2394
- if (BINARY_FILE_KINDS.has(kind)) {
2395
- updateBinaryFileArtifact(element, segment, isStreaming);
2396
- return;
2397
- }
2398
- const previewable = PREVIEWABLE_KINDS.has(kind);
2399
- const wasStreaming = element.getAttribute("data-streaming") === "true";
2400
- const cleanContent = stripCodeFences(segment.content || "");
2401
- const codeEl = element.querySelector(".code-content-wrapper code");
2402
- if (codeEl) {
2403
- codeEl.textContent = cleanContent;
2404
- } else {
2405
- const wrapper = element.querySelector(".code-content-wrapper");
2406
- if (wrapper) {
2407
- const displayLang = languageForKind(kind);
2408
- wrapper.innerHTML = `<pre><code class="language-${escapeHtml(displayLang)}">${escapeHtml(cleanContent)}</code></pre>`;
2409
- }
2410
- }
2411
- if (isStreaming) {
2412
- const segId = element.getAttribute("data-segment-id") ?? segment.id;
2413
- debounceHighlight(element, ".code-content-wrapper", cleanContent, languageForKind(kind), segId);
2414
- }
2415
- if (wasStreaming && !isStreaming) {
2416
- element.setAttribute("data-streaming", "false");
2417
- const wrapper = element.querySelector(".code-content-wrapper");
2418
- if (wrapper) {
2419
- const displayLang = languageForKind(kind);
2420
- void contextConfig().highlightCode(cleanContent, displayLang).then((html) => {
2421
- wrapper.innerHTML = html;
2422
- }).catch(() => {
2423
- });
2424
- }
2425
- element.querySelectorAll("button[disabled]").forEach((b) => {
2426
- b.disabled = false;
2427
- });
2428
- if (previewable) {
2429
- const pane = element.querySelector('.aparte-art-card__pane[data-pane="preview"]');
2430
- if (pane) {
2431
- const title = segment.title?.trim() || labelForKind(kind);
2432
- const buildPreview = contextConfig().getArtifactPreviewBuilder() ?? buildSafePreviewDocument;
2433
- const srcdoc = buildPreview(kind, cleanContent, title);
2434
- pane.innerHTML = `<iframe class="aparte-art-card__frame" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" title="${escapeAttr(title)}" srcdoc="${escapeAttr(srcdoc)}"></iframe>`;
2435
- }
2436
- const currentTab = element.getAttribute("data-tab");
2437
- if (currentTab !== "code") {
2438
- element.setAttribute("data-tab", "preview");
2439
- element.querySelectorAll("[data-tab-target]").forEach((b) => {
2440
- b.setAttribute("aria-selected", b.getAttribute("data-tab-target") === "preview" ? "true" : "false");
2441
- });
2442
- }
2443
- }
2444
- }
2445
- },
2446
- getStyles: () => `
2447
- .segment-artifact-card {
2448
- display: flex; flex-direction: column;
2449
- margin: 8px 0;
2450
- border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
2451
- border-radius: 12px;
2452
- background: var(--aparte-surface, #fff);
2453
- overflow: hidden;
2454
- font: inherit;
2455
- color: var(--aparte-text, inherit);
2456
- }
2457
- .aparte-art-card__header {
2458
- display: flex; align-items: center; justify-content: space-between;
2459
- padding: 8px 10px;
2460
- border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
2461
- background: var(--aparte-surface-2, rgba(0,0,0,0.02));
2462
- min-height: 36px;
2463
- }
2464
- .aparte-art-card__title-block { display: flex; align-items: center; gap: 8px; min-width: 0; }
2465
- .aparte-art-card__kind {
2466
- font-size: 0.7rem; font-weight: 600;
2467
- text-transform: uppercase; letter-spacing: 0.04em;
2468
- padding: 2px 6px; border-radius: 4px;
2469
- background: var(--aparte-surface, #fff);
2470
- border: 1px solid var(--aparte-border, rgba(0,0,0,0.1));
2471
- color: var(--aparte-text-muted, rgba(0,0,0,0.6));
2472
- }
2473
- .aparte-art-card__title {
2474
- font-size: 0.85rem; font-weight: 500;
2475
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
2476
- }
2477
- .aparte-art-card__pulse {
2478
- width: 8px; height: 8px; border-radius: 50%;
2479
- background: var(--aparte-accent, #3b82f6);
2480
- animation: aparte-art-card-pulse 1.2s ease-in-out infinite;
2481
- }
2482
- @keyframes aparte-art-card-pulse {
2483
- 0%, 100% { opacity: 0.35; transform: scale(0.85); }
2484
- 50% { opacity: 1; transform: scale(1.1); }
2485
- }
2486
- .aparte-art-card__actions { display: flex; gap: 4px; }
2487
- .aparte-art-card__btn {
2488
- display: inline-flex; align-items: center; justify-content: center;
2489
- width: 28px; height: 28px;
2490
- background: transparent;
2491
- border: 1px solid transparent;
2492
- border-radius: 6px;
2493
- color: var(--aparte-text-muted, rgba(0,0,0,0.55));
2494
- cursor: pointer;
2495
- transition: background 0.12s, border-color 0.12s, color 0.12s;
2496
- }
2497
- .aparte-art-card__btn:hover:not(:disabled) {
2498
- background: var(--aparte-surface-hover, rgba(0,0,0,0.04));
2499
- border-color: var(--aparte-border, rgba(0,0,0,0.1));
2500
- color: var(--aparte-text, inherit);
2501
- }
2502
- .aparte-art-card__btn:disabled { opacity: 0.4; cursor: not-allowed; }
2503
- .aparte-art-card__tabs {
2504
- display: flex; gap: 2px;
2505
- padding: 4px 8px 0;
2506
- border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
2507
- background: var(--aparte-surface-2, rgba(0,0,0,0.02));
2508
- }
2509
- .aparte-art-card__tabs button {
2510
- padding: 6px 12px;
2511
- font-size: 0.78rem;
2512
- background: transparent;
2513
- border: 1px solid transparent;
2514
- border-bottom: none;
2515
- border-radius: 6px 6px 0 0;
2516
- color: var(--aparte-text-muted, rgba(0,0,0,0.6));
2517
- cursor: pointer;
2518
- transition: background 0.12s, color 0.12s;
2519
- }
2520
- .aparte-art-card__tabs button[aria-selected="true"] {
2521
- background: var(--aparte-surface, #fff);
2522
- border-color: var(--aparte-border, rgba(0,0,0,0.08));
2523
- color: var(--aparte-text, inherit);
2524
- font-weight: 500;
2525
- }
2526
- .aparte-art-card__tabs button:disabled { opacity: 0.4; cursor: not-allowed; }
2527
- .aparte-art-card__body {
2528
- position: relative;
2529
- min-height: 80px;
2530
- max-height: 600px;
2531
- overflow: hidden;
2532
- }
2533
- .aparte-art-card__pane { display: none; height: 100%; }
2534
- .segment-artifact-card[data-tab="code"] .aparte-art-card__pane[data-pane="code"] { display: block; }
2535
- .segment-artifact-card[data-tab="preview"] .aparte-art-card__pane[data-pane="preview"] { display: block; }
2536
- .aparte-art-card__pane[data-pane="code"] {
2537
- max-height: 600px; overflow: auto;
2538
- }
2539
- .aparte-art-card__pane[data-pane="code"] pre {
2540
- margin: 0; padding: 12px;
2541
- font-size: 0.82rem;
2542
- background: var(--aparte-code-bg, #fafafa);
2543
- }
2544
- .aparte-art-card__frame {
2545
- display: block;
2546
- width: 100%; height: 480px;
2547
- border: 0;
2548
- background: #fff;
2549
- }
2550
- .aparte-art-card__pending {
2551
- display: flex; align-items: center; justify-content: center;
2552
- height: 120px;
2553
- color: var(--aparte-text-muted, rgba(0,0,0,0.5));
2554
- font-size: 0.85rem;
2555
- font-style: italic;
2556
- }
2557
- /* ── Binary file artifact (xlsx/pdf/docx) ──────────────────── */
2558
- .segment-artifact-file {
2559
- display: flex; flex-direction: column;
2560
- margin: 8px 0;
2561
- border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
2562
- border-radius: 12px;
2563
- background: var(--aparte-surface, #fff);
2564
- overflow: hidden;
2565
- color: var(--aparte-text, inherit);
2566
- font: inherit;
2567
- }
2568
- .aparte-art-file__card {
2569
- display: flex; align-items: center; gap: 12px;
2570
- padding: 12px 14px;
2571
- background: var(--aparte-surface-2, rgba(0,0,0,0.02));
2572
- border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
2573
- }
2574
- .aparte-art-file__body {
2575
- position: relative;
2576
- }
2577
- .aparte-art-file__code-pane {
2578
- max-height: 360px; overflow: auto;
2579
- background: var(--aparte-code-bg, #fafafa);
2580
- }
2581
- .aparte-art-file__code-pane pre {
2582
- margin: 0; padding: 12px;
2583
- font-size: 0.82rem;
2584
- font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
2585
- }
2586
- .aparte-art-file__preview-pane {
2587
- max-height: 460px;
2588
- overflow: auto;
2589
- background: #fff;
2590
- /* Preview is a document view — force light scheme regardless of
2591
- the app theme, with a dark text colour so cells stay readable. */
2592
- color: #1f2937;
2593
- }
2594
- .aparte-art-file__icon {
2595
- width: 40px; height: 40px;
2596
- border-radius: 8px;
2597
- display: flex; align-items: center; justify-content: center;
2598
- background: linear-gradient(135deg, #1d6f42, #0f5132);
2599
- color: #fff;
2600
- font-weight: 700;
2601
- font-size: 0.78rem;
2602
- letter-spacing: 0.04em;
2603
- flex-shrink: 0;
2604
- }
2605
- .aparte-art-file__icon[data-kind="pdf"] { background: linear-gradient(135deg, #c0392b, #7d1f17); }
2606
- .aparte-art-file__icon[data-kind="docx"] { background: linear-gradient(135deg, #1e5288, #0f3060); }
2607
- .aparte-art-file__meta { flex: 1 1 auto; min-width: 0; }
2608
- .aparte-art-file__meta-name {
2609
- font-weight: 600; font-size: 0.92rem;
2610
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
2611
- }
2612
- .aparte-art-file__meta-sub {
2613
- font-size: 0.78rem;
2614
- color: var(--aparte-text-muted, rgba(0,0,0,0.55));
2615
- }
2616
- .aparte-art-file__actions { display: flex; gap: 6px; flex-shrink: 0; }
2617
- .aparte-art-file__btn {
2618
- border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
2619
- background: var(--aparte-surface, #fff);
2620
- color: var(--aparte-text, inherit);
2621
- padding: 6px 10px;
2622
- border-radius: 6px;
2623
- font-size: 0.8rem;
2624
- cursor: pointer;
2625
- transition: background 0.12s ease;
2626
- }
2627
- .aparte-art-file__btn:hover { background: var(--aparte-surface-2, rgba(0,0,0,0.05)); }
2628
- .aparte-art-file__btn--primary {
2629
- background: var(--aparte-primary, #6366f1);
2630
- color: #fff;
2631
- border-color: var(--aparte-primary, #6366f1);
2632
- }
2633
- .aparte-art-file__btn--primary:hover { filter: brightness(1.07); }
2634
- /* Hide the download button until the sandbox has produced the buffer.
2635
- Avoids confusing the user with a disabled-but-styled-primary button
2636
- during streaming / compiling. */
2637
- .segment-artifact-file:not([data-state="ready"]) .aparte-art-file__btn[data-action="download"] {
2638
- display: none;
2639
- }
2640
- .aparte-art-file__preview-pane table {
2641
- border-collapse: collapse;
2642
- width: 100%;
2643
- font-size: 0.82rem;
2644
- font-family: inherit;
2645
- }
2646
- .aparte-art-file__preview-pane th,
2647
- .aparte-art-file__preview-pane td {
2648
- border: 1px solid rgba(0,0,0,0.10);
2649
- padding: 6px 10px;
2650
- text-align: left;
2651
- white-space: nowrap;
2652
- color: #1f2937;
2653
- background: #fff;
2654
- }
2655
- .aparte-art-file__preview-pane tr:nth-child(odd) td { background: #f9fafb; }
2656
- .aparte-art-file__preview-pane tr:first-child td {
2657
- background: #f3f4f6;
2658
- font-weight: 600;
2659
- position: sticky; top: 0;
2660
- color: #111827;
2661
- }
2662
- .aparte-art-file__preview-empty {
2663
- padding: 20px; text-align: center;
2664
- color: var(--aparte-text-muted, rgba(0,0,0,0.5));
2665
- font-size: 0.85rem; font-style: italic;
2666
- }
2667
- .segment-artifact-file[data-state="error"] .aparte-art-file__icon {
2668
- background: linear-gradient(135deg, #c0392b, #7d1f17);
2669
- }
2670
- .aparte-art-file__error {
2671
- padding: 16px 18px;
2672
- background: rgba(239, 68, 68, 0.05);
2673
- border-top: 1px solid rgba(239, 68, 68, 0.2);
2674
- color: var(--aparte-text, inherit);
2675
- }
2676
- .aparte-art-file__error-title {
2677
- font-weight: 600;
2678
- font-size: 0.9rem;
2679
- margin-bottom: 6px;
2680
- color: #b91c1c;
2681
- }
2682
- .aparte-art-file__error-msg {
2683
- font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
2684
- font-size: 0.78rem;
2685
- padding: 6px 10px;
2686
- background: rgba(0,0,0,0.04);
2687
- border-radius: 4px;
2688
- margin-bottom: 8px;
2689
- color: #7f1d1d;
2690
- word-break: break-word;
2691
- }
2692
- .aparte-art-file__error-hint {
2693
- font-size: 0.78rem;
2694
- color: var(--aparte-text-muted, rgba(0,0,0,0.55));
2695
- font-style: italic;
2696
- }
2697
- `
2698
- };
2699
- const FILE_ICON_LABEL = {
2700
- xlsx: "XLS",
2701
- pdf: "PDF",
2702
- docx: "DOC"
2703
- };
2704
- const _binaryArtifactCache = /* @__PURE__ */ new Map();
2705
- const MAX_BINARY_ARTIFACTS = 24;
2706
- function cacheBinaryArtifact(id, entry) {
2707
- _binaryArtifactCache.delete(id);
2708
- if (_binaryArtifactCache.size >= MAX_BINARY_ARTIFACTS) {
2709
- const oldest = _binaryArtifactCache.keys().next().value;
2710
- if (oldest !== void 0) _binaryArtifactCache.delete(oldest);
2420
+ while (i < s.length && s[i] !== "\n") i++;
2421
+ if (i < s.length) i++;
2711
2422
  }
2712
- _binaryArtifactCache.set(id, entry);
2423
+ return -1;
2713
2424
  }
2714
- const _fileGenHandlers = /* @__PURE__ */ new Map();
2715
- const MAX_FILE_GEN_HANDLERS = 32;
2716
- const _lastDispatchAt = /* @__PURE__ */ new Map();
2717
- const RE_DISPATCH_DEBOUNCE_MS = 3e4;
2718
2425
  const _lastHighlightAt = /* @__PURE__ */ new Map();
2719
- const HIGHLIGHT_DEBOUNCE_MS = 400;
2720
- const MAX_THROTTLE_ENTRIES = 256;
2721
2426
  function markThrottle(map, id, at) {
2722
2427
  map.delete(id);
2723
2428
  if (map.size >= MAX_THROTTLE_ENTRIES) {
@@ -2737,7 +2442,137 @@ function debounceHighlight(element, paneSelector, content, lang, segId) {
2737
2442
  }).catch(() => {
2738
2443
  });
2739
2444
  }
2740
- if (typeof window !== "undefined") {
2445
+ function labelForKind(kind) {
2446
+ switch (kind) {
2447
+ case "react":
2448
+ return "React component";
2449
+ case "html":
2450
+ return "HTML document";
2451
+ case "svg":
2452
+ return "SVG image";
2453
+ case "js":
2454
+ return "JavaScript snippet";
2455
+ case "css":
2456
+ return "CSS stylesheet";
2457
+ case "json":
2458
+ return "JSON document";
2459
+ case "markdown":
2460
+ return "Markdown document";
2461
+ case "csv":
2462
+ return "CSV table";
2463
+ case "text":
2464
+ return "Text file";
2465
+ case "python":
2466
+ return "Python script";
2467
+ case "typescript":
2468
+ return "TypeScript file";
2469
+ case "bash":
2470
+ return "Bash script";
2471
+ case "sql":
2472
+ return "SQL query";
2473
+ case "pdf":
2474
+ return "PDF generator";
2475
+ case "xlsx":
2476
+ return "Excel generator";
2477
+ case "docx":
2478
+ return "Word generator";
2479
+ default:
2480
+ return "Artifact";
2481
+ }
2482
+ }
2483
+ const MAX_THROTTLE_ENTRIES = 256;
2484
+ const HIGHLIGHT_DEBOUNCE_MS = 400;
2485
+ const PREVIEW_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:";
2486
+ function withMetaCsp(doc) {
2487
+ const meta = `<meta http-equiv="Content-Security-Policy" content="${escapeAttr(PREVIEW_CSP)}">`;
2488
+ const doctype = doc.match(/^\s*<!doctype[^>]*>/i);
2489
+ const at = doctype ? doctype[0].length : 0;
2490
+ return `${doc.slice(0, at)}<head>${meta}</head>${doc.slice(at)}`;
2491
+ }
2492
+ function buildSafePreviewDocument(kind, body, title) {
2493
+ return withMetaCsp(buildPreviewBody(kind, body, title));
2494
+ }
2495
+ function buildPreviewBody(kind, body, title) {
2496
+ switch (kind) {
2497
+ case "html": {
2498
+ if (startsWithDoctype(body)) return body;
2499
+ return `<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>${escapeAttr(title)}</title></head><body>${body}</body></html>`;
2500
+ }
2501
+ case "svg":
2502
+ return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
2503
+ <style>html,body{margin:0;height:100%;display:flex;align-items:center;justify-content:center;background:#fff}svg{max-width:90%;max-height:90%}</style>
2504
+ </head><body>${body}</body></html>`;
2505
+ case "js": {
2506
+ const safeBody = escapeClosingScriptTag(body);
2507
+ return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
2508
+ <style>body{margin:0;font-family:ui-sans-serif,system-ui,sans-serif;padding:1rem;background:#fff;color:#0f172a}</style>
2509
+ </head><body><div id="root"></div><script>
2510
+ try { ${safeBody}
2511
+ } catch (e) { document.getElementById('root').innerHTML = '<pre style="color:#b91c1c">' + (e && e.stack || e) + '</pre>'; }
2512
+ <\/script></body></html>`;
2513
+ }
2514
+ case "css":
2515
+ return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
2516
+ <style>${body}</style></head><body>
2517
+ <div class="demo">
2518
+ <h1>Heading</h1>
2519
+ <p>Paragraph with a <a href="#">link</a> and <strong>strong</strong> text.</p>
2520
+ <button>Button</button>
2521
+ <input placeholder="Input"/>
2522
+ <ul><li>One</li><li>Two</li><li>Three</li></ul>
2523
+ </div></body></html>`;
2524
+ default:
2525
+ return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
2526
+ <style>body{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;padding:1rem;background:#fff;color:#0f172a}pre{white-space:pre-wrap;word-break:break-word;margin:0}</style>
2527
+ </head><body><pre>${escapeHtml(body)}</pre></body></html>`;
2528
+ }
2529
+ }
2530
+ function startsWithDoctype(s) {
2531
+ let i = 0;
2532
+ while (i < s.length && (s[i] === " " || s[i] === " " || s[i] === "\n" || s[i] === "\r")) i++;
2533
+ const probe = s.slice(i, i + 9).toLowerCase();
2534
+ return probe === "<!doctype";
2535
+ }
2536
+ function escapeClosingScriptTag(body) {
2537
+ let out = "";
2538
+ let i = 0;
2539
+ while (i < body.length) {
2540
+ if (body[i] === "<" && body.slice(i, i + 8).toLowerCase() === "<\/script") {
2541
+ const next = body[i + 8];
2542
+ if (next === void 0 || next === "/" || next === ">" || /\s/.test(next)) {
2543
+ out += "<\\/script";
2544
+ i += 8;
2545
+ continue;
2546
+ }
2547
+ }
2548
+ out += body[i];
2549
+ i++;
2550
+ }
2551
+ return out;
2552
+ }
2553
+ const FILE_ICON_LABEL = {
2554
+ xlsx: "XLS",
2555
+ pdf: "PDF",
2556
+ docx: "DOC"
2557
+ };
2558
+ const _binaryArtifactCache = /* @__PURE__ */ new Map();
2559
+ const MAX_BINARY_ARTIFACTS = 24;
2560
+ function cacheBinaryArtifact(id, entry) {
2561
+ _binaryArtifactCache.delete(id);
2562
+ if (_binaryArtifactCache.size >= MAX_BINARY_ARTIFACTS) {
2563
+ const oldest = _binaryArtifactCache.keys().next().value;
2564
+ if (oldest !== void 0) _binaryArtifactCache.delete(oldest);
2565
+ }
2566
+ _binaryArtifactCache.set(id, entry);
2567
+ }
2568
+ const _fileGenHandlers = /* @__PURE__ */ new Map();
2569
+ const MAX_FILE_GEN_HANDLERS = 32;
2570
+ const _lastDispatchAt = /* @__PURE__ */ new Map();
2571
+ const RE_DISPATCH_DEBOUNCE_MS = 3e4;
2572
+ let _artifactReadyHookInstalled = false;
2573
+ function installArtifactReadyHook() {
2574
+ if (_artifactReadyHookInstalled || typeof window === "undefined") return;
2575
+ _artifactReadyHookInstalled = true;
2741
2576
  window.addEventListener("aparte-artifact-ready", (event) => {
2742
2577
  const segId = event.detail?.segmentId;
2743
2578
  if (segId) markThrottle(_lastDispatchAt, segId, Date.now());
@@ -2865,6 +2700,7 @@ function setupBinaryFileArtifact(element, segment, kind) {
2865
2700
  );
2866
2701
  return;
2867
2702
  }
2703
+ if (!contextConfig().getHostHandlers().artifactRehydrate) return;
2868
2704
  const lastAt = _lastDispatchAt.get(segment.id) ?? 0;
2869
2705
  if (Date.now() - lastAt < RE_DISPATCH_DEBOUNCE_MS) return;
2870
2706
  markThrottle(_lastDispatchAt, segment.id, Date.now());
@@ -2905,92 +2741,473 @@ function updateBinaryFileArtifact(element, segment, isStreaming) {
2905
2741
  element.setAttribute("data-state", "compiling");
2906
2742
  const wrapper = element.querySelector('[data-role="code-pane"]');
2907
2743
  if (wrapper) {
2908
- void contextConfig(element).highlightCode(cleanContent, "js").then((html) => {
2744
+ void contextConfig(element).highlightCode(cleanContent, "js").then((html) => {
2745
+ wrapper.innerHTML = html;
2746
+ }).catch(() => {
2747
+ });
2748
+ }
2749
+ const sub = element.querySelector('[data-role="file-sub"]');
2750
+ if (sub) sub.textContent = "Running sandbox…";
2751
+ }
2752
+ }
2753
+ function swapToPreview(element, detail, kind) {
2754
+ element.setAttribute("data-state", "ready");
2755
+ const codePane = element.querySelector('[data-role="code-pane"]');
2756
+ if (codePane) codePane.hidden = true;
2757
+ const preview = element.querySelector('[data-role="preview-pane"]');
2758
+ if (preview) {
2759
+ preview.innerHTML = detail.previewHtml ? contextConfig().sanitizeHtml(detail.previewHtml) : `<div class="aparte-art-file__preview-empty">Preview not available for ${escapeHtml(kind)} yet</div>`;
2760
+ preview.hidden = false;
2761
+ }
2762
+ const nameEl = element.querySelector('[data-role="file-name"]');
2763
+ if (nameEl) nameEl.textContent = detail.filename;
2764
+ const sub = element.querySelector('[data-role="file-sub"]');
2765
+ if (sub) sub.textContent = `${formatBytes(detail.bytes)} · ${kind.toUpperCase()}`;
2766
+ const dlBtn = element.querySelector('[data-action="download"]');
2767
+ if (dlBtn) dlBtn.disabled = false;
2768
+ }
2769
+ function formatBytes(n) {
2770
+ if (n < 1024) return `${n} B`;
2771
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
2772
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
2773
+ }
2774
+ function showSandboxError(element, phase, errorMsg) {
2775
+ const state = element.getAttribute("data-state");
2776
+ if (state === "ready") return;
2777
+ element.setAttribute("data-state", "error");
2778
+ const sub = element.querySelector('[data-role="file-sub"]');
2779
+ if (sub) sub.textContent = `Error (${phase})`;
2780
+ const body = element.querySelector(".aparte-art-file__body");
2781
+ if (body) {
2782
+ const short = (errorMsg.split("\n")[0] ?? "").slice(0, 240);
2783
+ body.innerHTML = `
2784
+ <div class="aparte-art-file__error">
2785
+ <div class="aparte-art-file__error-title">The sandbox failed during generation.</div>
2786
+ <div class="aparte-art-file__error-msg">${escapeHtml(short)}</div>
2787
+ <div class="aparte-art-file__error-hint">Common cause: the model produced invalid code (undefined variable, wrong argument type). Retry the request — the model may produce different code.</div>
2788
+ </div>
2789
+ `;
2790
+ }
2791
+ }
2792
+ const PREVIEWABLE_KINDS = /* @__PURE__ */ new Set(["react", "html", "svg", "js", "css"]);
2793
+ const BINARY_FILE_KINDS = /* @__PURE__ */ new Set(["pdf", "xlsx", "docx"]);
2794
+ const artifactRenderer = {
2795
+ type: "artifact",
2796
+ render: (segment) => {
2797
+ const kind = (segment.artifactType || "unknown").toLowerCase();
2798
+ if (BINARY_FILE_KINDS.has(kind)) {
2799
+ return renderBinaryFileArtifact(segment, kind);
2800
+ }
2801
+ const title = segment.title?.trim() || labelForKind(kind);
2802
+ const displayLang = languageForKind(kind);
2803
+ const isStreaming = !!segment.isStreaming;
2804
+ const previewable = PREVIEWABLE_KINDS.has(kind);
2805
+ const isBinary = BINARY_FILE_KINDS.has(kind);
2806
+ const cleanContent = stripCodeFences(segment.content || "");
2807
+ return `
2808
+ <div class="segment segment-artifact-card"
2809
+ data-segment-id="${escapeHtml(segment.id)}"
2810
+ data-artifact-type="${escapeHtml(kind)}"
2811
+ data-streaming="${isStreaming ? "true" : "false"}"
2812
+ data-tab="code"
2813
+ data-previewable="${previewable ? "true" : "false"}"
2814
+ data-binary="${isBinary ? "true" : "false"}">
2815
+ <header class="aparte-art-card__header">
2816
+ <div class="aparte-art-card__title-block">
2817
+ <span class="aparte-art-card__kind" data-kind="${escapeHtml(kind)}">${escapeHtml(displayLang)}</span>
2818
+ <span class="aparte-art-card__title">${escapeHtml(title)}</span>
2819
+ ${isStreaming ? '<span class="aparte-art-card__pulse" aria-label="Streaming"></span>' : ""}
2820
+ </div>
2821
+ <div class="aparte-art-card__actions">
2822
+ <button type="button" class="aparte-art-card__btn" data-action="copy" title="${escapeAttr(contextConfig().t("copy"))}" aria-label="Copy">
2823
+ ${contextConfig().getIcon("copy")}
2824
+ </button>
2825
+ <button type="button" class="aparte-art-card__btn" data-action="download" title="Download" aria-label="Download" ${isStreaming ? "disabled" : ""}>
2826
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v9m0 0l-3-3m3 3l3-3M2 13h12"/></svg>
2827
+ </button>
2828
+ </div>
2829
+ </header>
2830
+ <nav class="aparte-art-card__tabs" role="tablist">
2831
+ ${previewable ? `<button type="button" role="tab" data-tab-target="preview" aria-selected="false" ${isStreaming ? "disabled" : ""}>Preview</button>` : ""}
2832
+ <button type="button" role="tab" data-tab-target="code" aria-selected="true">Code</button>
2833
+ </nav>
2834
+ <div class="aparte-art-card__body">
2835
+ <div class="aparte-art-card__pane" data-pane="code">
2836
+ <div class="code-content-wrapper">
2837
+ <pre><code class="language-${escapeHtml(displayLang)}">${escapeHtml(cleanContent)}</code></pre>
2838
+ </div>
2839
+ </div>
2840
+ ${previewable ? `
2841
+ <div class="aparte-art-card__pane" data-pane="preview">
2842
+ <div class="aparte-art-card__pending">Press Preview to run this artifact.</div>
2843
+ </div>
2844
+ ` : ""}
2845
+ </div>
2846
+ </div>
2847
+ `;
2848
+ },
2849
+ setup: (element, segment) => {
2850
+ latestSegment.set(element, segment);
2851
+ const kind = (segment.artifactType || "").toLowerCase();
2852
+ if (BINARY_FILE_KINDS.has(kind)) {
2853
+ setupBinaryFileArtifact(element, segment, kind);
2854
+ return;
2855
+ }
2856
+ const wrapper = element.querySelector(".code-content-wrapper");
2857
+ if (wrapper) {
2858
+ const displayLang = languageForKind(kind);
2859
+ const cleanContent = stripCodeFences(segment.content || "");
2860
+ void contextConfig().highlightCode(cleanContent, displayLang).then((html) => {
2909
2861
  wrapper.innerHTML = html;
2910
2862
  }).catch(() => {
2911
2863
  });
2912
2864
  }
2913
- const sub = element.querySelector('[data-role="file-sub"]');
2914
- if (sub) sub.textContent = "Running sandbox…";
2915
- }
2916
- }
2917
- function swapToPreview(element, detail, kind) {
2918
- element.setAttribute("data-state", "ready");
2919
- const codePane = element.querySelector('[data-role="code-pane"]');
2920
- if (codePane) codePane.hidden = true;
2921
- const preview = element.querySelector('[data-role="preview-pane"]');
2922
- if (preview) {
2923
- preview.innerHTML = detail.previewHtml ? contextConfig().sanitizeHtml(detail.previewHtml) : `<div class="aparte-art-file__preview-empty">Preview not available for ${escapeHtml(kind)} yet</div>`;
2924
- preview.hidden = false;
2925
- }
2926
- const nameEl = element.querySelector('[data-role="file-name"]');
2927
- if (nameEl) nameEl.textContent = detail.filename;
2928
- const sub = element.querySelector('[data-role="file-sub"]');
2929
- if (sub) sub.textContent = `${formatBytes(detail.bytes)} · ${kind.toUpperCase()}`;
2930
- const dlBtn = element.querySelector('[data-action="download"]');
2931
- if (dlBtn) dlBtn.disabled = false;
2932
- }
2933
- function formatBytes(n) {
2934
- if (n < 1024) return `${n} B`;
2935
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
2936
- return `${(n / (1024 * 1024)).toFixed(2)} MB`;
2937
- }
2938
- function showSandboxError(element, phase, errorMsg) {
2939
- const state = element.getAttribute("data-state");
2940
- if (state === "ready") return;
2941
- element.setAttribute("data-state", "error");
2942
- const sub = element.querySelector('[data-role="file-sub"]');
2943
- if (sub) sub.textContent = `Error (${phase})`;
2944
- const body = element.querySelector(".aparte-art-file__body");
2945
- if (body) {
2946
- const short = (errorMsg.split("\n")[0] ?? "").slice(0, 240);
2947
- body.innerHTML = `
2948
- <div class="aparte-art-file__error">
2949
- <div class="aparte-art-file__error-title">The sandbox failed during generation.</div>
2950
- <div class="aparte-art-file__error-msg">${escapeHtml(short)}</div>
2951
- <div class="aparte-art-file__error-hint">Common cause: the model produced invalid code (undefined variable, wrong argument type). Retry the request — the model may produce different code.</div>
2952
- </div>
2953
- `;
2954
- }
2955
- }
2956
- function labelForKind(kind) {
2957
- switch (kind) {
2958
- case "react":
2959
- return "React component";
2960
- case "html":
2961
- return "HTML document";
2962
- case "svg":
2963
- return "SVG image";
2964
- case "js":
2965
- return "JavaScript snippet";
2966
- case "css":
2967
- return "CSS stylesheet";
2968
- case "json":
2969
- return "JSON document";
2970
- case "markdown":
2971
- return "Markdown document";
2972
- case "csv":
2973
- return "CSV table";
2974
- case "text":
2975
- return "Text file";
2976
- case "python":
2977
- return "Python script";
2978
- case "typescript":
2979
- return "TypeScript file";
2980
- case "bash":
2981
- return "Bash script";
2982
- case "sql":
2983
- return "SQL query";
2984
- case "pdf":
2985
- return "PDF generator";
2986
- case "xlsx":
2987
- return "Excel generator";
2988
- case "docx":
2989
- return "Word generator";
2990
- default:
2991
- return "Artifact";
2992
- }
2993
- }
2865
+ element.querySelectorAll("[data-tab-target]").forEach((btn) => {
2866
+ btn.addEventListener("click", () => {
2867
+ const target = btn.getAttribute("data-tab-target");
2868
+ if (!target) return;
2869
+ if (target === "preview") mountPreviewFrame(element, segment);
2870
+ element.setAttribute("data-tab", target);
2871
+ element.querySelectorAll("[data-tab-target]").forEach((b) => {
2872
+ b.setAttribute("aria-selected", b === btn ? "true" : "false");
2873
+ });
2874
+ });
2875
+ });
2876
+ const copyBtn = element.querySelector('[data-action="copy"]');
2877
+ if (copyBtn) {
2878
+ copyBtn.addEventListener("click", () => {
2879
+ const code = stripCodeFences(segment.content || "");
2880
+ void navigator.clipboard.writeText(code).catch(() => {
2881
+ });
2882
+ const original = copyBtn.innerHTML;
2883
+ copyBtn.innerHTML = contextConfig(copyBtn).getIcon("check");
2884
+ copyBtn.setAttribute("title", contextConfig(copyBtn).t("copied"));
2885
+ setTimeout(() => {
2886
+ copyBtn.innerHTML = original;
2887
+ copyBtn.setAttribute("title", contextConfig(copyBtn).t("copy"));
2888
+ }, 1500);
2889
+ });
2890
+ }
2891
+ const dlBtn = element.querySelector('[data-action="download"]');
2892
+ if (dlBtn) {
2893
+ dlBtn.addEventListener("click", () => {
2894
+ if (dlBtn.disabled) return;
2895
+ const kind2 = (segment.artifactType || "").toLowerCase();
2896
+ const isBinary = BINARY_FILE_KINDS.has(kind2);
2897
+ if (isBinary) {
2898
+ element.dispatchEvent(new CustomEvent("aparte-artifact-redownload", {
2899
+ bubbles: true,
2900
+ composed: true,
2901
+ detail: {
2902
+ segmentId: segment.id,
2903
+ mimeType: segment.mimeType,
2904
+ artifactType: segment.artifactType,
2905
+ title: segment.title,
2906
+ content: stripCodeFences(segment.content || "")
2907
+ }
2908
+ }));
2909
+ return;
2910
+ }
2911
+ downloadTextArtifact(segment);
2912
+ });
2913
+ }
2914
+ },
2915
+ update: (element, segment) => {
2916
+ const previous = latestSegment.get(element);
2917
+ latestSegment.set(element, segment);
2918
+ if (previous && previous.content !== segment.content) {
2919
+ element.querySelector('.aparte-art-card__pane[data-pane="preview"] iframe')?.remove();
2920
+ }
2921
+ const isStreaming = !!segment.isStreaming;
2922
+ const kind = (segment.artifactType || "").toLowerCase();
2923
+ if (BINARY_FILE_KINDS.has(kind)) {
2924
+ updateBinaryFileArtifact(element, segment, isStreaming);
2925
+ return;
2926
+ }
2927
+ const wasStreaming = element.getAttribute("data-streaming") === "true";
2928
+ const cleanContent = stripCodeFences(segment.content || "");
2929
+ const codeEl = element.querySelector(".code-content-wrapper code");
2930
+ if (codeEl) {
2931
+ codeEl.textContent = cleanContent;
2932
+ } else {
2933
+ const wrapper = element.querySelector(".code-content-wrapper");
2934
+ if (wrapper) {
2935
+ const displayLang = languageForKind(kind);
2936
+ wrapper.innerHTML = `<pre><code class="language-${escapeHtml(displayLang)}">${escapeHtml(cleanContent)}</code></pre>`;
2937
+ }
2938
+ }
2939
+ if (isStreaming) {
2940
+ const segId = element.getAttribute("data-segment-id") ?? segment.id;
2941
+ debounceHighlight(element, ".code-content-wrapper", cleanContent, languageForKind(kind), segId);
2942
+ }
2943
+ if (wasStreaming && !isStreaming) {
2944
+ element.setAttribute("data-streaming", "false");
2945
+ const wrapper = element.querySelector(".code-content-wrapper");
2946
+ if (wrapper) {
2947
+ const displayLang = languageForKind(kind);
2948
+ void contextConfig().highlightCode(cleanContent, displayLang).then((html) => {
2949
+ wrapper.innerHTML = html;
2950
+ }).catch(() => {
2951
+ });
2952
+ }
2953
+ element.querySelectorAll("button[disabled]").forEach((b) => {
2954
+ b.disabled = false;
2955
+ });
2956
+ }
2957
+ },
2958
+ getStyles: () => `
2959
+ .segment-artifact-card {
2960
+ display: flex; flex-direction: column;
2961
+ margin: 8px 0;
2962
+ border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
2963
+ border-radius: 12px;
2964
+ background: var(--aparte-surface, #fff);
2965
+ overflow: hidden;
2966
+ font: inherit;
2967
+ color: var(--aparte-text, inherit);
2968
+ }
2969
+ .aparte-art-card__header {
2970
+ display: flex; align-items: center; justify-content: space-between;
2971
+ padding: 8px 10px;
2972
+ border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
2973
+ background: var(--aparte-surface-2, rgba(0,0,0,0.02));
2974
+ min-height: 36px;
2975
+ }
2976
+ .aparte-art-card__title-block { display: flex; align-items: center; gap: 8px; min-width: 0; }
2977
+ .aparte-art-card__kind {
2978
+ font-size: 0.7rem; font-weight: 600;
2979
+ text-transform: uppercase; letter-spacing: 0.04em;
2980
+ padding: 2px 6px; border-radius: 4px;
2981
+ background: var(--aparte-surface, #fff);
2982
+ border: 1px solid var(--aparte-border, rgba(0,0,0,0.1));
2983
+ color: var(--aparte-text-muted, rgba(0,0,0,0.6));
2984
+ }
2985
+ .aparte-art-card__title {
2986
+ font-size: 0.85rem; font-weight: 500;
2987
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
2988
+ }
2989
+ .aparte-art-card__pulse {
2990
+ width: 8px; height: 8px; border-radius: 50%;
2991
+ background: var(--aparte-accent, #3b82f6);
2992
+ animation: aparte-art-card-pulse 1.2s ease-in-out infinite;
2993
+ }
2994
+ @keyframes aparte-art-card-pulse {
2995
+ 0%, 100% { opacity: 0.35; transform: scale(0.85); }
2996
+ 50% { opacity: 1; transform: scale(1.1); }
2997
+ }
2998
+ .aparte-art-card__actions { display: flex; gap: 4px; }
2999
+ .aparte-art-card__btn {
3000
+ display: inline-flex; align-items: center; justify-content: center;
3001
+ width: 28px; height: 28px;
3002
+ background: transparent;
3003
+ border: 1px solid transparent;
3004
+ border-radius: 6px;
3005
+ color: var(--aparte-text-muted, rgba(0,0,0,0.55));
3006
+ cursor: pointer;
3007
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
3008
+ }
3009
+ .aparte-art-card__btn:hover:not(:disabled) {
3010
+ background: var(--aparte-surface-hover, rgba(0,0,0,0.04));
3011
+ border-color: var(--aparte-border, rgba(0,0,0,0.1));
3012
+ color: var(--aparte-text, inherit);
3013
+ }
3014
+ .aparte-art-card__btn:disabled { opacity: 0.4; cursor: not-allowed; }
3015
+ .aparte-art-card__tabs {
3016
+ display: flex; gap: 2px;
3017
+ padding: 4px 8px 0;
3018
+ border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
3019
+ background: var(--aparte-surface-2, rgba(0,0,0,0.02));
3020
+ }
3021
+ .aparte-art-card__tabs button {
3022
+ padding: 6px 12px;
3023
+ font-size: 0.78rem;
3024
+ background: transparent;
3025
+ border: 1px solid transparent;
3026
+ border-bottom: none;
3027
+ border-radius: 6px 6px 0 0;
3028
+ color: var(--aparte-text-muted, rgba(0,0,0,0.6));
3029
+ cursor: pointer;
3030
+ transition: background 0.12s, color 0.12s;
3031
+ }
3032
+ .aparte-art-card__tabs button[aria-selected="true"] {
3033
+ background: var(--aparte-surface, #fff);
3034
+ border-color: var(--aparte-border, rgba(0,0,0,0.08));
3035
+ color: var(--aparte-text, inherit);
3036
+ font-weight: 500;
3037
+ }
3038
+ .aparte-art-card__tabs button:disabled { opacity: 0.4; cursor: not-allowed; }
3039
+ .aparte-art-card__body {
3040
+ position: relative;
3041
+ min-height: 80px;
3042
+ max-height: 600px;
3043
+ overflow: hidden;
3044
+ }
3045
+ .aparte-art-card__pane { display: none; height: 100%; }
3046
+ .segment-artifact-card[data-tab="code"] .aparte-art-card__pane[data-pane="code"] { display: block; }
3047
+ .segment-artifact-card[data-tab="preview"] .aparte-art-card__pane[data-pane="preview"] { display: block; }
3048
+ .aparte-art-card__pane[data-pane="code"] {
3049
+ max-height: 600px; overflow: auto;
3050
+ }
3051
+ .aparte-art-card__pane[data-pane="code"] pre {
3052
+ margin: 0; padding: 12px;
3053
+ font-size: 0.82rem;
3054
+ background: var(--aparte-code-bg, #fafafa);
3055
+ }
3056
+ .aparte-art-card__frame {
3057
+ display: block;
3058
+ width: 100%; height: 480px;
3059
+ border: 0;
3060
+ background: #fff;
3061
+ }
3062
+ .aparte-art-card__pending {
3063
+ display: flex; align-items: center; justify-content: center;
3064
+ height: 120px;
3065
+ color: var(--aparte-text-muted, rgba(0,0,0,0.5));
3066
+ font-size: 0.85rem;
3067
+ font-style: italic;
3068
+ }
3069
+ /* ── Binary file artifact (xlsx/pdf/docx) ──────────────────── */
3070
+ .segment-artifact-file {
3071
+ display: flex; flex-direction: column;
3072
+ margin: 8px 0;
3073
+ border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
3074
+ border-radius: 12px;
3075
+ background: var(--aparte-surface, #fff);
3076
+ overflow: hidden;
3077
+ color: var(--aparte-text, inherit);
3078
+ font: inherit;
3079
+ }
3080
+ .aparte-art-file__card {
3081
+ display: flex; align-items: center; gap: 12px;
3082
+ padding: 12px 14px;
3083
+ background: var(--aparte-surface-2, rgba(0,0,0,0.02));
3084
+ border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
3085
+ }
3086
+ .aparte-art-file__body {
3087
+ position: relative;
3088
+ }
3089
+ .aparte-art-file__code-pane {
3090
+ max-height: 360px; overflow: auto;
3091
+ background: var(--aparte-code-bg, #fafafa);
3092
+ }
3093
+ .aparte-art-file__code-pane pre {
3094
+ margin: 0; padding: 12px;
3095
+ font-size: 0.82rem;
3096
+ font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
3097
+ }
3098
+ .aparte-art-file__preview-pane {
3099
+ max-height: 460px;
3100
+ overflow: auto;
3101
+ background: #fff;
3102
+ /* Preview is a document view — force light scheme regardless of
3103
+ the app theme, with a dark text colour so cells stay readable. */
3104
+ color: #1f2937;
3105
+ }
3106
+ .aparte-art-file__icon {
3107
+ width: 40px; height: 40px;
3108
+ border-radius: 8px;
3109
+ display: flex; align-items: center; justify-content: center;
3110
+ background: linear-gradient(135deg, #1d6f42, #0f5132);
3111
+ color: #fff;
3112
+ font-weight: 700;
3113
+ font-size: 0.78rem;
3114
+ letter-spacing: 0.04em;
3115
+ flex-shrink: 0;
3116
+ }
3117
+ .aparte-art-file__icon[data-kind="pdf"] { background: linear-gradient(135deg, #c0392b, #7d1f17); }
3118
+ .aparte-art-file__icon[data-kind="docx"] { background: linear-gradient(135deg, #1e5288, #0f3060); }
3119
+ .aparte-art-file__meta { flex: 1 1 auto; min-width: 0; }
3120
+ .aparte-art-file__meta-name {
3121
+ font-weight: 600; font-size: 0.92rem;
3122
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
3123
+ }
3124
+ .aparte-art-file__meta-sub {
3125
+ font-size: 0.78rem;
3126
+ color: var(--aparte-text-muted, rgba(0,0,0,0.55));
3127
+ }
3128
+ .aparte-art-file__actions { display: flex; gap: 6px; flex-shrink: 0; }
3129
+ .aparte-art-file__btn {
3130
+ border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
3131
+ background: var(--aparte-surface, #fff);
3132
+ color: var(--aparte-text, inherit);
3133
+ padding: 6px 10px;
3134
+ border-radius: 6px;
3135
+ font-size: 0.8rem;
3136
+ cursor: pointer;
3137
+ transition: background 0.12s ease;
3138
+ }
3139
+ .aparte-art-file__btn:hover { background: var(--aparte-surface-2, rgba(0,0,0,0.05)); }
3140
+ .aparte-art-file__btn--primary {
3141
+ background: var(--aparte-primary, #6366f1);
3142
+ color: #fff;
3143
+ border-color: var(--aparte-primary, #6366f1);
3144
+ }
3145
+ .aparte-art-file__btn--primary:hover { filter: brightness(1.07); }
3146
+ /* Hide the download button until the sandbox has produced the buffer.
3147
+ Avoids confusing the user with a disabled-but-styled-primary button
3148
+ during streaming / compiling. */
3149
+ .segment-artifact-file:not([data-state="ready"]) .aparte-art-file__btn[data-action="download"] {
3150
+ display: none;
3151
+ }
3152
+ .aparte-art-file__preview-pane table {
3153
+ border-collapse: collapse;
3154
+ width: 100%;
3155
+ font-size: 0.82rem;
3156
+ font-family: inherit;
3157
+ }
3158
+ .aparte-art-file__preview-pane th,
3159
+ .aparte-art-file__preview-pane td {
3160
+ border: 1px solid rgba(0,0,0,0.10);
3161
+ padding: 6px 10px;
3162
+ text-align: left;
3163
+ white-space: nowrap;
3164
+ color: #1f2937;
3165
+ background: #fff;
3166
+ }
3167
+ .aparte-art-file__preview-pane tr:nth-child(odd) td { background: #f9fafb; }
3168
+ .aparte-art-file__preview-pane tr:first-child td {
3169
+ background: #f3f4f6;
3170
+ font-weight: 600;
3171
+ position: sticky; top: 0;
3172
+ color: #111827;
3173
+ }
3174
+ .aparte-art-file__preview-empty {
3175
+ padding: 20px; text-align: center;
3176
+ color: var(--aparte-text-muted, rgba(0,0,0,0.5));
3177
+ font-size: 0.85rem; font-style: italic;
3178
+ }
3179
+ .segment-artifact-file[data-state="error"] .aparte-art-file__icon {
3180
+ background: linear-gradient(135deg, #c0392b, #7d1f17);
3181
+ }
3182
+ .aparte-art-file__error {
3183
+ padding: 16px 18px;
3184
+ background: rgba(239, 68, 68, 0.05);
3185
+ border-top: 1px solid rgba(239, 68, 68, 0.2);
3186
+ color: var(--aparte-text, inherit);
3187
+ }
3188
+ .aparte-art-file__error-title {
3189
+ font-weight: 600;
3190
+ font-size: 0.9rem;
3191
+ margin-bottom: 6px;
3192
+ color: #b91c1c;
3193
+ }
3194
+ .aparte-art-file__error-msg {
3195
+ font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
3196
+ font-size: 0.78rem;
3197
+ padding: 6px 10px;
3198
+ background: rgba(0,0,0,0.04);
3199
+ border-radius: 4px;
3200
+ margin-bottom: 8px;
3201
+ color: #7f1d1d;
3202
+ word-break: break-word;
3203
+ }
3204
+ .aparte-art-file__error-hint {
3205
+ font-size: 0.78rem;
3206
+ color: var(--aparte-text-muted, rgba(0,0,0,0.55));
3207
+ font-style: italic;
3208
+ }
3209
+ `
3210
+ };
2994
3211
  function languageForKind(kind) {
2995
3212
  if (kind === "react") return "jsx";
2996
3213
  if (kind === "markdown") return "md";
@@ -3030,19 +3247,6 @@ function downloadTextArtifact(segment) {
3030
3247
  document.body.removeChild(a);
3031
3248
  setTimeout(() => URL.revokeObjectURL(url), 1e3);
3032
3249
  }
3033
- function escapeAttr(s) {
3034
- let out = "";
3035
- for (let i = 0; i < s.length; i++) {
3036
- const ch = s[i];
3037
- if (ch === "&") out += "&amp;";
3038
- else if (ch === '"') out += "&quot;";
3039
- else if (ch === "<") out += "&lt;";
3040
- else if (ch === ">") out += "&gt;";
3041
- else if (ch === "'") out += "&#039;";
3042
- else out += ch;
3043
- }
3044
- return out;
3045
- }
3046
3250
  function slugifyForFilename(text) {
3047
3251
  const lower = text.trim().toLowerCase();
3048
3252
  let out = "";
@@ -3063,63 +3267,86 @@ function slugifyForFilename(text) {
3063
3267
  if (out.endsWith("-")) out = out.slice(0, -1);
3064
3268
  return out;
3065
3269
  }
3066
- function buildSafePreviewDocument(kind, body, title) {
3067
- switch (kind) {
3068
- case "html": {
3069
- if (startsWithDoctype(body)) return body;
3070
- return `<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>${escapeAttr(title)}</title></head><body>${body}</body></html>`;
3071
- }
3072
- case "svg":
3073
- return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
3074
- <style>html,body{margin:0;height:100%;display:flex;align-items:center;justify-content:center;background:#fff}svg{max-width:90%;max-height:90%}</style>
3075
- </head><body>${body}</body></html>`;
3076
- case "js": {
3077
- const safeBody = escapeClosingScriptTag(body);
3078
- return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
3079
- <style>body{margin:0;font-family:ui-sans-serif,system-ui,sans-serif;padding:1rem;background:#fff;color:#0f172a}</style>
3080
- </head><body><div id="root"></div><script>
3081
- try { ${safeBody}
3082
- } catch (e) { document.getElementById('root').innerHTML = '<pre style="color:#b91c1c">' + (e && e.stack || e) + '</pre>'; }
3083
- <\/script></body></html>`;
3084
- }
3085
- case "css":
3086
- return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
3087
- <style>${body}</style></head><body>
3088
- <div class="demo">
3089
- <h1>Heading</h1>
3090
- <p>Paragraph with a <a href="#">link</a> and <strong>strong</strong> text.</p>
3091
- <button>Button</button>
3092
- <input placeholder="Input"/>
3093
- <ul><li>One</li><li>Two</li><li>Three</li></ul>
3094
- </div></body></html>`;
3095
- default:
3096
- return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
3097
- <style>body{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;padding:1rem;background:#fff;color:#0f172a}pre{white-space:pre-wrap;word-break:break-word;margin:0}</style>
3098
- </head><body><pre>${escapeHtml(body)}</pre></body></html>`;
3270
+ const latestSegment = /* @__PURE__ */ new WeakMap();
3271
+ function mountPreviewFrame(element, fallback) {
3272
+ const pane = element.querySelector('.aparte-art-card__pane[data-pane="preview"]');
3273
+ if (!pane || pane.querySelector("iframe")) return;
3274
+ const segment = latestSegment.get(element) ?? fallback;
3275
+ const kind = segment.artifactType || deriveArtifactKind(segment.mimeType ?? "", "text");
3276
+ if (!PREVIEWABLE_KINDS.has(kind)) return;
3277
+ const title = segment.title?.trim() || labelForKind(kind);
3278
+ const build = contextConfig().getArtifactPreviewBuilder() ?? buildSafePreviewDocument;
3279
+ const srcdoc = build(kind, stripCodeFences(segment.content || ""), title);
3280
+ pane.innerHTML = `<iframe class="aparte-art-card__frame" sandbox="allow-scripts" csp="${escapeAttr(PREVIEW_CSP)}" referrerpolicy="no-referrer" loading="lazy" title="${escapeAttr(title)}" srcdoc="${escapeAttr(srcdoc)}"></iframe>`;
3281
+ }
3282
+ const registries = /* @__PURE__ */ new WeakMap();
3283
+ function registryFor(config) {
3284
+ let reg = registries.get(config);
3285
+ if (!reg) {
3286
+ reg = { renderers: /* @__PURE__ */ new Map(), defaultsInstalled: false, defaultsDeclined: false };
3287
+ registries.set(config, reg);
3288
+ }
3289
+ return reg;
3290
+ }
3291
+ function effectiveRenderers(config) {
3292
+ const own = registryFor(config);
3293
+ if (config === aparteGlobalConfig || own.defaultsDeclined) return own.renderers;
3294
+ const merged = new Map(registryFor(aparteGlobalConfig).renderers);
3295
+ for (const [type, renderer] of own.renderers) merged.set(type, renderer);
3296
+ return merged;
3297
+ }
3298
+ let styleElement = null;
3299
+ function registerSegmentRenderer(renderer, config = contextConfig()) {
3300
+ registryFor(config).renderers.set(renderer.type, renderer);
3301
+ injectRendererStyles(config);
3302
+ }
3303
+ function declineDefaultRenderers(config = contextConfig()) {
3304
+ registryFor(config).defaultsDeclined = true;
3305
+ }
3306
+ function installDefaultRenderersOnce(config = contextConfig()) {
3307
+ const reg = registryFor(config);
3308
+ if (reg.defaultsInstalled || reg.defaultsDeclined) return;
3309
+ reg.defaultsInstalled = true;
3310
+ for (const renderer of DEFAULT_RENDERERS) {
3311
+ if (!reg.renderers.has(renderer.type)) reg.renderers.set(renderer.type, renderer);
3099
3312
  }
3313
+ injectRendererStyles(config);
3314
+ installArtifactReadyHook();
3100
3315
  }
3101
- function startsWithDoctype(s) {
3102
- let i = 0;
3103
- while (i < s.length && (s[i] === " " || s[i] === " " || s[i] === "\n" || s[i] === "\r")) i++;
3104
- const probe = s.slice(i, i + 9).toLowerCase();
3105
- return probe === "<!doctype";
3316
+ function unregisterSegmentRenderer(type, config = contextConfig()) {
3317
+ registryFor(config).renderers.delete(type);
3106
3318
  }
3107
- function escapeClosingScriptTag(body) {
3108
- let out = "";
3109
- let i = 0;
3110
- while (i < body.length) {
3111
- if (body[i] === "<" && body.slice(i, i + 8).toLowerCase() === "<\/script") {
3112
- const next = body[i + 8];
3113
- if (next === void 0 || next === "/" || next === ">" || /\s/.test(next)) {
3114
- out += "<\\/script";
3115
- i += 8;
3116
- continue;
3117
- }
3118
- }
3119
- out += body[i];
3120
- i++;
3319
+ function getSegmentRenderer(type, config = contextConfig()) {
3320
+ return effectiveRenderers(config).get(type);
3321
+ }
3322
+ function getAllRenderers(config = contextConfig()) {
3323
+ return Array.from(effectiveRenderers(config).values());
3324
+ }
3325
+ function collectRendererStyles(config = contextConfig()) {
3326
+ return Array.from(registryFor(config).renderers.values()).map((r) => r.getStyles?.() || "").filter(Boolean).join("\n");
3327
+ }
3328
+ const injectedRendererStyles = /* @__PURE__ */ new Set();
3329
+ function injectRendererStyles(config = contextConfig()) {
3330
+ if (typeof document === "undefined") return;
3331
+ if (!styleElement || !styleElement.isConnected) {
3332
+ styleElement = document.createElement("style");
3333
+ styleElement.id = "aparte-renderer-styles";
3334
+ document.head.appendChild(styleElement);
3335
+ styleElement.textContent = Array.from(injectedRendererStyles).join("\n");
3121
3336
  }
3122
- return out;
3337
+ let added = false;
3338
+ for (const renderer of registryFor(config).renderers.values()) {
3339
+ const css = renderer.getStyles?.();
3340
+ if (!css || injectedRendererStyles.has(css)) continue;
3341
+ injectedRendererStyles.add(css);
3342
+ added = true;
3343
+ }
3344
+ if (added) styleElement.textContent = Array.from(injectedRendererStyles).join("\n");
3345
+ }
3346
+ function registerDefaultRenderers(config = contextConfig()) {
3347
+ registryFor(config).defaultsInstalled = true;
3348
+ for (const renderer of DEFAULT_RENDERERS) registerSegmentRenderer(renderer, config);
3349
+ installArtifactReadyHook();
3123
3350
  }
3124
3351
  const DEFAULT_RENDERERS = [
3125
3352
  textRenderer,
@@ -3191,7 +3418,7 @@ function applyRetention(messages, tree, maxMessages) {
3191
3418
  };
3192
3419
  return { messages: trimmedFlat, tree: trimmedTree };
3193
3420
  }
3194
- class ConversationManager {
3421
+ class AparteConversationManager {
3195
3422
  _adapter;
3196
3423
  _conversations = [];
3197
3424
  _activeId = null;
@@ -3243,7 +3470,7 @@ class ConversationManager {
3243
3470
  async createNew(title = "New Chat") {
3244
3471
  const now = Date.now();
3245
3472
  const conv = {
3246
- id: crypto.randomUUID(),
3473
+ id: uuid(),
3247
3474
  title,
3248
3475
  createdAt: now,
3249
3476
  updatedAt: now,
@@ -3374,7 +3601,7 @@ class ConversationManager {
3374
3601
  try {
3375
3602
  l(snapshot);
3376
3603
  } catch (err) {
3377
- console.error("[ConversationManager] listener threw:", err);
3604
+ console.error("[AparteConversationManager] listener threw:", err);
3378
3605
  }
3379
3606
  });
3380
3607
  }
@@ -3384,12 +3611,9 @@ class ConversationManager {
3384
3611
  return text.trim() || "New Chat";
3385
3612
  }
3386
3613
  }
3387
- function attachmentId() {
3388
- return typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
3389
- }
3390
3614
  function filesToAttachments(files) {
3391
3615
  return files.map((file) => ({
3392
- id: attachmentId(),
3616
+ id: uuid(),
3393
3617
  name: file.name,
3394
3618
  type: file.type || "application/octet-stream",
3395
3619
  url: URL.createObjectURL(file),
@@ -3397,6 +3621,13 @@ function filesToAttachments(files) {
3397
3621
  blob: file
3398
3622
  }));
3399
3623
  }
3624
+ function revokeAttachmentUrls(attachments) {
3625
+ if (!attachments?.length) return;
3626
+ if (typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function") return;
3627
+ for (const attachment of attachments) {
3628
+ if (attachment.url?.startsWith("blob:")) URL.revokeObjectURL(attachment.url);
3629
+ }
3630
+ }
3400
3631
  class AparteConversationController {
3401
3632
  _binding;
3402
3633
  _options;
@@ -3428,7 +3659,30 @@ class AparteConversationController {
3428
3659
  * conversation lifecycle yet.
3429
3660
  */
3430
3661
  _manager() {
3431
- return this._options.manager ?? resolveConfig(this._binding.host).getConversationManager();
3662
+ if (this._options.manager) return this._options.manager;
3663
+ const scoped = resolveConfig(this._binding.host);
3664
+ const manager = scoped.getConversationManager();
3665
+ if (!manager) this._warnManagerOnTheWrongConfig(scoped);
3666
+ return manager;
3667
+ }
3668
+ /**
3669
+ * Degraded mode is legitimate — a host that never opted into the conversation
3670
+ * lifecycle has no manager and should not be nagged. Registering one on the
3671
+ * WRONG config is not: the four wrapper hooks used to write it to the global
3672
+ * singleton unconditionally, so `config` + persistence looked like it worked
3673
+ * (the optimistic UI still appends) and saved nothing at all.
3674
+ *
3675
+ * Warned once per controller, and only in the case that is unambiguously a
3676
+ * mistake: no manager here, but one on the global.
3677
+ */
3678
+ _warnedNoManager = false;
3679
+ _warnManagerOnTheWrongConfig(scoped) {
3680
+ if (this._warnedNoManager) return;
3681
+ if (scoped === aparteGlobalConfig || !aparteGlobalConfig.getConversationManager()) return;
3682
+ this._warnedNoManager = true;
3683
+ console.warn(
3684
+ "[aparte] This chat resolves its own config, but the conversation manager was registered on aparteGlobalConfig — so nothing is being persisted. Pass the same config to init(): `init(adapter, myConfig)`, using the config you gave the chat."
3685
+ );
3432
3686
  }
3433
3687
  /**
3434
3688
  * Attach event listeners to the binding host. Returns an unbind function.
@@ -3443,7 +3697,7 @@ class AparteConversationController {
3443
3697
  const files = evt.detail?.files;
3444
3698
  const attachments = files?.length ? filesToAttachments(files) : void 0;
3445
3699
  const userMsg = {
3446
- id: typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
3700
+ id: uuid(),
3447
3701
  role: "user",
3448
3702
  content,
3449
3703
  timestamp: evt.detail?.timestamp ?? Date.now(),
@@ -3510,8 +3764,12 @@ class AparteConversationController {
3510
3764
  return () => this.unbind();
3511
3765
  }
3512
3766
  /** Detach event listeners. */
3767
+ /** Release handle for the hydration-retry subscription (see setConversationId). */
3768
+ _stopHydrationRetry = null;
3513
3769
  unbind() {
3514
3770
  this._persistActive();
3771
+ this._stopHydrationRetry?.();
3772
+ this._stopHydrationRetry = null;
3515
3773
  const host = this._binding.host;
3516
3774
  if (this._onSendCapture) {
3517
3775
  host.removeEventListener("aparte-send", this._onSendCapture, { capture: true });
@@ -3589,8 +3847,10 @@ class AparteConversationController {
3589
3847
  const conv = manager?.conversations.find((c) => c.id === id);
3590
3848
  if (!manager || !conv) {
3591
3849
  if (manager && !manager.initialized) {
3850
+ this._stopHydrationRetry?.();
3592
3851
  const stop = manager.subscribe(() => {
3593
3852
  stop();
3853
+ this._stopHydrationRetry = null;
3594
3854
  if (manager.conversations.some((c) => c.id === id)) {
3595
3855
  void this.setConversationId(id);
3596
3856
  } else if (this._activeId === null) {
@@ -3599,6 +3859,7 @@ class AparteConversationController {
3599
3859
  this._binding.clearMessages();
3600
3860
  }
3601
3861
  });
3862
+ this._stopHydrationRetry = stop;
3602
3863
  return;
3603
3864
  }
3604
3865
  console.warn("[ConversationController] unknown id or no manager — clearing. manager:", !!manager, "conv:", !!conv);
@@ -3680,6 +3941,9 @@ class AparteConversationController {
3680
3941
  await manager.addMessage(convId, userMsg);
3681
3942
  }
3682
3943
  }
3944
+ function cssEscape(value) {
3945
+ return value.replace(/["\\]/g, "\\$&");
3946
+ }
3683
3947
  class AparteChatHost {
3684
3948
  binding;
3685
3949
  options;
@@ -3786,7 +4050,7 @@ class AparteChatHost {
3786
4050
  const convBinding = {
3787
4051
  hostId: this.binding.hostId,
3788
4052
  host,
3789
- // Controller-driven flushes mirror ConversationManager state, not
4053
+ // Controller-driven flushes mirror AparteConversationManager state, not
3790
4054
  // user intent — deliberately NOT emitted as `messagesChange` to
3791
4055
  // avoid a feedback loop with hosts using a two-way `messages` input.
3792
4056
  setMessages: (msgs) => {
@@ -4173,7 +4437,7 @@ class AparteChatHost {
4173
4437
  const vp = this.binding.viewport;
4174
4438
  if (!vp) return null;
4175
4439
  return vp.querySelector(
4176
- `aparte-chat-bubble[message-id="${id}"]`
4440
+ `aparte-chat-bubble[message-id="${cssEscape(id)}"]`
4177
4441
  );
4178
4442
  }
4179
4443
  _lastBubble() {
@@ -4257,31 +4521,34 @@ class AparteChatHost {
4257
4521
  });
4258
4522
  }
4259
4523
  }
4260
- function assertNever(value) {
4261
- throw new Error(`Unhandled union member: ${JSON.stringify(value)}`);
4262
- }
4263
4524
  function dispatchLifecycleEvent(target, name, detail) {
4264
- target.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
4525
+ const id = target.id || void 0;
4526
+ const stamped = detail && typeof detail === "object" ? { targetId: id, ...detail } : detail;
4527
+ target.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail: stamped }));
4265
4528
  }
4266
4529
  function dispatchArtifactLifecycle(target, messageId, segment, progress, isFinal) {
4267
4530
  const id = segment.id;
4268
4531
  const content = segment.content ?? "";
4269
- const seen = progress.get(id);
4270
- if (seen === void 0) {
4532
+ if (progress.get(id) === void 0) {
4271
4533
  target.dispatchEvent(new CustomEvent("aparte-artifact-start", {
4272
4534
  bubbles: true,
4273
4535
  composed: true,
4274
- detail: { messageId, segmentId: id, mimeType: segment.mimeType, artifactType: segment.artifactType, title: segment.title }
4536
+ detail: {
4537
+ messageId,
4538
+ segmentId: id,
4539
+ mimeType: segment.mimeType,
4540
+ artifactType: segment.artifactType,
4541
+ title: segment.title
4542
+ }
4275
4543
  }));
4276
4544
  progress.set(id, 0);
4277
4545
  }
4278
4546
  const lastLen = progress.get(id) ?? 0;
4279
4547
  if (content.length > lastLen) {
4280
- const chunk = content.slice(lastLen);
4281
4548
  target.dispatchEvent(new CustomEvent("aparte-artifact-delta", {
4282
4549
  bubbles: true,
4283
4550
  composed: true,
4284
- detail: { segmentId: id, chunk }
4551
+ detail: { segmentId: id, chunk: content.slice(lastLen) }
4285
4552
  }));
4286
4553
  progress.set(id, content.length);
4287
4554
  }
@@ -4289,10 +4556,187 @@ function dispatchArtifactLifecycle(target, messageId, segment, progress, isFinal
4289
4556
  target.dispatchEvent(new CustomEvent("aparte-artifact-ready", {
4290
4557
  bubbles: true,
4291
4558
  composed: true,
4292
- detail: { messageId, segmentId: id, mimeType: segment.mimeType, artifactType: segment.artifactType, title: segment.title, content }
4559
+ detail: {
4560
+ messageId,
4561
+ segmentId: id,
4562
+ mimeType: segment.mimeType,
4563
+ artifactType: segment.artifactType,
4564
+ title: segment.title,
4565
+ content
4566
+ }
4293
4567
  }));
4294
4568
  }
4295
4569
  }
4570
+ const INLINE_MAX_LINES = 15;
4571
+ const XML_OPEN_TAG = "<artifact";
4572
+ function partialXmlOpenTagLength(text) {
4573
+ const max = Math.min(text.length, XML_OPEN_TAG.length - 1);
4574
+ for (let k = max; k > 0; k--) {
4575
+ if (text.endsWith(XML_OPEN_TAG.slice(0, k))) return k;
4576
+ }
4577
+ return 0;
4578
+ }
4579
+ function feedXmlArtifactDelta(delta, xml, ctx) {
4580
+ const { targetElement, messageId, textParser, streamingSegmentIds, artifactProgress, artifactXmlHint } = ctx;
4581
+ let remaining = delta;
4582
+ const emitChatText = (text, syncActive = true) => {
4583
+ if (!text) return;
4584
+ const r = textParser.parse(text);
4585
+ for (const seg of r.segments) {
4586
+ if (!streamingSegmentIds.has(seg.id)) {
4587
+ targetElement.addSegment?.(seg);
4588
+ streamingSegmentIds.add(seg.id);
4589
+ } else if ("content" in seg) {
4590
+ targetElement.updateSegment?.(seg.id, { content: seg.content });
4591
+ }
4592
+ }
4593
+ const active = textParser.getState().activeSegment;
4594
+ if (syncActive && active) {
4595
+ if (!streamingSegmentIds.has(active.id)) {
4596
+ targetElement.addSegment?.(active);
4597
+ streamingSegmentIds.add(active.id);
4598
+ } else {
4599
+ targetElement.updateSegment?.(active.id, { content: active.content });
4600
+ }
4601
+ } else if (!r.segments.length && !active) ;
4602
+ };
4603
+ while (remaining.length > 0) {
4604
+ if (xml.state === "normal") {
4605
+ const tagStart = remaining.indexOf(XML_OPEN_TAG);
4606
+ if (tagStart === -1) {
4607
+ const held = partialXmlOpenTagLength(remaining);
4608
+ if (held > 0) {
4609
+ emitChatText(remaining.slice(0, remaining.length - held), false);
4610
+ xml.scanBuf = remaining.slice(remaining.length - held);
4611
+ xml.state = "scanning";
4612
+ } else {
4613
+ emitChatText(remaining);
4614
+ }
4615
+ remaining = "";
4616
+ } else {
4617
+ emitChatText(remaining.slice(0, tagStart), false);
4618
+ xml.scanBuf = "";
4619
+ remaining = remaining.slice(tagStart);
4620
+ xml.state = "scanning";
4621
+ }
4622
+ } else if (xml.state === "scanning") {
4623
+ xml.scanBuf += remaining;
4624
+ remaining = "";
4625
+ const cmp = Math.min(xml.scanBuf.length, XML_OPEN_TAG.length);
4626
+ if (xml.scanBuf.slice(0, cmp) !== XML_OPEN_TAG.slice(0, cmp)) {
4627
+ emitChatText(xml.scanBuf);
4628
+ xml.scanBuf = "";
4629
+ xml.state = "normal";
4630
+ continue;
4631
+ }
4632
+ const gtIdx = xml.scanBuf.indexOf(">");
4633
+ if (gtIdx !== -1) {
4634
+ const tag = xml.scanBuf.slice(0, gtIdx + 1);
4635
+ const mimeMatch = /mimeType=['"]([^'"]+)['"]/.exec(tag);
4636
+ const titleMatch = /title=['"]([^'"]+)['"]/.exec(tag);
4637
+ xml.mime = mimeMatch?.[1] ?? artifactXmlHint.mimeType;
4638
+ xml.title = titleMatch?.[1] ?? artifactXmlHint.kind;
4639
+ xml.kind = deriveArtifactKind(xml.mime, artifactXmlHint.kind);
4640
+ xml.segId = `artifact-xml-${uuid()}`;
4641
+ xml.content = "";
4642
+ const openSeg = {
4643
+ id: xml.segId,
4644
+ type: "artifact",
4645
+ mimeType: xml.mime,
4646
+ artifactType: xml.kind,
4647
+ title: xml.title,
4648
+ content: ""
4649
+ };
4650
+ targetElement.addSegment?.(openSeg);
4651
+ streamingSegmentIds.add(xml.segId);
4652
+ dispatchArtifactLifecycle(targetElement, messageId, openSeg, artifactProgress, false);
4653
+ xml.state = "in-artifact";
4654
+ remaining = xml.scanBuf.slice(gtIdx + 1);
4655
+ xml.scanBuf = "";
4656
+ }
4657
+ } else {
4658
+ const CLOSE = "</artifact>";
4659
+ const combined = xml.closeBuf + remaining;
4660
+ const closeIdx = combined.indexOf(CLOSE);
4661
+ if (closeIdx !== -1) {
4662
+ xml.content += combined.slice(0, closeIdx);
4663
+ const lineCount = xml.content.split("\n").length;
4664
+ const isInline = lineCount < INLINE_MAX_LINES;
4665
+ const finalSeg = {
4666
+ id: xml.segId,
4667
+ mimeType: xml.mime,
4668
+ artifactType: xml.kind,
4669
+ title: xml.title,
4670
+ content: xml.content
4671
+ };
4672
+ targetElement.updateSegment?.(xml.segId, { content: xml.content, inline: isInline });
4673
+ dispatchArtifactLifecycle(targetElement, messageId, finalSeg, artifactProgress, true);
4674
+ xml.state = "normal";
4675
+ xml.closeBuf = "";
4676
+ remaining = combined.slice(closeIdx + CLOSE.length);
4677
+ } else {
4678
+ const safeLen = Math.max(0, combined.length - CLOSE.length + 1);
4679
+ const safe = combined.slice(0, safeLen);
4680
+ xml.content += safe;
4681
+ xml.closeBuf = combined.slice(safeLen);
4682
+ remaining = "";
4683
+ if (xml.segId) {
4684
+ targetElement.updateSegment?.(xml.segId, { content: xml.content });
4685
+ dispatchArtifactLifecycle(targetElement, messageId, {
4686
+ id: xml.segId,
4687
+ mimeType: xml.mime,
4688
+ artifactType: xml.kind,
4689
+ title: xml.title,
4690
+ content: xml.content
4691
+ }, artifactProgress, false);
4692
+ }
4693
+ }
4694
+ }
4695
+ }
4696
+ }
4697
+ function finalizeXmlArtifact(xml, ctx) {
4698
+ const { targetElement, messageId, artifactProgress, artifactXmlHint } = ctx;
4699
+ if (xml.state === "scanning") {
4700
+ const held = xml.scanBuf;
4701
+ xml.scanBuf = "";
4702
+ xml.state = "normal";
4703
+ if (held) {
4704
+ const r = ctx.textParser.parse(held);
4705
+ for (const seg of r.segments) {
4706
+ if (!ctx.streamingSegmentIds.has(seg.id)) {
4707
+ targetElement.addSegment?.(seg);
4708
+ ctx.streamingSegmentIds.add(seg.id);
4709
+ } else if ("content" in seg) {
4710
+ targetElement.updateSegment?.(seg.id, { content: seg.content });
4711
+ }
4712
+ }
4713
+ const active = ctx.textParser.getState().activeSegment;
4714
+ if (active) {
4715
+ if (!ctx.streamingSegmentIds.has(active.id)) {
4716
+ targetElement.addSegment?.(active);
4717
+ ctx.streamingSegmentIds.add(active.id);
4718
+ } else {
4719
+ targetElement.updateSegment?.(active.id, { content: active.content });
4720
+ }
4721
+ }
4722
+ }
4723
+ return;
4724
+ }
4725
+ if (artifactXmlHint && xml.state === "in-artifact" && xml.segId) {
4726
+ xml.content += xml.closeBuf;
4727
+ const lineCount = xml.content.split("\n").length;
4728
+ const isInline = lineCount < INLINE_MAX_LINES;
4729
+ targetElement.updateSegment?.(xml.segId, { content: xml.content, inline: isInline });
4730
+ dispatchArtifactLifecycle(targetElement, messageId, {
4731
+ id: xml.segId,
4732
+ mimeType: xml.mime,
4733
+ artifactType: xml.kind,
4734
+ title: xml.title,
4735
+ content: xml.content
4736
+ }, artifactProgress, true);
4737
+ console.warn("[AparteClient] XML artifact finalized without closing tag — content may be partial.");
4738
+ }
4739
+ }
4296
4740
  function createStreamAdapter(opts) {
4297
4741
  const { target, config, messageId, artifactHint } = opts;
4298
4742
  let parser = new AparteStreamParser();
@@ -4322,7 +4766,7 @@ function createStreamAdapter(opts) {
4322
4766
  thinkingContent += e.delta;
4323
4767
  if (!thinkingId) {
4324
4768
  const seg = {
4325
- id: `think-${crypto.randomUUID()}`,
4769
+ id: `think-${uuid()}`,
4326
4770
  type: "thinking",
4327
4771
  content: thinkingContent,
4328
4772
  collapsed: true,
@@ -4347,12 +4791,10 @@ function createStreamAdapter(opts) {
4347
4791
  if (!streaming.has(segment.id)) {
4348
4792
  target.addSegment?.(segment);
4349
4793
  streaming.add(segment.id);
4794
+ } else if ("content" in segment) {
4795
+ target.updateSegment?.(segment.id, { content: segment.content });
4350
4796
  }
4351
4797
  }
4352
- if (r.segments.length === 0 && !parser.getState().activeSegment) {
4353
- if (target.typeName) target.typeName(e.delta);
4354
- else target.updateLastMessage?.(e.delta, { append: true });
4355
- }
4356
4798
  break;
4357
4799
  }
4358
4800
  const result = parser.parse(e.delta);
@@ -4394,10 +4836,7 @@ function createStreamAdapter(opts) {
4394
4836
  dispatchArtifactLifecycle(target, messageId, active, artifactProgress, false);
4395
4837
  }
4396
4838
  }
4397
- } else if (result.segments.length === 0) {
4398
- if (target.typeName) target.typeName(e.delta);
4399
- else target.updateLastMessage?.(e.delta, { append: true });
4400
- }
4839
+ } else if (result.segments.length === 0) ;
4401
4840
  break;
4402
4841
  }
4403
4842
  case "text-flush": {
@@ -4517,7 +4956,7 @@ function createStreamAdapter(opts) {
4517
4956
  case "turn-limit-exceeded":
4518
4957
  if (e.scope === "global") {
4519
4958
  target.addSegment?.({
4520
- id: `max-turns-${crypto.randomUUID()}`,
4959
+ id: `max-turns-${uuid()}`,
4521
4960
  type: "error",
4522
4961
  content: `Stopped after ${e.limit} tool calls to prevent an infinite loop.`,
4523
4962
  details: "MAX_TURNS_EXCEEDED"
@@ -4527,7 +4966,7 @@ function createStreamAdapter(opts) {
4527
4966
  }
4528
4967
  break;
4529
4968
  case "phase-advance":
4530
- target.addSegment?.({ id: `pw-${crypto.randomUUID()}`, type: "pipeline-waiting" });
4969
+ target.addSegment?.({ id: `pw-${uuid()}`, type: "pipeline-waiting" });
4531
4970
  break;
4532
4971
  case "run-aborted":
4533
4972
  dispatchLifecycleEvent(target, "aparte-message-aborted", { messageId });
@@ -4564,6 +5003,10 @@ function readableToAsyncIterable(stream, signal) {
4564
5003
  },
4565
5004
  async return() {
4566
5005
  signal.removeEventListener("abort", onAbort);
5006
+ try {
5007
+ await reader.cancel();
5008
+ } catch {
5009
+ }
4567
5010
  try {
4568
5011
  reader.releaseLock();
4569
5012
  } catch {
@@ -4574,7 +5017,20 @@ function readableToAsyncIterable(stream, signal) {
4574
5017
  }
4575
5018
  };
4576
5019
  }
4577
- const TOOL_HANDLER_TIMEOUT_MS = 5 * 60 * 1e3;
5020
+ const DEFAULT_TOOL_HANDLER_TIMEOUT_MS = 5 * 60 * 1e3;
5021
+ function withToolTimeout(work, controller, timeoutMs) {
5022
+ let timer;
5023
+ const timedOut = new Promise((_resolve, reject) => {
5024
+ timer = setTimeout(() => {
5025
+ controller.abort();
5026
+ reject(Object.assign(new Error(`tool handler exceeded ${timeoutMs}ms`), { name: "AbortError" }));
5027
+ }, timeoutMs);
5028
+ });
5029
+ return {
5030
+ raced: Promise.race([work(controller.signal), timedOut]),
5031
+ done: () => clearTimeout(timer)
5032
+ };
5033
+ }
4578
5034
  class AparteClient {
4579
5035
  _boundHandler = null;
4580
5036
  _boundAbortHandler = null;
@@ -4583,6 +5039,30 @@ class AparteClient {
4583
5039
  _boundEditHandler = null;
4584
5040
  _activeToolControllers = /* @__PURE__ */ new Set();
4585
5041
  _isAborted = false;
5042
+ /**
5043
+ * Does this window event belong to this client?
5044
+ *
5045
+ * One rule for all five handlers, because it used to be four near-copies and
5046
+ * one omission: `aparte-compact` had no guard at all, so in the two-client
5047
+ * layout the JSDoc documents, a single compact event made BOTH clients run —
5048
+ * two paid summarisation calls against whichever chat the DOM scan found
5049
+ * first, and a global reset that wiped the other conversation.
5050
+ *
5051
+ * A scoped client also answers only events ADDRESSED to it. The old guard let
5052
+ * an untargeted event through to every scoped client, which turned one
5053
+ * broadcast into an action on every chat on the page.
5054
+ */
5055
+ _isForThisInstance(e) {
5056
+ if (!e) return true;
5057
+ const detail = e.detail;
5058
+ const scope = this.options.scopeToTargetId;
5059
+ if (scope) return detail?.targetId === scope;
5060
+ if (this._config === aparteGlobalConfig) return true;
5061
+ const target = this._resolveTarget(detail?.targetId);
5062
+ if (!target) return true;
5063
+ const owner = resolveConfig(target);
5064
+ return owner === this._config || owner === aparteGlobalConfig;
5065
+ }
4586
5066
  /** Aborts the in-flight vendor/transport fetch when the user stops a stream. */
4587
5067
  _streamController = null;
4588
5068
  options;
@@ -4593,11 +5073,11 @@ class AparteClient {
4593
5073
  autoRegister: true,
4594
5074
  ...options
4595
5075
  };
4596
- this._config = options.config ?? AparteConfig;
5076
+ this._config = options.config ?? aparteGlobalConfig;
4597
5077
  if (this.options.autoRegister) {
4598
- registerDefaultRenderers();
5078
+ registerDefaultRenderers(this._config);
4599
5079
  } else {
4600
- declineDefaultRenderers();
5080
+ declineDefaultRenderers(this._config);
4601
5081
  }
4602
5082
  this._setupListeners();
4603
5083
  }
@@ -4610,10 +5090,7 @@ class AparteClient {
4610
5090
  this._boundHandler = (e) => {
4611
5091
  const event = e;
4612
5092
  if (event.type !== "aparte-send") return;
4613
- if (this.options.scopeToTargetId) {
4614
- const evtTargetId = event.detail?.targetId;
4615
- if (evtTargetId && evtTargetId !== this.options.scopeToTargetId) return;
4616
- }
5093
+ if (!this._isForThisInstance(event)) return;
4617
5094
  this._isAborted = false;
4618
5095
  for (const controller of this._activeToolControllers) {
4619
5096
  controller.abort();
@@ -4634,27 +5111,23 @@ class AparteClient {
4634
5111
  }
4635
5112
  if (!this._boundAbortHandler) {
4636
5113
  this._boundAbortHandler = (e) => {
4637
- if (this.options.scopeToTargetId) {
4638
- const evtTargetId = e?.detail?.targetId;
4639
- if (evtTargetId && evtTargetId !== this.options.scopeToTargetId) return;
4640
- }
5114
+ if (!this._isForThisInstance(e)) return;
4641
5115
  this.abort();
4642
5116
  };
4643
5117
  }
4644
5118
  window.addEventListener("aparte-abort", this._boundAbortHandler);
4645
5119
  if (!this._boundCompactHandler) {
4646
- this._boundCompactHandler = () => {
4647
- void this.compact();
5120
+ this._boundCompactHandler = (e) => {
5121
+ if (!this._isForThisInstance(e)) return;
5122
+ const detail = e.detail;
5123
+ void this.compact(detail?.targetId);
4648
5124
  };
4649
5125
  }
4650
5126
  window.addEventListener("aparte-compact", this._boundCompactHandler);
4651
5127
  if (!this._boundRetryHandler) {
4652
5128
  this._boundRetryHandler = (e) => {
4653
5129
  const evt = e;
4654
- if (this.options.scopeToTargetId) {
4655
- const evtTargetId = evt.detail?.targetId;
4656
- if (evtTargetId && evtTargetId !== this.options.scopeToTargetId) return;
4657
- }
5130
+ if (!this._isForThisInstance(evt)) return;
4658
5131
  void this._handleRetry(evt);
4659
5132
  };
4660
5133
  }
@@ -4662,10 +5135,7 @@ class AparteClient {
4662
5135
  if (!this._boundEditHandler) {
4663
5136
  this._boundEditHandler = (e) => {
4664
5137
  const evt = e;
4665
- if (this.options.scopeToTargetId) {
4666
- const evtTargetId = evt.detail?.targetId;
4667
- if (evtTargetId && evtTargetId !== this.options.scopeToTargetId) return;
4668
- }
5138
+ if (!this._isForThisInstance(evt)) return;
4669
5139
  void this._handleEdit(evt);
4670
5140
  };
4671
5141
  }
@@ -4675,6 +5145,7 @@ class AparteClient {
4675
5145
  * Stop listening.
4676
5146
  */
4677
5147
  stop() {
5148
+ this.abort();
4678
5149
  if (!this._boundHandler) return;
4679
5150
  window.removeEventListener("aparte-send", this._boundHandler);
4680
5151
  this._boundHandler = null;
@@ -4718,7 +5189,27 @@ class AparteClient {
4718
5189
  * approval to `{ approved: false }` — there is no timeout, since a human may
4719
5190
  * take any amount of time to decide.
4720
5191
  */
4721
- _awaitToolDecision(toolCallId, signal) {
5192
+ /**
5193
+ * Await the human's Approve/Reject for one tool call.
5194
+ *
5195
+ * `target` is not decoration: it is the scope of the consent. The listener sits
5196
+ * on `document` and used to accept any `aparte-tool-decision` whose
5197
+ * `detail.toolCallId` matched — and that id is the tool-call id the MODEL chose.
5198
+ * The built-in buttons dispatch with `bubbles: true, composed: true`, so on a
5199
+ * page with two chats a click aimed at one tool could satisfy the gate awaiting
5200
+ * a different tool in a different conversation. The consented action and the
5201
+ * executed action came apart, which is the entire failure mode an approval gate
5202
+ * exists to prevent — and the handler behind it is arbitrary consumer code.
5203
+ *
5204
+ * The check is DOM CONTAINMENT rather than a `targetId` string comparison. A
5205
+ * model can choose an id; it cannot choose where in the tree the click happened.
5206
+ * It also needs no change on the dispatch side, so a consumer's own Approve
5207
+ * button keeps working as long as it fires from inside its own chat.
5208
+ *
5209
+ * The request half of this handshake was hardened with `targetId` for exactly
5210
+ * this hazard. This is its sibling, and it was missed.
5211
+ */
5212
+ _awaitToolDecision(toolCallId, signal, target) {
4722
5213
  return new Promise((resolve) => {
4723
5214
  if (signal.aborted) {
4724
5215
  resolve({ approved: false });
@@ -4731,6 +5222,7 @@ class AparteClient {
4731
5222
  const onDecision = (e) => {
4732
5223
  const detail = e.detail;
4733
5224
  if (detail?.toolCallId !== toolCallId) return;
5225
+ if (target && e.target instanceof Node && e.target !== document && !target.contains(e.target)) return;
4734
5226
  cleanup();
4735
5227
  resolve({ approved: detail?.approved === true, payload: detail?.payload });
4736
5228
  };
@@ -4744,7 +5236,7 @@ class AparteClient {
4744
5236
  }
4745
5237
  /**
4746
5238
  * Resolve the auth for a provider: `options.keyResolver` takes precedence,
4747
- * then the AparteConfig key channel (`setKeyProvider`) so a key registered
5239
+ * then the aparteGlobalConfig key channel (`setKeyProvider`) so a key registered
4748
5240
  * there reaches the request. One key source on the happy path.
4749
5241
  */
4750
5242
  async _resolveAuth(providerId) {
@@ -4781,12 +5273,24 @@ class AparteClient {
4781
5273
  if (baseRequest.toolChoice === "none") {
4782
5274
  baseRequest = { ...baseRequest, tools: void 0 };
4783
5275
  }
4784
- this._isAborted = false;
4785
- this._dispatchLifecycleEvent(targetElement, "aparte-message-start", { messageId, role: "assistant" });
5276
+ if (this._isAborted) {
5277
+ dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5278
+ return;
5279
+ }
5280
+ dispatchLifecycleEvent(targetElement, "aparte-message-start", { messageId, role: "assistant" });
4786
5281
  try {
4787
5282
  const usage = await this._streamLoop(targetElement, messageId, provider, baseRequest, authConfig);
4788
- this._dispatchLifecycleEvent(targetElement, "aparte-message-done", { messageId, role: "assistant", usage });
5283
+ if (this._isAborted) {
5284
+ this._updateMessage(targetElement, messageId, { status: "completed" });
5285
+ } else {
5286
+ dispatchLifecycleEvent(targetElement, "aparte-message-done", { messageId, role: "assistant", usage });
5287
+ }
4789
5288
  } catch (error) {
5289
+ if (this._isAborted) {
5290
+ dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5291
+ this._updateMessage(targetElement, messageId, { status: "completed" });
5292
+ return;
5293
+ }
4790
5294
  const aparteError = AparteError.from(error, AparteErrorCode.UNKNOWN_ERROR);
4791
5295
  this._handleLifecycleError(targetElement, messageId, aparteError);
4792
5296
  }
@@ -4798,20 +5302,9 @@ class AparteClient {
4798
5302
  * Triggered programmatically or by dispatching `window.dispatchEvent(new CustomEvent('aparte-compact'))`.
4799
5303
  * Dispatches `aparte-compact-done` on window when complete, or `aparte-compact-error` on failure.
4800
5304
  */
4801
- async compact() {
4802
- let target = null;
4803
- if (this.options.targetResolver) {
4804
- target = this.options.targetResolver();
4805
- }
4806
- if (!target) {
4807
- const candidates = document.querySelectorAll("aparte-chat, [data-aparte-chat]");
4808
- for (const el2 of Array.from(candidates)) {
4809
- if (typeof el2.getMessages === "function") {
4810
- target = el2;
4811
- break;
4812
- }
4813
- }
4814
- }
5305
+ async compact(targetId) {
5306
+ let target = this._resolveTarget(targetId);
5307
+ if (target && typeof target.getMessages !== "function") target = null;
4815
5308
  if (!target) {
4816
5309
  window.dispatchEvent(new CustomEvent("aparte-compact-error", { detail: { error: "No aparte-chat target found" } }));
4817
5310
  return;
@@ -4860,7 +5353,14 @@ class AparteClient {
4860
5353
  modelId: config.defaultModel || "",
4861
5354
  stream: false
4862
5355
  };
4863
- const response = await this._config.getTransport().chat(provider, summarizeRequest, authConfig, { providerId: provider.id });
5356
+ const compactController = new AbortController();
5357
+ this._streamController = compactController;
5358
+ const response = await this._config.getTransport().chat(
5359
+ provider,
5360
+ summarizeRequest,
5361
+ authConfig,
5362
+ { providerId: provider.id, signal: compactController.signal }
5363
+ );
4864
5364
  let summary;
4865
5365
  if (typeof response === "string") {
4866
5366
  summary = response;
@@ -4878,10 +5378,12 @@ class AparteClient {
4878
5378
  if (!summary.trim()) {
4879
5379
  throw new Error("Empty summary returned by model");
4880
5380
  }
4881
- window.dispatchEvent(new CustomEvent("aparte-reset"));
5381
+ const clearAll = target.clearAll;
5382
+ if (typeof clearAll === "function") clearAll.call(target);
5383
+ else window.dispatchEvent(new CustomEvent("aparte-reset"));
4882
5384
  await new Promise((resolve) => setTimeout(resolve, 50));
4883
5385
  target.appendMessage?.({
4884
- id: crypto.randomUUID(),
5386
+ id: uuid(),
4885
5387
  role: "assistant",
4886
5388
  content: `📝 **Conversation summary**
4887
5389
 
@@ -4904,17 +5406,41 @@ ${summary}`,
4904
5406
  * using the same conversation history minus the retried reply.
4905
5407
  */
4906
5408
  /**
4907
- * Registered tools, gated by capability: only returned when the current model
4908
- * declares `function_calling` support (else `[]`). Single source for the gate so
4909
- * send / retry / edit can't drift — the drift is exactly what shipped `tools` on
4910
- * the initial send while retry/edit correctly omitted them.
5409
+ * The registered tools, unless the current model says it cannot call one.
5410
+ *
5411
+ * Single source for the gate, so send / retry / edit cannot drift — that drift
5412
+ * is what once shipped `tools` on the initial send while retry and edit omitted
5413
+ * them.
5414
+ *
5415
+ * The gate used to be "the model DECLARES function_calling", defaulting to
5416
+ * stripping — and that made the whole tool surface unreachable on the primary
5417
+ * documented path. Three things had to line up for a tool to be sent, and one
5418
+ * never did: the app sets a model id by hand (`setModelConfig`) or a selector
5419
+ * sets it from a fetched list, and in both cases the model's declared
5420
+ * capabilities are commonly unknown. `undefined` capabilities then read as "no
5421
+ * function calling", so `tools: []` went on the wire while `getTools()` held
5422
+ * the tool the app had explicitly registered. The model answered, correctly,
5423
+ * that it had no such tool, and nothing anywhere said why.
5424
+ *
5425
+ * So the question is now "did this model say it cannot", not "did it say it
5426
+ * can". Registering a tool is an explicit act by the app; silently dropping it
5427
+ * because a `/models` listing is terse is the library second-guessing the
5428
+ * developer. A model that declares its capabilities and omits function calling
5429
+ * is still honoured — that is a statement, and it is respected.
5430
+ *
5431
+ * Failure modes, both ways round: over-sending means a model that cannot use
5432
+ * tools does not call one, or its own endpoint rejects the array with an error
5433
+ * the developer can read. Under-sending was silent, total, and looked like a
5434
+ * lying model.
4911
5435
  */
4912
5436
  _toolsForCurrentModel() {
4913
- const supportsFunctionCalling = this._config.getCurrentModel()?.capabilities?.includes("function_calling") ?? false;
4914
- return supportsFunctionCalling ? this._config.getTools() : [];
5437
+ const capabilities = this._config.getCurrentModel()?.capabilities;
5438
+ const declinesTools = capabilities ? !capabilities.includes("function_calling") : false;
5439
+ return declinesTools ? [] : this._config.getTools();
4915
5440
  }
4916
5441
  async _handleRetry(event) {
4917
5442
  const { messageId, targetId } = event.detail ?? {};
5443
+ this._isAborted = false;
4918
5444
  if (!messageId) return;
4919
5445
  const targetElement = this._resolveTarget(targetId);
4920
5446
  if (!targetElement) {
@@ -4925,9 +5451,9 @@ ${summary}`,
4925
5451
  const retryIdx = allMessages.findIndex((m) => m.id === messageId);
4926
5452
  const retryMsg = retryIdx >= 0 ? allMessages[retryIdx] : void 0;
4927
5453
  const sliceEnd = retryMsg?.role === "user" ? retryIdx + 1 : retryIdx;
4928
- const historyMessages = retryIdx > 0 ? allMessages.slice(0, sliceEnd) : allMessages;
5454
+ const historyMessages = retryIdx >= 0 ? allMessages.slice(0, sliceEnd) : allMessages;
4929
5455
  const newMsg = {
4930
- id: crypto.randomUUID(),
5456
+ id: uuid(),
4931
5457
  role: "assistant",
4932
5458
  content: "",
4933
5459
  status: "pending",
@@ -4960,6 +5486,7 @@ ${summary}`,
4960
5486
  */
4961
5487
  async _handleEdit(event) {
4962
5488
  const { messageId, content: newContent, targetId } = event.detail ?? {};
5489
+ this._isAborted = false;
4963
5490
  if (!messageId || newContent === void 0) return;
4964
5491
  const targetElement = this._resolveTarget(targetId);
4965
5492
  if (!targetElement) {
@@ -4988,7 +5515,7 @@ ${summary}`,
4988
5515
  const provider = this._config.getAIProvider(providerId);
4989
5516
  if (!provider) return;
4990
5517
  const authConfig = await this._resolveAuth(providerId);
4991
- const newMessageId = crypto.randomUUID();
5518
+ const newMessageId = uuid();
4992
5519
  targetElement.appendMessage?.({
4993
5520
  id: newMessageId,
4994
5521
  role: "assistant",
@@ -5051,6 +5578,12 @@ ${summary}`,
5051
5578
  }
5052
5579
  async _handleSend(event) {
5053
5580
  const { content, modelId, providerId: explicitProviderId } = event.detail;
5581
+ if (this._config.getRequireModelSelection() && !this._config.hasSelectedModel() && !modelId) {
5582
+ console.warn(
5583
+ "[AparteClient] Send refused: requireModelSelection is on and no model is selected yet. The composer blocks this, but this send did not come from it — disable your own send affordances while `hasSelectedModel()` is false."
5584
+ );
5585
+ return;
5586
+ }
5054
5587
  let targetElement = null;
5055
5588
  const targetId = event.detail?.targetId;
5056
5589
  if (targetId) {
@@ -5093,7 +5626,7 @@ ${summary}`,
5093
5626
  console.warn("[AparteClient] ⚠️ No target element found with appendMessage support. Provide a targetResolver in AparteClientOptions or ensure the composer has a `target` attribute.");
5094
5627
  return;
5095
5628
  }
5096
- const messageId = crypto.randomUUID();
5629
+ const messageId = uuid();
5097
5630
  const config = this._config.getModelConfig();
5098
5631
  const providerId = explicitProviderId || config.defaultProvider;
5099
5632
  if (!providerId) {
@@ -5177,10 +5710,69 @@ ${summary}`,
5177
5710
  content: this._extractText(m)
5178
5711
  })).filter((m) => m.content.length > 0);
5179
5712
  }
5713
+ /**
5714
+ * What the model is told it said last turn — reconstructed from what was
5715
+ * RENDERED, not from the raw-text field.
5716
+ *
5717
+ * The order used to be the other way round, and it silently corrupted the
5718
+ * context on the most common opening in a coding chat: a reply starting with a
5719
+ * code fence was sent back as three backticks and nothing else. Three
5720
+ * correct-in-isolation decisions composed into it:
5721
+ *
5722
+ * 1. the parser withholds an ambiguous prefix (``` ``` ```, `` ` ``, `<`)
5723
+ * waiting for the next delta, and creates no active segment while it waits;
5724
+ * 2. so `_streamLoop` sees zero segments, concludes the parser produced
5725
+ * nothing, and appends the raw delta straight to `message.content`;
5726
+ * 3. and this method preferred `content`.
5727
+ *
5728
+ * The bubble hides its content element as soon as segments exist, so the UI was
5729
+ * perfect and only the NEXT request showed it. No unit test, no example and
5730
+ * no browser test could see it.
5731
+ *
5732
+ * `content` stays as the fallback, and that is not vestigial: a non-streaming
5733
+ * transport writes the whole reply there and creates no segments at all.
5734
+ */
5180
5735
  _extractText(message) {
5736
+ const rendered = this._segmentsToText(message.segments);
5737
+ if (rendered) return rendered;
5181
5738
  if (typeof message.content === "string" && message.content) return message.content;
5182
- if (!message.segments) return "";
5183
- return message.segments.filter((s) => s.type === "text" || s.type === "code").map((s) => s.content ?? "").join("\n").trim();
5739
+ return "";
5740
+ }
5741
+ /**
5742
+ * Segments → the text a model can read back.
5743
+ *
5744
+ * Fences and the language tag are kept. Without them the model re-reads its own
5745
+ * code as prose — which is how it starts explaining a snippet it believes it
5746
+ * wrote in English. Artifacts are included for the same reason: dropping them
5747
+ * made an artifact the model had just produced invisible on the very next turn,
5748
+ * so it could not be asked to change the thing it had built.
5749
+ *
5750
+ * `thinking` stays out on purpose — it is the model's own scratchpad, and most
5751
+ * APIs neither want it back nor bill for it kindly.
5752
+ */
5753
+ _segmentsToText(segments) {
5754
+ if (!segments?.length) return "";
5755
+ const fence = "```";
5756
+ const parts = [];
5757
+ for (const segment of segments) {
5758
+ const content = segment.content ?? "";
5759
+ if (segment.type === "text") {
5760
+ if (content) parts.push(content);
5761
+ } else if (segment.type === "code") {
5762
+ const lang = segment.language ?? "";
5763
+ parts.push(`${fence}${lang}
5764
+ ${content}
5765
+ ${fence}`);
5766
+ } else if (segment.type === "artifact") {
5767
+ const title = segment.title ?? "artifact";
5768
+ const kind = segment.artifactType ?? "";
5769
+ parts.push(`${fence}${kind}
5770
+ <!-- artifact: ${title} -->
5771
+ ${content}
5772
+ ${fence}`);
5773
+ }
5774
+ }
5775
+ return parts.join("\n").trim();
5184
5776
  }
5185
5777
  /**
5186
5778
  * Which of the send's pending files get inlined into the request: the
@@ -5238,135 +5830,6 @@ ${content}
5238
5830
  );
5239
5831
  return parts.filter((p) => p !== null);
5240
5832
  }
5241
- /**
5242
- * Stream loop: runs one provider.chat() call and repeats if a tool was called.
5243
- * Maintains a running messages array to inject tool_call / tool_result turns.
5244
- */
5245
- /**
5246
- * Feed one text delta to the Claude-style `<artifact>` XML streamer. Scans for
5247
- * `<artifact …>` / `</artifact>`, routing chat text through the text parser and
5248
- * artifact content into a dedicated artifact segment (handling tags split across
5249
- * deltas). Mutates `xml` in place. Extracted from _streamLoop.
5250
- */
5251
- _feedXmlArtifactDelta(delta, xml, ctx) {
5252
- const { targetElement, messageId, textParser, streamingSegmentIds, artifactProgress, artifactXmlHint } = ctx;
5253
- let remaining = delta;
5254
- while (remaining.length > 0) {
5255
- if (xml.state === "normal") {
5256
- const tagStart = remaining.indexOf("<artifact");
5257
- if (tagStart === -1) {
5258
- const r = textParser.parse(remaining);
5259
- for (const seg of r.segments) {
5260
- if (!streamingSegmentIds.has(seg.id)) {
5261
- targetElement.addSegment?.(seg);
5262
- streamingSegmentIds.add(seg.id);
5263
- } else if ("content" in seg) {
5264
- targetElement.updateSegment?.(seg.id, { content: seg.content });
5265
- }
5266
- }
5267
- const active = textParser.getState().activeSegment;
5268
- if (active) {
5269
- if (!streamingSegmentIds.has(active.id)) {
5270
- targetElement.addSegment?.(active);
5271
- streamingSegmentIds.add(active.id);
5272
- } else {
5273
- targetElement.updateSegment?.(active.id, { content: active.content });
5274
- }
5275
- } else if (!r.segments.length) {
5276
- if (targetElement.typeName) targetElement.typeName(remaining);
5277
- else targetElement.updateLastMessage?.(remaining, { append: true });
5278
- }
5279
- remaining = "";
5280
- } else {
5281
- const before = remaining.slice(0, tagStart);
5282
- if (before) {
5283
- const r = textParser.parse(before);
5284
- for (const seg of r.segments) {
5285
- if (!streamingSegmentIds.has(seg.id)) {
5286
- targetElement.addSegment?.(seg);
5287
- streamingSegmentIds.add(seg.id);
5288
- }
5289
- }
5290
- if (!r.segments.length && !textParser.getState().activeSegment) {
5291
- if (targetElement.typeName) targetElement.typeName(before);
5292
- else targetElement.updateLastMessage?.(before, { append: true });
5293
- }
5294
- }
5295
- xml.scanBuf = remaining.slice(tagStart);
5296
- remaining = "";
5297
- xml.state = "scanning";
5298
- }
5299
- } else if (xml.state === "scanning") {
5300
- xml.scanBuf += remaining;
5301
- remaining = "";
5302
- const gtIdx = xml.scanBuf.indexOf(">");
5303
- if (gtIdx !== -1) {
5304
- const tag = xml.scanBuf.slice(0, gtIdx + 1);
5305
- const mimeMatch = /mimeType=['"]([^'"]+)['"]/.exec(tag);
5306
- const titleMatch = /title=['"]([^'"]+)['"]/.exec(tag);
5307
- xml.mime = mimeMatch?.[1] ?? artifactXmlHint.mimeType;
5308
- xml.title = titleMatch?.[1] ?? artifactXmlHint.kind;
5309
- xml.kind = deriveArtifactKind(xml.mime, artifactXmlHint.kind);
5310
- xml.segId = `artifact-xml-${crypto.randomUUID()}`;
5311
- xml.content = "";
5312
- const openSeg = {
5313
- id: xml.segId,
5314
- type: "artifact",
5315
- mimeType: xml.mime,
5316
- artifactType: xml.kind,
5317
- title: xml.title,
5318
- content: ""
5319
- };
5320
- targetElement.addSegment?.(openSeg);
5321
- streamingSegmentIds.add(xml.segId);
5322
- this._dispatchArtifactLifecycle(targetElement, messageId, openSeg, artifactProgress, false);
5323
- xml.state = "in-artifact";
5324
- remaining = xml.scanBuf.slice(gtIdx + 1);
5325
- xml.scanBuf = "";
5326
- }
5327
- } else {
5328
- const CLOSE = "</artifact>";
5329
- const combined = xml.closeBuf + remaining;
5330
- const closeIdx = combined.indexOf(CLOSE);
5331
- if (closeIdx !== -1) {
5332
- xml.content += combined.slice(0, closeIdx);
5333
- const lineCount = xml.content.split("\n").length;
5334
- const isInline = lineCount < 15;
5335
- const finalSeg = {
5336
- id: xml.segId,
5337
- type: "artifact",
5338
- mimeType: xml.mime,
5339
- artifactType: xml.kind,
5340
- title: xml.title,
5341
- content: xml.content,
5342
- inline: isInline
5343
- };
5344
- targetElement.updateSegment?.(xml.segId, { content: xml.content, inline: isInline });
5345
- this._dispatchArtifactLifecycle(targetElement, messageId, finalSeg, artifactProgress, true);
5346
- xml.state = "normal";
5347
- xml.closeBuf = "";
5348
- remaining = combined.slice(closeIdx + CLOSE.length);
5349
- } else {
5350
- const safeLen = Math.max(0, combined.length - CLOSE.length + 1);
5351
- const safe = combined.slice(0, safeLen);
5352
- xml.content += safe;
5353
- xml.closeBuf = combined.slice(safeLen);
5354
- remaining = "";
5355
- if (xml.segId) {
5356
- targetElement.updateSegment?.(xml.segId, { content: xml.content });
5357
- this._dispatchArtifactLifecycle(targetElement, messageId, {
5358
- id: xml.segId,
5359
- type: "artifact",
5360
- mimeType: xml.mime,
5361
- artifactType: xml.kind,
5362
- title: xml.title,
5363
- content: xml.content
5364
- }, artifactProgress, false);
5365
- }
5366
- }
5367
- }
5368
- }
5369
- }
5370
5833
  /**
5371
5834
  * Turn-1 forced tool call. When `toolChoice = { name, input }`
5372
5835
  * (orchestrator-driven), execute the handler directly instead of consulting
@@ -5380,7 +5843,7 @@ ${content}
5380
5843
  if (!(turns === 1 && toolChoice && typeof toolChoice === "object" && toolChoice.input !== void 0)) {
5381
5844
  return { baseRequest, skip: false };
5382
5845
  }
5383
- const syntheticId = crypto.randomUUID();
5846
+ const syntheticId = uuid();
5384
5847
  const syntheticCall = { id: syntheticId, name: toolChoice.name, input: toolChoice.input };
5385
5848
  const toolSeg = {
5386
5849
  id: `tool-${syntheticId}`,
@@ -5403,9 +5866,16 @@ ${content}
5403
5866
  }
5404
5867
  const controller = new AbortController();
5405
5868
  this._activeToolControllers.add(controller);
5406
- const timeout = setTimeout(() => controller.abort(), TOOL_HANDLER_TIMEOUT_MS);
5869
+ const { raced, done: clearToolTimeout } = withToolTimeout(
5870
+ (sig) => handler(syntheticCall, sig, {
5871
+ target: targetElement,
5872
+ config: this._config
5873
+ }),
5874
+ controller,
5875
+ this.options.toolTimeoutMs ?? DEFAULT_TOOL_HANDLER_TIMEOUT_MS
5876
+ );
5407
5877
  try {
5408
- const result = await handler(syntheticCall, controller.signal);
5878
+ const result = await raced;
5409
5879
  targetElement.updateSegment?.(toolSeg.id, { status: "resolved", result: result.content });
5410
5880
  messages.push({ role: "tool_call", content: "", toolCalls: [syntheticCall] });
5411
5881
  messages.push({ role: "tool_result", content: result.content, toolCallId: syntheticId });
@@ -5417,7 +5887,7 @@ ${content}
5417
5887
  }
5418
5888
  throw err;
5419
5889
  } finally {
5420
- clearTimeout(timeout);
5890
+ clearToolTimeout();
5421
5891
  this._activeToolControllers.delete(controller);
5422
5892
  }
5423
5893
  }
@@ -5435,7 +5905,7 @@ ${content}
5435
5905
  if (event.name === "create_artifact") {
5436
5906
  const input = event.input;
5437
5907
  const mimeType = input.mimeType ?? "text/plain";
5438
- const kind = mimeType.includes("react") ? "react" : mimeType.includes("html") ? "html" : mimeType.includes("javascript") ? "js" : mimeType.includes("css") ? "css" : mimeType.includes("svg") ? "svg" : mimeType.includes("json") ? "json" : mimeType.includes("csv") ? "csv" : mimeType.includes("markdown") ? "markdown" : "text";
5908
+ const kind = deriveArtifactKind(mimeType, "text");
5439
5909
  const artifactSeg = {
5440
5910
  id: `artifact-${event.id}`,
5441
5911
  type: "artifact",
@@ -5445,7 +5915,7 @@ ${content}
5445
5915
  content: input.content ?? ""
5446
5916
  };
5447
5917
  targetElement.addSegment?.(artifactSeg);
5448
- this._dispatchArtifactLifecycle(targetElement, messageId, artifactSeg, artifactProgress, true);
5918
+ dispatchArtifactLifecycle(targetElement, messageId, artifactSeg, artifactProgress, true);
5449
5919
  messages.push({
5450
5920
  role: "tool_call",
5451
5921
  content: "",
@@ -5499,13 +5969,13 @@ ${content}
5499
5969
  const approvalController = new AbortController();
5500
5970
  this._activeToolControllers.add(approvalController);
5501
5971
  targetElement.updateSegment?.(toolSeg.id, { status: "awaiting-approval" });
5502
- targetElement.dispatchEvent?.(new CustomEvent("aparte-tool-approval-request", {
5503
- bubbles: true,
5504
- composed: true,
5505
- detail: { toolCallId: event.id, toolName: event.name, input: event.input }
5506
- }));
5972
+ dispatchLifecycleEvent(targetElement, "aparte-tool-approval-request", {
5973
+ toolCallId: event.id,
5974
+ toolName: event.name,
5975
+ input: event.input
5976
+ });
5507
5977
  let decision;
5508
- const resolveApproval = this.options.approvalResolver ?? ((id, sig) => this._awaitToolDecision(id, sig));
5978
+ const resolveApproval = this.options.approvalResolver ?? ((id, sig) => this._awaitToolDecision(id, sig, targetElement));
5509
5979
  try {
5510
5980
  decision = await resolveApproval(event.id, approvalController.signal);
5511
5981
  } finally {
@@ -5535,12 +6005,17 @@ ${content}
5535
6005
  }
5536
6006
  const controller = new AbortController();
5537
6007
  this._activeToolControllers.add(controller);
5538
- const timeout = setTimeout(() => controller.abort(), TOOL_HANDLER_TIMEOUT_MS);
5539
- try {
5540
- const result = await handler(
6008
+ const { raced, done: clearToolTimeout } = withToolTimeout(
6009
+ (sig) => handler(
5541
6010
  { id: event.id, name: event.name, input: effectiveInput },
5542
- controller.signal
5543
- );
6011
+ sig,
6012
+ { target: targetElement, config: this._config }
6013
+ ),
6014
+ controller,
6015
+ this.options.toolTimeoutMs ?? DEFAULT_TOOL_HANDLER_TIMEOUT_MS
6016
+ );
6017
+ try {
6018
+ const result = await raced;
5544
6019
  targetElement.updateSegment?.(toolSeg.id, { status: "resolved", result: result.content });
5545
6020
  const existingToolCallMsg = messages.find(
5546
6021
  (m) => m.role === "tool_call" && m.toolCalls?.some((tc) => tc.id === event.id)
@@ -5566,7 +6041,7 @@ ${content}
5566
6041
  throw err;
5567
6042
  }
5568
6043
  } finally {
5569
- clearTimeout(timeout);
6044
+ clearToolTimeout();
5570
6045
  this._activeToolControllers.delete(controller);
5571
6046
  }
5572
6047
  } else {
@@ -5576,7 +6051,12 @@ ${content}
5576
6051
  }
5577
6052
  return { continueLoop };
5578
6053
  }
6054
+ /**
6055
+ * Stream loop: runs one provider.chat() call and repeats if a tool was called.
6056
+ * Maintains a running messages array to inject tool_call / tool_result turns.
6057
+ */
5579
6058
  async _streamLoop(targetElement, messageId, provider, baseRequest, authConfig) {
6059
+ this._streamController?.abort();
5580
6060
  const streamController = new AbortController();
5581
6061
  this._streamController = streamController;
5582
6062
  if (this.options.streamRunner) {
@@ -5595,14 +6075,14 @@ ${content}
5595
6075
  }
5596
6076
  while (continueLoop) {
5597
6077
  if (this._isAborted) {
5598
- this._dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
6078
+ dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5599
6079
  break;
5600
6080
  }
5601
6081
  turns++;
5602
6082
  if (turns > globalMaxTurns) {
5603
6083
  console.warn(`[AparteClient] maxTurns (${globalMaxTurns}) exceeded — stopping loop.`);
5604
6084
  targetElement.addSegment?.({
5605
- id: `max-turns-${crypto.randomUUID()}`,
6085
+ id: `max-turns-${uuid()}`,
5606
6086
  type: "error",
5607
6087
  content: `Stopped after ${globalMaxTurns} tool calls to prevent an infinite loop.`,
5608
6088
  details: "MAX_TURNS_EXCEEDED"
@@ -5630,7 +6110,17 @@ ${content}
5630
6110
  const request = { ...baseRequest, messages: phaseMessages, _meta: phaseMeta };
5631
6111
  const response = await this._config.getTransport().chat(provider, request, authConfig, { providerId: provider.id, signal: streamController.signal });
5632
6112
  if (typeof response === "string") {
5633
- this._updateMessage(targetElement, messageId, { content: response, status: "completed" });
6113
+ const wholeParser = new AparteStreamParser();
6114
+ const parsed = [
6115
+ ...wholeParser.parse(response).segments,
6116
+ ...wholeParser.finalize()
6117
+ ];
6118
+ if (parsed.length > 0) {
6119
+ for (const segment of parsed) targetElement.addSegment?.(segment);
6120
+ this._updateMessage(targetElement, messageId, { status: "completed" });
6121
+ } else {
6122
+ this._updateMessage(targetElement, messageId, { content: response, status: "completed" });
6123
+ }
5634
6124
  return void 0;
5635
6125
  }
5636
6126
  const reader = response.getReader();
@@ -5646,7 +6136,7 @@ ${content}
5646
6136
  let rawSegId = null;
5647
6137
  let rawContent = "";
5648
6138
  if (artifactRawHint) {
5649
- rawSegId = `artifact-raw-${crypto.randomUUID()}`;
6139
+ rawSegId = `artifact-raw-${uuid()}`;
5650
6140
  const rawSeg = {
5651
6141
  id: rawSegId,
5652
6142
  type: "artifact",
@@ -5657,7 +6147,7 @@ ${content}
5657
6147
  };
5658
6148
  targetElement.addSegment?.(rawSeg);
5659
6149
  streamingSegmentIds.add(rawSegId);
5660
- this._dispatchArtifactLifecycle(targetElement, messageId, rawSeg, artifactProgress, false);
6150
+ dispatchArtifactLifecycle(targetElement, messageId, rawSeg, artifactProgress, false);
5661
6151
  }
5662
6152
  const artifactXmlHint = baseRequest._meta?.artifactXml;
5663
6153
  const xmlCtx = {
@@ -5672,25 +6162,32 @@ ${content}
5672
6162
  };
5673
6163
  let precedingText = "";
5674
6164
  const toolCallsThisTurn = [];
6165
+ const bailOnAbort = () => {
6166
+ if (!this._isAborted) return false;
6167
+ try {
6168
+ void reader.cancel();
6169
+ } catch {
6170
+ }
6171
+ dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
6172
+ continueLoop = false;
6173
+ return true;
6174
+ };
6175
+ let streamDrained = false;
5675
6176
  try {
5676
6177
  while (true) {
5677
- if (this._isAborted) {
5678
- try {
5679
- void reader.cancel();
5680
- } catch {
5681
- }
5682
- this._dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5683
- continueLoop = false;
6178
+ if (bailOnAbort()) break;
6179
+ const { done, value: event } = await reader.read();
6180
+ if (bailOnAbort()) break;
6181
+ if (done) {
6182
+ streamDrained = true;
5684
6183
  break;
5685
6184
  }
5686
- const { done, value: event } = await reader.read();
5687
- if (done) break;
5688
6185
  switch (event.type) {
5689
6186
  case "thinking": {
5690
6187
  thinkingContent += event.delta;
5691
6188
  if (!thinkingSegmentId) {
5692
6189
  const seg = {
5693
- id: `think-${crypto.randomUUID()}`,
6190
+ id: `think-${uuid()}`,
5694
6191
  type: "thinking",
5695
6192
  content: thinkingContent,
5696
6193
  collapsed: true,
@@ -5713,7 +6210,7 @@ ${content}
5713
6210
  if (artifactRawHint && rawSegId) {
5714
6211
  rawContent += event.delta;
5715
6212
  targetElement.updateSegment?.(rawSegId, { content: rawContent });
5716
- this._dispatchArtifactLifecycle(targetElement, messageId, {
6213
+ dispatchArtifactLifecycle(targetElement, messageId, {
5717
6214
  id: rawSegId,
5718
6215
  type: "artifact",
5719
6216
  mimeType: artifactRawHint.mimeType,
@@ -5724,7 +6221,7 @@ ${content}
5724
6221
  break;
5725
6222
  }
5726
6223
  if (artifactXmlHint) {
5727
- this._feedXmlArtifactDelta(event.delta, xmlCtx, {
6224
+ feedXmlArtifactDelta(event.delta, xmlCtx, {
5728
6225
  targetElement,
5729
6226
  messageId,
5730
6227
  textParser,
@@ -5756,7 +6253,7 @@ ${content}
5756
6253
  targetElement.updateSegment?.(segment.id, { content: segment.content });
5757
6254
  }
5758
6255
  if (segment.type === "artifact") {
5759
- this._dispatchArtifactLifecycle(targetElement, messageId, segment, artifactProgress, true);
6256
+ dispatchArtifactLifecycle(targetElement, messageId, segment, artifactProgress, true);
5760
6257
  }
5761
6258
  }
5762
6259
  const active = textParser.getState().activeSegment;
@@ -5765,17 +6262,15 @@ ${content}
5765
6262
  targetElement.addSegment?.(active);
5766
6263
  streamingSegmentIds.add(active.id);
5767
6264
  if (active.type === "artifact") {
5768
- this._dispatchArtifactLifecycle(targetElement, messageId, active, artifactProgress, false);
6265
+ dispatchArtifactLifecycle(targetElement, messageId, active, artifactProgress, false);
5769
6266
  }
5770
6267
  } else {
5771
6268
  targetElement.updateSegment?.(active.id, { content: active.content });
5772
6269
  if (active.type === "artifact") {
5773
- this._dispatchArtifactLifecycle(targetElement, messageId, active, artifactProgress, false);
6270
+ dispatchArtifactLifecycle(targetElement, messageId, active, artifactProgress, false);
5774
6271
  }
5775
6272
  }
5776
6273
  } else if (result.segments.length === 0) {
5777
- if (targetElement.typeName) targetElement.typeName(event.delta);
5778
- else targetElement.updateLastMessage?.(event.delta, { append: true });
5779
6274
  }
5780
6275
  break;
5781
6276
  }
@@ -5792,16 +6287,22 @@ ${content}
5792
6287
  case "done":
5793
6288
  if (event.usage) lastUsage = event.usage;
5794
6289
  break;
5795
- default:
5796
- assertNever(event);
6290
+ default: {
6291
+ const exhaustive = event;
6292
+ void exhaustive;
6293
+ this._warnUnknownStreamEvent(event);
6294
+ break;
6295
+ }
5797
6296
  }
6297
+ if (!continueLoop) break;
5798
6298
  }
6299
+ finalizeXmlArtifact(xmlCtx, { targetElement, messageId, artifactProgress, artifactXmlHint, textParser, streamingSegmentIds });
5799
6300
  const finals = textParser.finalize();
5800
6301
  if (artifactRawHint && rawSegId) {
5801
6302
  const lineCount = rawContent.split("\n").length;
5802
6303
  const isInline = lineCount < 15;
5803
6304
  targetElement.updateSegment?.(rawSegId, { content: rawContent, inline: isInline });
5804
- this._dispatchArtifactLifecycle(targetElement, messageId, {
6305
+ dispatchArtifactLifecycle(targetElement, messageId, {
5805
6306
  id: rawSegId,
5806
6307
  type: "artifact",
5807
6308
  mimeType: artifactRawHint.mimeType,
@@ -5811,22 +6312,6 @@ ${content}
5811
6312
  inline: isInline
5812
6313
  }, artifactProgress, true);
5813
6314
  }
5814
- if (artifactXmlHint && xmlCtx.state === "in-artifact" && xmlCtx.segId) {
5815
- xmlCtx.content += xmlCtx.closeBuf;
5816
- const lineCount = xmlCtx.content.split("\n").length;
5817
- const isInline = lineCount < 15;
5818
- targetElement.updateSegment?.(xmlCtx.segId, { content: xmlCtx.content, inline: isInline });
5819
- this._dispatchArtifactLifecycle(targetElement, messageId, {
5820
- id: xmlCtx.segId,
5821
- type: "artifact",
5822
- mimeType: xmlCtx.mime,
5823
- artifactType: xmlCtx.kind,
5824
- title: xmlCtx.title,
5825
- content: xmlCtx.content,
5826
- inline: isInline
5827
- }, artifactProgress, true);
5828
- console.warn("[AparteClient] XML artifact finalized without closing tag — content may be partial.");
5829
- }
5830
6315
  if (artifactHint && !artifactPromoted) {
5831
6316
  const codeIdx = finals.findIndex((s) => s.type === "code");
5832
6317
  if (codeIdx !== -1) {
@@ -5853,7 +6338,7 @@ ${content}
5853
6338
  targetElement.updateSegment?.(s.id, { content: s.content });
5854
6339
  }
5855
6340
  if (s.type === "artifact") {
5856
- this._dispatchArtifactLifecycle(targetElement, messageId, s, artifactProgress, true);
6341
+ dispatchArtifactLifecycle(targetElement, messageId, s, artifactProgress, true);
5857
6342
  }
5858
6343
  }
5859
6344
  if (toolCallsThisTurn.length === 0) {
@@ -5862,13 +6347,19 @@ ${content}
5862
6347
  messages.push({ role: "assistant", content: precedingText.trim() });
5863
6348
  }
5864
6349
  pipelineIndex++;
5865
- const pwId = `pw-${crypto.randomUUID()}`;
6350
+ const pwId = `pw-${uuid()}`;
5866
6351
  targetElement.addSegment?.({ id: pwId, type: "pipeline-waiting" });
5867
6352
  } else {
5868
6353
  continueLoop = false;
5869
6354
  }
5870
6355
  }
5871
6356
  } finally {
6357
+ if (!streamDrained) {
6358
+ try {
6359
+ await reader.cancel();
6360
+ } catch {
6361
+ }
6362
+ }
5872
6363
  reader.releaseLock();
5873
6364
  }
5874
6365
  }
@@ -5908,12 +6399,17 @@ ${content}
5908
6399
  const response = await this._config.getTransport().chat(provider, request, authConfig, { providerId: provider.id, signal });
5909
6400
  return typeof response === "string" ? response : readableToAsyncIterable(response, signal);
5910
6401
  };
5911
- const toolLookup = (name) => this._config.getToolHandler(name);
6402
+ const toolLookup = (name) => {
6403
+ const handler = this._config.getToolHandler(name);
6404
+ if (!handler) return void 0;
6405
+ const context = { target: targetElement, config: this._config };
6406
+ return (call, sig) => handler(call, sig, context);
6407
+ };
5912
6408
  const toolConfigLookup = (name) => {
5913
6409
  const tool = this._config.getTools().find((t) => t.name === name);
5914
6410
  return tool ? { maxTurns: tool.maxTurns, needsApproval: tool.needsApproval } : void 0;
5915
6411
  };
5916
- const approvalResolver = this.options.approvalResolver ?? ((id, sig) => this._awaitToolDecision(id, sig));
6412
+ const approvalResolver = this.options.approvalResolver ?? ((id, sig) => this._awaitToolDecision(id, sig, targetElement));
5917
6413
  const usage = await streamRunner({
5918
6414
  messageId,
5919
6415
  baseRequest,
@@ -5924,9 +6420,14 @@ ${content}
5924
6420
  emitter,
5925
6421
  signal,
5926
6422
  maxTurns: this.options.maxTurns,
6423
+ // Forwarded, because the option's own JSDoc promises it means one thing
6424
+ // whichever loop runs — and the fix that introduced it only wired the
6425
+ // inline path, so setting it was silently ignored here. Exactly the
6426
+ // asymmetry the option was added to remove.
6427
+ toolTimeoutMs: this.options.toolTimeoutMs,
5927
6428
  // Match the inline loop's id conventions: prefixed artifact ids, but a
5928
6429
  // BARE uuid for the synthetic tool (the adapter renders `tool-<id>`).
5929
- idGen: (prefix) => prefix === "synthetic-tool" ? crypto.randomUUID() : `${prefix}-${crypto.randomUUID()}`
6430
+ idGen: (prefix) => prefix === "synthetic-tool" ? uuid() : `${prefix}-${uuid()}`
5930
6431
  });
5931
6432
  return usage ?? void 0;
5932
6433
  }
@@ -5943,15 +6444,16 @@ ${content}
5943
6444
  */
5944
6445
  _handleLifecycleError(target, messageId, error) {
5945
6446
  const errorSegment = {
5946
- id: `error-${crypto.randomUUID()}`,
6447
+ id: `error-${uuid()}`,
5947
6448
  type: "error",
5948
6449
  content: error.message,
5949
6450
  details: error.code
5950
6451
  };
5951
6452
  if (target.updateMessage) {
6453
+ const rendered = target.getMessages?.()?.find((m) => m.id === messageId)?.segments ?? [];
5952
6454
  target.updateMessage(messageId, {
5953
6455
  status: "error",
5954
- segments: [errorSegment]
6456
+ segments: [...rendered, errorSegment]
5955
6457
  });
5956
6458
  } else if (target.appendMessage) {
5957
6459
  target.appendMessage({
@@ -5962,65 +6464,17 @@ ${content}
5962
6464
  segments: [errorSegment]
5963
6465
  });
5964
6466
  }
5965
- this._dispatchLifecycleEvent(target, "aparte-message-error", { messageId, error });
5966
- }
5967
- _dispatchLifecycleEvent(target, name, detail) {
5968
- target.dispatchEvent(new CustomEvent(name, {
5969
- bubbles: true,
5970
- composed: true,
5971
- // Tag every lifecycle event with the target's id so several chats on
5972
- // one page stay isolated — a composer reacts only to its own host's
5973
- // turn (id-less single-instance pages still broadcast).
5974
- detail: { targetId: target.id || void 0, ...detail }
5975
- }));
6467
+ dispatchLifecycleEvent(target, "aparte-message-error", { messageId, error });
5976
6468
  }
5977
- /**
5978
- * Dispatch the artifact lifecycle (`aparte-artifact-start` / `delta` / `ready`)
5979
- * on the host bubble element. Idempotent for `start` (fires once per segment id)
5980
- * and emits `delta` only when the body actually grew. `isFinal=true` fires `ready`.
5981
- */
5982
- _dispatchArtifactLifecycle(target, messageId, segment, progress, isFinal) {
5983
- const id = segment.id;
5984
- const content = segment.content ?? "";
5985
- const seen = progress.get(id);
5986
- if (seen === void 0) {
5987
- target.dispatchEvent(new CustomEvent("aparte-artifact-start", {
5988
- bubbles: true,
5989
- composed: true,
5990
- detail: {
5991
- messageId,
5992
- segmentId: id,
5993
- mimeType: segment.mimeType,
5994
- artifactType: segment.artifactType,
5995
- title: segment.title
5996
- }
5997
- }));
5998
- progress.set(id, 0);
5999
- }
6000
- const lastLen = progress.get(id) ?? 0;
6001
- if (content.length > lastLen) {
6002
- const chunk = content.slice(lastLen);
6003
- target.dispatchEvent(new CustomEvent("aparte-artifact-delta", {
6004
- bubbles: true,
6005
- composed: true,
6006
- detail: { segmentId: id, chunk }
6007
- }));
6008
- progress.set(id, content.length);
6009
- }
6010
- if (isFinal) {
6011
- target.dispatchEvent(new CustomEvent("aparte-artifact-ready", {
6012
- bubbles: true,
6013
- composed: true,
6014
- detail: {
6015
- messageId,
6016
- segmentId: id,
6017
- mimeType: segment.mimeType,
6018
- artifactType: segment.artifactType,
6019
- title: segment.title,
6020
- content
6021
- }
6022
- }));
6023
- }
6469
+ /** Warn once per client for an unrecognised stream event, then carry on. */
6470
+ _warnedUnknownEvents = /* @__PURE__ */ new Set();
6471
+ _warnUnknownStreamEvent(event) {
6472
+ const type = String(event?.type ?? "undefined");
6473
+ if (this._warnedUnknownEvents.has(type)) return;
6474
+ this._warnedUnknownEvents.add(type);
6475
+ console.warn(
6476
+ `[AparteClient] Ignoring unrecognised stream event "${type}". The reply is unaffected; this usually means the provider or its SDK emits a part this version of aparté does not map yet.`
6477
+ );
6024
6478
  }
6025
6479
  }
6026
6480
  function findHead(node) {
@@ -6028,7 +6482,7 @@ function findHead(node) {
6028
6482
  if ("current" in node) return node;
6029
6483
  return null;
6030
6484
  }
6031
- class MessageRepository {
6485
+ class AparteMessageRepository {
6032
6486
  _nodes = /* @__PURE__ */ new Map();
6033
6487
  _head = null;
6034
6488
  _root = { next: null, children: [] };
@@ -6095,7 +6549,7 @@ class MessageRepository {
6095
6549
  const existing = this._nodes.get(message.id);
6096
6550
  const parentNode = parentId ? this._nodes.get(parentId) : void 0;
6097
6551
  if (parentId !== null && parentNode === void 0) {
6098
- throw new Error(`MessageRepository: parent "${parentId}" not found.`);
6552
+ throw new Error(`AparteMessageRepository: parent "${parentId}" not found.`);
6099
6553
  }
6100
6554
  const parent = parentNode ?? null;
6101
6555
  if (existing) {
@@ -6260,7 +6714,7 @@ class MessageRepository {
6260
6714
  node.next = null;
6261
6715
  }
6262
6716
  }
6263
- const DEFAULT_UI_EVENTS = [
6717
+ const APARTE_DEFAULT_UI_EVENTS = [
6264
6718
  "aparte-send",
6265
6719
  "aparte-action",
6266
6720
  "aparte-retry",
@@ -6273,8 +6727,9 @@ function applyElementProps(el2, props, transformValue = (value) => value) {
6273
6727
  for (const [key, value] of Object.entries(props)) {
6274
6728
  if (key.startsWith("--")) {
6275
6729
  el2.style.setProperty(key, String(value));
6276
- } else if (key.startsWith("on") && typeof value === "function") ;
6277
- else if (value === null || value === void 0 || value === false) {
6730
+ } else if (key.startsWith("on")) {
6731
+ el2.removeAttribute(key);
6732
+ } else if (value === null || value === void 0 || value === false) {
6278
6733
  el2.removeAttribute(key);
6279
6734
  } else if (value === true) {
6280
6735
  el2.setAttribute(key, "");
@@ -6295,17 +6750,30 @@ function el(tag, className, text) {
6295
6750
  if (text != null) node.textContent = text;
6296
6751
  return node;
6297
6752
  }
6298
- function fieldHeader(parent, field) {
6299
- if (field.title) parent.appendChild(el("p", "aparte-elic-title", field.title));
6753
+ function fieldHeader(parent, field, skipTitle) {
6754
+ let titleId;
6755
+ if (field.title && field.title.trim() !== skipTitle?.trim()) {
6756
+ const title = el("p", "aparte-elic-title", field.title);
6757
+ titleId = `elic-title-${uuid()}`;
6758
+ title.id = titleId;
6759
+ parent.appendChild(title);
6760
+ }
6300
6761
  if (field.description) parent.appendChild(el("p", "aparte-elic-desc", field.description));
6762
+ return titleId;
6301
6763
  }
6302
- function buildEnumField(field, onChange) {
6764
+ function labelGroup(list, opts) {
6765
+ list.setAttribute("role", opts.multiple ? "group" : "radiogroup");
6766
+ if (opts.titleId) list.setAttribute("aria-labelledby", opts.titleId);
6767
+ else if (opts.fallbackLabel) list.setAttribute("aria-label", opts.fallbackLabel);
6768
+ }
6769
+ function buildEnumField(field, onChange, fallbackLabel) {
6303
6770
  const wrap = el("div", "aparte-elic-field aparte-elic-enum");
6304
- fieldHeader(wrap, field);
6771
+ const titleId = fieldHeader(wrap, field, fallbackLabel);
6305
6772
  const list = el("div", "aparte-elic-options");
6306
- const name = `elic-${crypto.randomUUID()}`;
6773
+ labelGroup(list, { multiple: field.multiple, titleId, fallbackLabel });
6774
+ const name = `elic-${uuid()}`;
6307
6775
  const type = field.multiple ? "checkbox" : "radio";
6308
- const allowOther = field.allowOther ?? true;
6776
+ const allowOther = field.allowOther ?? contextConfig().getElicitationOptions().allowOther;
6309
6777
  const defaults = new Set(Array.isArray(field.default) ? field.default : field.default != null ? [field.default] : []);
6310
6778
  const buildOption = (value, label, description, recommended) => {
6311
6779
  const row = el("label", "aparte-elic-option" + (recommended ? " aparte-elic-option--recommended" : ""));
@@ -6331,12 +6799,13 @@ function buildEnumField(field, onChange) {
6331
6799
  control.name = name;
6332
6800
  control.value = "__other__";
6333
6801
  const body = el("span", "aparte-elic-option-body");
6334
- body.appendChild(el("span", "aparte-elic-option-title", "Other…"));
6802
+ const t = contextConfig();
6803
+ body.appendChild(el("span", "aparte-elic-option-title", t.t("elicitationOther")));
6335
6804
  otherText = el("input", "aparte-elic-other-input");
6336
6805
  otherText.type = "text";
6337
- otherText.placeholder = "Type your answer…";
6806
+ otherText.placeholder = t.t("elicitationOtherPlaceholder");
6338
6807
  otherText.style.display = "none";
6339
- otherText.setAttribute("aria-label", "Custom answer");
6808
+ otherText.setAttribute("aria-label", t.t("elicitationOtherLabel"));
6340
6809
  body.appendChild(otherText);
6341
6810
  row.append(control, body);
6342
6811
  list.appendChild(row);
@@ -6374,11 +6843,12 @@ function buildEnumField(field, onChange) {
6374
6843
  focus: () => controls()[0]?.focus()
6375
6844
  };
6376
6845
  }
6377
- function buildBooleanField(field, onChange) {
6846
+ function buildBooleanField(field, onChange, fallbackLabel) {
6378
6847
  const wrap = el("div", "aparte-elic-field aparte-elic-boolean");
6379
- fieldHeader(wrap, field);
6848
+ const titleId = fieldHeader(wrap, field, fallbackLabel);
6380
6849
  const list = el("div", "aparte-elic-options");
6381
- const name = `elic-${crypto.randomUUID()}`;
6850
+ labelGroup(list, { titleId, fallbackLabel });
6851
+ const name = `elic-${uuid()}`;
6382
6852
  const mk = (val, label) => {
6383
6853
  const row = el("label", "aparte-elic-option");
6384
6854
  const control = el("input", "aparte-elic-control");
@@ -6391,8 +6861,9 @@ function buildBooleanField(field, onChange) {
6391
6861
  row.append(control, body);
6392
6862
  list.appendChild(row);
6393
6863
  };
6394
- mk("true", field.trueLabel ?? "Yes");
6395
- mk("false", field.falseLabel ?? "No");
6864
+ const t = contextConfig();
6865
+ mk("true", field.trueLabel ?? t.t("elicitationYes"));
6866
+ mk("false", field.falseLabel ?? t.t("elicitationNo"));
6396
6867
  wrap.appendChild(list);
6397
6868
  list.addEventListener("change", onChange);
6398
6869
  const checked = () => list.querySelector("input:checked");
@@ -6403,15 +6874,15 @@ function buildBooleanField(field, onChange) {
6403
6874
  focus: () => list.querySelector("input")?.focus()
6404
6875
  };
6405
6876
  }
6406
- function buildStringField(field, onChange) {
6877
+ function buildStringField(field, onChange, fallbackLabel) {
6407
6878
  const wrap = el("div", "aparte-elic-field aparte-elic-string");
6408
- fieldHeader(wrap, field);
6879
+ fieldHeader(wrap, field, fallbackLabel);
6409
6880
  const input = field.multiline ? el("textarea", "aparte-elic-text") : el("input", "aparte-elic-text");
6410
6881
  if (!field.multiline) input.type = "text";
6411
6882
  if (field.placeholder) input.setAttribute("placeholder", field.placeholder);
6412
6883
  if (field.default) input.value = field.default;
6413
6884
  if (field.maxLength != null) input.setAttribute("maxlength", String(field.maxLength));
6414
- input.setAttribute("aria-label", field.title ?? field.description ?? "Your answer");
6885
+ input.setAttribute("aria-label", field.title ?? field.description ?? fallbackLabel ?? contextConfig().t("elicitationAnswerLabel"));
6415
6886
  wrap.appendChild(input);
6416
6887
  input.addEventListener("input", onChange);
6417
6888
  const required = field.required ?? true;
@@ -6427,96 +6898,182 @@ function buildStringField(field, onChange) {
6427
6898
  focus: () => input.focus()
6428
6899
  };
6429
6900
  }
6430
- function buildField(field, onChange) {
6901
+ function buildField(field, onChange, fallbackLabel, key) {
6902
+ const custom = contextConfig().getElicitationFieldRenderer()?.(field, { key, notifyChange: onChange });
6903
+ if (custom) {
6904
+ return {
6905
+ el: custom.el,
6906
+ getValue: () => custom.getValue(),
6907
+ isComplete: () => custom.isComplete(),
6908
+ focus: () => custom.focus?.()
6909
+ };
6910
+ }
6431
6911
  switch (field.type) {
6432
6912
  case "enum":
6433
- return buildEnumField(field, onChange);
6913
+ return buildEnumField(field, onChange, fallbackLabel);
6434
6914
  case "boolean":
6435
- return buildBooleanField(field, onChange);
6915
+ return buildBooleanField(field, onChange, fallbackLabel);
6436
6916
  case "string":
6437
- return buildStringField(field, onChange);
6917
+ return buildStringField(field, onChange, fallbackLabel);
6438
6918
  }
6439
6919
  }
6440
6920
  function buildElicitationPanel(message, schema, onChange) {
6441
6921
  const panel = el("div", "aparte-elic-panel");
6442
6922
  if (message) panel.appendChild(el("p", "aparte-elic-message", message));
6923
+ const body = el("div", "aparte-elic-body");
6924
+ panel.appendChild(body);
6925
+ const dismiss = el("div", "aparte-elic-dismiss");
6926
+ panel.appendChild(dismiss);
6443
6927
  if (schema.type === "object") {
6928
+ let show = function(index) {
6929
+ current = index;
6930
+ fields.forEach((f, i) => {
6931
+ f.field.el.hidden = i !== index;
6932
+ });
6933
+ syncNav();
6934
+ };
6444
6935
  const entries = Object.entries(schema.properties);
6445
6936
  const requiredKeys = new Set(schema.required ?? entries.map(([k]) => k));
6937
+ const cfg = contextConfig();
6938
+ let syncNav = () => {
6939
+ };
6940
+ const notify = () => {
6941
+ syncNav();
6942
+ onChange();
6943
+ };
6446
6944
  const fields = entries.map(([key, field2]) => {
6447
- const built = buildField(field2, onChange);
6945
+ const built = buildField(field2, notify, void 0, key);
6448
6946
  if (!field2.title && !built.el.querySelector(".aparte-elic-title")) {
6449
6947
  built.el.insertBefore(el("p", "aparte-elic-title", key), built.el.firstChild);
6450
6948
  }
6451
- panel.appendChild(built.el);
6452
- return { key, field: built, required: requiredKeys.has(key) };
6949
+ body.appendChild(built.el);
6950
+ return { key, field: built, required: requiredKeys.has(key), header: field2.header };
6453
6951
  });
6454
- return {
6952
+ const isComplete = () => fields.every((f) => !f.required || f.field.isComplete());
6953
+ const api = {
6455
6954
  el: panel,
6955
+ dismiss,
6456
6956
  getContent: () => Object.fromEntries(fields.map((f) => [f.key, f.field.getValue()])),
6457
- isComplete: () => fields.every((f) => !f.required || f.field.isComplete()),
6458
- focus: () => fields[0]?.field.focus()
6957
+ isComplete,
6958
+ focus: () => fields[0]?.field.focus(),
6959
+ // A form of one behaves like a single field; only a real form steps.
6960
+ mode: () => "submit",
6961
+ canProceed: isComplete,
6962
+ proceed: () => {
6963
+ }
6964
+ };
6965
+ if (fields.length < 2 || cfg.getElicitationOptions().layout !== "stepped") return api;
6966
+ panel.classList.add("aparte-elic-panel--stepped");
6967
+ let current = 0;
6968
+ const steps = el("div", "aparte-elic-steps");
6969
+ steps.setAttribute("role", "tablist");
6970
+ const chips = fields.map((f, i) => {
6971
+ const chip = el("button", "aparte-elic-step");
6972
+ chip.type = "button";
6973
+ chip.setAttribute("role", "tab");
6974
+ chip.textContent = f.header?.trim() || String(i + 1);
6975
+ chip.addEventListener("click", () => {
6976
+ show(i);
6977
+ f.field.focus();
6978
+ });
6979
+ steps.appendChild(chip);
6980
+ return chip;
6981
+ });
6982
+ panel.insertBefore(steps, body);
6983
+ syncNav = () => {
6984
+ chips.forEach((chip, i) => {
6985
+ chip.setAttribute("aria-selected", String(i === current));
6986
+ chip.toggleAttribute("data-answered", fields[i].field.isComplete());
6987
+ });
6988
+ };
6989
+ show(0);
6990
+ const last = () => current === fields.length - 1;
6991
+ return {
6992
+ ...api,
6993
+ focus: () => fields[current]?.field.focus(),
6994
+ mode: () => last() ? "submit" : "advance",
6995
+ // Advancing needs only THIS question answered; submitting needs them all,
6996
+ // which is what stops the last question from accepting a form with a hole
6997
+ // in it.
6998
+ canProceed: () => last() ? isComplete() : !!fields[current]?.field.isComplete(),
6999
+ proceed: () => {
7000
+ if (last()) return;
7001
+ show(current + 1);
7002
+ fields[current]?.field.focus();
7003
+ }
6459
7004
  };
6460
7005
  }
6461
- const field = buildField(schema, onChange);
6462
- panel.appendChild(field.el);
7006
+ const field = buildField(schema, onChange, message);
7007
+ body.appendChild(field.el);
6463
7008
  return {
6464
7009
  el: panel,
7010
+ dismiss,
6465
7011
  getContent: () => field.getValue(),
6466
7012
  isComplete: () => field.isComplete(),
6467
- focus: () => field.focus()
7013
+ focus: () => field.focus(),
7014
+ mode: () => "submit",
7015
+ canProceed: () => field.isComplete(),
7016
+ proceed: () => {
7017
+ }
6468
7018
  };
6469
7019
  }
6470
7020
  function requestUserInput(request) {
6471
7021
  return resolveConfig(request.target ?? null).requestUserInput(request);
6472
7022
  }
6473
7023
  export {
7024
+ uuid as $,
6474
7025
  APARTE_CONVERSATION_SCHEMA_VERSION as A,
6475
- BackendTransport as B,
6476
- ConversationManager as C,
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,
6486
- MessageRepository as M,
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,
6495
- APARTE_HOST_ATTR as a,
6496
- AparteChatHost as b,
6497
- AparteClient as c,
6498
- AparteConfig as d,
6499
- AparteConfigClass as e,
6500
- AparteConversationController as f,
6501
- AparteError as g,
6502
- AparteErrorCode as h,
6503
- AparteStreamParser as i,
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
7026
+ createStreamAdapter as B,
7027
+ cssEscape as C,
7028
+ declineDefaultRenderers as D,
7029
+ defaultSanitizer as E,
7030
+ deriveArtifactKind as F,
7031
+ detachConfig as G,
7032
+ escapeAttr as H,
7033
+ escapeHtml as I,
7034
+ filesToAttachments as J,
7035
+ getAllRenderers as K,
7036
+ getSegmentRenderer as L,
7037
+ installDefaultRenderersOnce as M,
7038
+ isAwaitingReply as N,
7039
+ isFormatAdapter as O,
7040
+ isSafeUrl as P,
7041
+ parseAparteEventStream as Q,
7042
+ parseMarkdownToSegments as R,
7043
+ populateBubbleFromMessage as S,
7044
+ readableToAsyncIterable as T,
7045
+ registerDefaultRenderers as U,
7046
+ registerSegmentRenderer as V,
7047
+ requestUserInput as W,
7048
+ resolveConfig as X,
7049
+ revokeAttachmentUrls as Y,
7050
+ runWithConfig as Z,
7051
+ unregisterSegmentRenderer as _,
7052
+ APARTE_DEFAULT_BUBBLE_ACTIONS as a,
7053
+ APARTE_DEFAULT_HOST_HANDLERS as b,
7054
+ APARTE_DEFAULT_ICON_FALLBACKS as c,
7055
+ APARTE_DEFAULT_LOCALE as d,
7056
+ APARTE_DEFAULT_SKELETON_FALLBACKS as e,
7057
+ APARTE_DEFAULT_UI_EVENTS as f,
7058
+ APARTE_HOST_ATTR as g,
7059
+ AparteBackendTransport as h,
7060
+ AparteChatHost as i,
7061
+ AparteClient as j,
7062
+ AparteConfig as k,
7063
+ AparteConversationController as l,
7064
+ AparteConversationManager as m,
7065
+ AparteDirectTransport as n,
7066
+ AparteError as o,
7067
+ AparteErrorCode as p,
7068
+ AparteMessageRepository as q,
7069
+ AparteStreamParser as r,
7070
+ aparteGlobalConfig as s,
7071
+ applyElementProps as t,
7072
+ attachConfig as u,
7073
+ buildElicitationPanel as v,
7074
+ collectRendererStyles as w,
7075
+ contentToText as x,
7076
+ contextConfig as y,
7077
+ createAparteChatHandler as z
6521
7078
  };
6522
- //# sourceMappingURL=index-Dn7TH14G.js.map
7079
+ //# sourceMappingURL=index-BieFbKeW.js.map