@aparte/core 0.7.1 → 0.9.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 (175) 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/bubble/bubble-sync.d.ts.map +1 -1
  13. package/dist/components/chat/aparte-chat.d.ts +0 -6
  14. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  15. package/dist/components/composer/aparte-composer-action.d.ts +18 -3
  16. package/dist/components/composer/aparte-composer-action.d.ts.map +1 -1
  17. package/dist/components/composer/aparte-composer-add-attachment.d.ts.map +1 -1
  18. package/dist/components/composer/aparte-composer-attachments.d.ts +1 -1
  19. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  20. package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -1
  21. package/dist/components/composer/aparte-composer-input.d.ts +0 -2
  22. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
  23. package/dist/components/composer/aparte-composer-send.d.ts +5 -0
  24. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  25. package/dist/components/composer/aparte-composer-toolbar.d.ts +4 -0
  26. package/dist/components/composer/aparte-composer-toolbar.d.ts.map +1 -1
  27. package/dist/components/composer/aparte-composer.d.ts +64 -6
  28. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  29. package/dist/components/composer/index.d.ts +1 -1
  30. package/dist/components/composer/index.d.ts.map +1 -1
  31. package/dist/components/conversation-list/aparte-conversation-list.d.ts +2 -5
  32. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  33. package/dist/components/conversation-list/index.d.ts +1 -1
  34. package/dist/components/conversation-list/index.d.ts.map +1 -1
  35. package/dist/components/elicitation/aparte-elicitation.d.ts +63 -3
  36. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  37. package/dist/components/index.d.ts +2 -2
  38. package/dist/components/index.d.ts.map +1 -1
  39. package/dist/components/status/aparte-chat-status.d.ts +8 -6
  40. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  41. package/dist/components/viewport/aparte-chat-viewport.d.ts +45 -9
  42. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  43. package/dist/config/action-provider.d.ts +4 -4
  44. package/dist/config/aparte-config.d.ts +124 -41
  45. package/dist/config/aparte-config.d.ts.map +1 -1
  46. package/dist/config/attachment-renderer.d.ts +1 -1
  47. package/dist/config/avatar-provider.d.ts +1 -1
  48. package/dist/config/config-context.d.ts +66 -11
  49. package/dist/config/config-context.d.ts.map +1 -1
  50. package/dist/config/elicitation-field-renderer.d.ts +73 -0
  51. package/dist/config/elicitation-field-renderer.d.ts.map +1 -0
  52. package/dist/config/error-renderer.d.ts +1 -1
  53. package/dist/config/icon-provider.d.ts +2 -2
  54. package/dist/config/icon-provider.d.ts.map +1 -1
  55. package/dist/config/index.d.ts +7 -5
  56. package/dist/config/index.d.ts.map +1 -1
  57. package/dist/config/locale.d.ts +43 -2
  58. package/dist/config/locale.d.ts.map +1 -1
  59. package/dist/config/sanitize.d.ts +1 -7
  60. package/dist/config/sanitize.d.ts.map +1 -1
  61. package/dist/config/sibling-nav-renderer.d.ts +1 -1
  62. package/dist/config/skeleton-provider.d.ts +2 -2
  63. package/dist/config/skeleton-provider.d.ts.map +1 -1
  64. package/dist/config/status-renderer.d.ts +1 -1
  65. package/dist/conversations/conversation-controller.d.ts +19 -5
  66. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  67. package/dist/conversations/conversation-manager.d.ts +2 -2
  68. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  69. package/dist/conversations/index.d.ts +1 -1
  70. package/dist/conversations/index.d.ts.map +1 -1
  71. package/dist/conversations/types.d.ts +1 -1
  72. package/dist/custom-elements.json +8418 -6295
  73. package/dist/elicitation/index.d.ts +1 -1
  74. package/dist/elicitation/panel.d.ts +32 -0
  75. package/dist/elicitation/panel.d.ts.map +1 -1
  76. package/dist/elicitation/types.d.ts +29 -2
  77. package/dist/elicitation/types.d.ts.map +1 -1
  78. package/dist/host/aparte-chat-host.d.ts +3 -3
  79. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  80. package/dist/{index-Dn7TH14G.js → index-hQQljwfX.js} +1890 -1222
  81. package/dist/index-hQQljwfX.js.map +1 -0
  82. package/dist/index.css +299 -21
  83. package/dist/index.d.ts +22 -13
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +485 -210
  86. package/dist/index.js.map +1 -1
  87. package/dist/index.node.d.ts +23 -13
  88. package/dist/index.node.d.ts.map +1 -1
  89. package/dist/index.node.js +56 -46
  90. package/dist/index.node.js.map +1 -1
  91. package/dist/interop/element-props.d.ts +1 -1
  92. package/dist/interop/element-props.d.ts.map +1 -1
  93. package/dist/parsers/aparte-stream-parser.d.ts +25 -0
  94. package/dist/parsers/aparte-stream-parser.d.ts.map +1 -1
  95. package/dist/primitives/index.d.ts +1 -1
  96. package/dist/primitives/index.d.ts.map +1 -1
  97. package/dist/primitives/select/aparte-optgroup.d.ts +15 -4
  98. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  99. package/dist/primitives/select/aparte-option.d.ts +0 -5
  100. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  101. package/dist/primitives/select/aparte-select.d.ts +17 -5
  102. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  103. package/dist/primitives/select/index.d.ts +1 -1
  104. package/dist/primitives/select/index.d.ts.map +1 -1
  105. package/dist/renderers/markdown-stream.d.ts +42 -0
  106. package/dist/renderers/markdown-stream.d.ts.map +1 -0
  107. package/dist/renderers/segment-renderers.d.ts +20 -14
  108. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  109. package/dist/renderers/segments/artifact/binary-file.d.ts +45 -0
  110. package/dist/renderers/segments/artifact/binary-file.d.ts.map +1 -0
  111. package/dist/renderers/segments/artifact/card.d.ts +3 -0
  112. package/dist/renderers/segments/artifact/card.d.ts.map +1 -0
  113. package/dist/renderers/segments/artifact/preview-document.d.ts +27 -0
  114. package/dist/renderers/segments/artifact/preview-document.d.ts.map +1 -0
  115. package/dist/renderers/segments/artifact/shared.d.ts +12 -0
  116. package/dist/renderers/segments/artifact/shared.d.ts.map +1 -0
  117. package/dist/renderers/segments/code.d.ts +3 -0
  118. package/dist/renderers/segments/code.d.ts.map +1 -0
  119. package/dist/renderers/segments/error.d.ts +3 -0
  120. package/dist/renderers/segments/error.d.ts.map +1 -0
  121. package/dist/renderers/segments/file-tree.d.ts +3 -0
  122. package/dist/renderers/segments/file-tree.d.ts.map +1 -0
  123. package/dist/renderers/segments/pipeline-waiting.d.ts +3 -0
  124. package/dist/renderers/segments/pipeline-waiting.d.ts.map +1 -0
  125. package/dist/renderers/segments/progress.d.ts +3 -0
  126. package/dist/renderers/segments/progress.d.ts.map +1 -0
  127. package/dist/renderers/segments/terminal.d.ts +3 -0
  128. package/dist/renderers/segments/terminal.d.ts.map +1 -0
  129. package/dist/renderers/segments/text.d.ts +10 -0
  130. package/dist/renderers/segments/text.d.ts.map +1 -0
  131. package/dist/renderers/segments/thinking.d.ts +3 -0
  132. package/dist/renderers/segments/thinking.d.ts.map +1 -0
  133. package/dist/renderers/segments/tool-call.d.ts +3 -0
  134. package/dist/renderers/segments/tool-call.d.ts.map +1 -0
  135. package/dist/runtime/message-repository.d.ts +3 -3
  136. package/dist/runtime/message-repository.d.ts.map +1 -1
  137. package/dist/transport/backend-handler.d.ts +19 -7
  138. package/dist/transport/backend-handler.d.ts.map +1 -1
  139. package/dist/transport/backend-transport.d.ts +1 -1
  140. package/dist/transport/backend-transport.d.ts.map +1 -1
  141. package/dist/transport/direct-transport.d.ts +4 -4
  142. package/dist/transport/direct-transport.d.ts.map +1 -1
  143. package/dist/transport/index.d.ts +2 -2
  144. package/dist/transport/index.d.ts.map +1 -1
  145. package/dist/transport/types.d.ts +3 -3
  146. package/dist/types/element-map.d.ts +69 -0
  147. package/dist/types/element-map.d.ts.map +1 -0
  148. package/dist/types/event-map.d.ts +84 -23
  149. package/dist/types/event-map.d.ts.map +1 -1
  150. package/dist/types/events.d.ts +212 -64
  151. package/dist/types/events.d.ts.map +1 -1
  152. package/dist/types/index.d.ts +4 -5
  153. package/dist/types/index.d.ts.map +1 -1
  154. package/dist/types/model-provider.d.ts +100 -54
  155. package/dist/types/model-provider.d.ts.map +1 -1
  156. package/dist/types/models.d.ts +29 -7
  157. package/dist/types/models.d.ts.map +1 -1
  158. package/dist/types/segments.d.ts +33 -8
  159. package/dist/types/segments.d.ts.map +1 -1
  160. package/dist/types/theming.d.ts +25 -37
  161. package/dist/types/theming.d.ts.map +1 -1
  162. package/dist/types/tools.d.ts +60 -15
  163. package/dist/types/tools.d.ts.map +1 -1
  164. package/dist/utils/escape.d.ts +46 -0
  165. package/dist/utils/escape.d.ts.map +1 -0
  166. package/dist/utils/files-to-attachments.d.ts +18 -1
  167. package/dist/utils/files-to-attachments.d.ts.map +1 -1
  168. package/dist/utils/segments.d.ts +174 -0
  169. package/dist/utils/segments.d.ts.map +1 -0
  170. package/dist/utils/uuid.d.ts +26 -0
  171. package/dist/utils/uuid.d.ts.map +1 -0
  172. package/package.json +3 -5
  173. package/dist/index-Dn7TH14G.js.map +0 -1
  174. package/dist/types/providers.d.ts +0 -93
  175. 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>" }
@@ -222,11 +237,11 @@ class AparteStreamParser {
222
237
  if (seg.type === "text") content = stripTrailingFence(content);
223
238
  seg.content = content;
224
239
  }
225
- finalSegments.push(this._state.activeSegment);
240
+ finalSegments.push(this._closed(this._state.activeSegment));
226
241
  this._state.activeSegment = null;
227
242
  this._state.buffer = "";
228
243
  } else if (this._state.buffer.trim()) {
229
- finalSegments.push(this._createTextSegment(stripTrailingFence(this._state.buffer)));
244
+ finalSegments.push(this._closed(this._createTextSegment(stripTrailingFence(this._state.buffer))));
230
245
  this._state.buffer = "";
231
246
  }
232
247
  return finalSegments;
@@ -296,15 +311,23 @@ class AparteStreamParser {
296
311
  }
297
312
  let segmentToEmit = null;
298
313
  if (this._state.activeSegment && this._state.activeSegment.type === "text") {
299
- segmentToEmit = this._state.activeSegment;
314
+ segmentToEmit = this._closed(this._state.activeSegment);
300
315
  this._state.activeSegment = null;
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) {
@@ -337,7 +360,7 @@ class AparteStreamParser {
337
360
  if (this._state.activeSegment && "content" in this._state.activeSegment) {
338
361
  this._state.activeSegment.content += codeContent2;
339
362
  }
340
- const segment2 = this._state.activeSegment;
363
+ const segment2 = this._closed(this._state.activeSegment);
341
364
  this._state.mode = "text";
342
365
  this._state.activeSegment = null;
343
366
  return { segment: segment2, remaining: "" };
@@ -355,7 +378,7 @@ class AparteStreamParser {
355
378
  if (this._state.activeSegment && "content" in this._state.activeSegment) {
356
379
  this._state.activeSegment.content += codeContent;
357
380
  }
358
- const segment = this._state.activeSegment;
381
+ const segment = this._closed(this._state.activeSegment);
359
382
  this._state.mode = "text";
360
383
  this._state.activeSegment = null;
361
384
  const afterClose = buffer.slice(closeIndex + 4);
@@ -397,7 +420,7 @@ class AparteStreamParser {
397
420
  if (this._state.activeSegment && "content" in this._state.activeSegment) {
398
421
  this._state.activeSegment.content += thinkingContent;
399
422
  }
400
- const segment = this._state.activeSegment;
423
+ const segment = this._closed(this._state.activeSegment);
401
424
  this._state.mode = "text";
402
425
  this._state.activeSegment = null;
403
426
  this._state.thinkingEnd = void 0;
@@ -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);
@@ -458,7 +482,7 @@ class AparteStreamParser {
458
482
  if (this._state.activeSegment && "content" in this._state.activeSegment) {
459
483
  this._state.activeSegment.content += tail;
460
484
  }
461
- const segment = this._state.activeSegment;
485
+ const segment = this._closed(this._state.activeSegment);
462
486
  this._state.mode = "text";
463
487
  this._state.activeSegment = null;
464
488
  return { segment, remaining: buffer.slice(closeIndex + endDelim.length) };
@@ -477,7 +501,35 @@ 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()}`;
505
+ }
506
+ /**
507
+ * Mark a segment the parser has just CLOSED.
508
+ *
509
+ * The end of a delimited segment is not a guess: the closing token IS the end.
510
+ * `</think>`, a closing fence, `</artifact>` — and, for a text run, the opening
511
+ * of whatever comes next. The parser computes that moment at six sites and used
512
+ * to drop it, emitting the finished segment with no flag saying so.
513
+ *
514
+ * What that cost: nothing downstream could tell a finished reasoning block from
515
+ * one still streaming, so the only remaining signal was the END OF THE TURN. A
516
+ * reader watched "Thinking" for as long as the answer took to stream — twenty
517
+ * seconds after the model had stopped thinking — and the Markdown flush and the
518
+ * highlight-on-settle waited exactly as long. Two workarounds were written
519
+ * before this one: close at the end of the message (wrong value), and close when
520
+ * the next segment opens (right value, wrong moment, and no help for a reply
521
+ * that is reasoning and nothing else).
522
+ *
523
+ * Both agent loops share this parser, so saying it here says it once.
524
+ *
525
+ * Not every end is in band, and the exceptions are honest: a text run that ends
526
+ * only because the STREAM ended has no delimiter — `finalize()` is its truth —
527
+ * and a `tool_call` never passes through here at all, since its end is its
528
+ * `status`.
529
+ */
530
+ _closed(segment) {
531
+ if (segment) segment.isStreaming = false;
532
+ return segment;
481
533
  }
482
534
  _createTextSegment(content) {
483
535
  return {
@@ -597,7 +649,7 @@ function parseAparteEventStream(stream) {
597
649
  }
598
650
  });
599
651
  }
600
- class BackendTransport {
652
+ class AparteBackendTransport {
601
653
  constructor(options) {
602
654
  this.options = options;
603
655
  }
@@ -626,11 +678,9 @@ class BackendTransport {
626
678
  function createAparteChatHandler(options) {
627
679
  const doFetch = options.fetchImpl ?? fetch;
628
680
  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
- }
681
+ const verdict = await options.authorize(req);
682
+ if (verdict instanceof Response) return verdict;
683
+ if (!verdict) return jsonError(401, "Unauthorized.");
634
684
  let providerId;
635
685
  let request;
636
686
  try {
@@ -675,11 +725,25 @@ function createAparteChatHandler(options) {
675
725
  return jsonError(502, `Vendor request failed: ${err?.message ?? "network error"}`);
676
726
  }
677
727
  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
- });
728
+ const raw = await vendor.text().catch(() => "");
729
+ let code;
730
+ let type;
731
+ try {
732
+ const parsed = JSON.parse(raw);
733
+ code = parsed?.error?.code;
734
+ type = parsed?.error?.type;
735
+ } catch {
736
+ }
737
+ return new Response(
738
+ JSON.stringify({
739
+ error: {
740
+ message: `Vendor request failed (HTTP ${vendor.status}).`,
741
+ ...typeof code === "string" ? { code } : {},
742
+ ...typeof type === "string" ? { type } : {}
743
+ }
744
+ }),
745
+ { status: vendor.status, headers: { "Content-Type": "application/json" } }
746
+ );
683
747
  }
684
748
  if (request.stream === false) {
685
749
  const json = await vendor.json().catch(() => ({}));
@@ -843,8 +907,15 @@ const TAG_ATTRS = {
843
907
  const URL_ATTRS = /* @__PURE__ */ new Set(["href", "src"]);
844
908
  const SAFE_URL = /^(?:https?:|mailto:|tel:|ftp:|sms:)/i;
845
909
  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;
910
+ const SAFE_DATA_IMG = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|svg\+xml)[;,]/i;
847
911
  const CONTROL_WS = /[\u0000-\u0020]+/g;
912
+ function isSafeSrcset(value, tag) {
913
+ const urlTag = tag === "source" ? "img" : tag;
914
+ for (const m of value.matchAll(/[a-z][a-z0-9+.-]*:[^\s,]*/gi)) {
915
+ if (!isSafeUrl(m[0], urlTag)) return false;
916
+ }
917
+ return true;
918
+ }
848
919
  function isSafeUrl(value, tag) {
849
920
  const v = value.replace(CONTROL_WS, "");
850
921
  if (!v) return true;
@@ -877,6 +948,7 @@ function scrubStyle(value) {
877
948
  const prop = trimmed.slice(0, idx).trim().toLowerCase();
878
949
  const val = trimmed.slice(idx + 1);
879
950
  if (!val.trim() || !SAFE_STYLE_PROPS.has(prop)) continue;
951
+ if (val.includes("\\")) continue;
880
952
  if (/url\s*\(|expression\s*\(|javascript:|vbscript:|[<>]/i.test(val)) continue;
881
953
  kept.push(trimmed);
882
954
  }
@@ -890,7 +962,7 @@ function copyAttributes(src, dest, tag) {
890
962
  if (name.startsWith("on") || name.startsWith("formaction")) continue;
891
963
  if (!name.startsWith("data-") && !GLOBAL_ATTRS.has(name) && !(extra && extra.has(name))) continue;
892
964
  if (URL_ATTRS.has(name) && !isSafeUrl(value, tag)) continue;
893
- if (name === "srcset" && /javascript:|vbscript:/i.test(value)) continue;
965
+ if (name === "srcset" && !isSafeSrcset(value, tag)) continue;
894
966
  if (name === "style") {
895
967
  const scrubbed = scrubStyle(value);
896
968
  if (scrubbed === null) continue;
@@ -939,19 +1011,34 @@ const defaultSanitizer = (html) => {
939
1011
  sanitizeChildren(doc.body, container, doc);
940
1012
  return container.innerHTML;
941
1013
  };
942
- const DEFAULT_BUBBLE_ACTIONS = {
1014
+ function escapeHtml(value) {
1015
+ let out = "";
1016
+ for (let i = 0; i < value.length; i++) {
1017
+ const ch = value[i];
1018
+ if (ch === "&") out += "&amp;";
1019
+ else if (ch === "<") out += "&lt;";
1020
+ else if (ch === ">") out += "&gt;";
1021
+ else if (ch === '"') out += "&quot;";
1022
+ else if (ch === "'") out += "&#039;";
1023
+ else out += ch;
1024
+ }
1025
+ return out;
1026
+ }
1027
+ const escapeAttr = escapeHtml;
1028
+ const APARTE_DEFAULT_BUBBLE_ACTIONS = {
943
1029
  copy: true,
944
1030
  retry: false,
945
1031
  edit: false,
946
1032
  feedback: false,
947
1033
  info: false
948
1034
  };
949
- const DEFAULT_HOST_HANDLERS = {
1035
+ const APARTE_DEFAULT_HOST_HANDLERS = {
950
1036
  attachmentPreview: false,
951
1037
  terminalRun: false,
952
- artifactRedownload: false
1038
+ artifactRedownload: false,
1039
+ artifactRehydrate: false
953
1040
  };
954
- class AparteConfigClass {
1041
+ class AparteConfig {
955
1042
  _markdownProvider;
956
1043
  _streamingMarkdownProvider;
957
1044
  _highlightProvider;
@@ -971,30 +1058,50 @@ class AparteConfigClass {
971
1058
  _avatarProvider;
972
1059
  _keyProvider;
973
1060
  _artifactPreviewBuilder;
974
- _locale = DEFAULT_LOCALE;
1061
+ _locale = APARTE_DEFAULT_LOCALE;
975
1062
  _actions = [];
976
1063
  _listeners = /* @__PURE__ */ new Set();
977
1064
  // AI Provider Management (BYORK)
978
1065
  _aiProviders = /* @__PURE__ */ new Map();
1066
+ /**
1067
+ * What `refreshProviderModels()` last brought back, per provider.
1068
+ *
1069
+ * Without it, `getCurrentModel()` read `provider.getModels()` only — the
1070
+ * SYNCHRONOUS, hand-declared list, which every preset of
1071
+ * `@aparte/provider-openai-compat` leaves empty because a compat endpoint's
1072
+ * list is fetched at runtime. So the current model resolved to `undefined`
1073
+ * for the documented primary path, and everything that reads a capability off
1074
+ * it silently got nothing: `AparteClient._toolsForCurrentModel()` gates tools
1075
+ * on `capabilities.includes('function_calling')`, so NO tool was ever sent to
1076
+ * the model. A registered tool, an approval gate, `<aparte-elicitation>` — all
1077
+ * dead, with the model answering, correctly, that it had been given no tools.
1078
+ */
1079
+ _fetchedModels = /* @__PURE__ */ new Map();
979
1080
  _modelConfig = {};
980
1081
  /** Opt-in: gate the composer (block send + grey out) until a model is selected. */
981
1082
  _requireModelSelection = false;
982
- // Transport: where chat requests go + how auth is handled (DirectTransport = browser-direct).
983
- _transport = new DirectTransport();
1083
+ /** Host policy for the elicitation panel's free-text escape see {@link setElicitationOptions}. */
1084
+ _elicitationAllowOther = true;
1085
+ _elicitationLayout = "stepped";
1086
+ _elicitationFieldRenderer;
1087
+ // Transport: where chat requests go + how auth is handled (AparteDirectTransport = browser-direct).
1088
+ _transport = new AparteDirectTransport();
984
1089
  _modelPreferenceProvider;
985
1090
  // Conversation persistence (optional, agnostic)
986
1091
  _conversationManager;
987
- // Human-in-the-loop: presents typed input requests (ask_question,
1092
+ // Human-in-the-loop: presents typed input requests (ask_user,
988
1093
  // tool approval, forms). Set by the <aparte-elicitation> Web Component.
989
1094
  _elicitationPresenter;
1095
+ /** Warn once per config, not once per request — a tool loop can ask repeatedly. */
1096
+ _warnedNoPresenter = false;
990
1097
  // Tool Registry
991
1098
  _tools = /* @__PURE__ */ new Map();
992
1099
  _toolRenderers = /* @__PURE__ */ new Map();
993
1100
  // 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
1101
+ _hostHandlers = { ...APARTE_DEFAULT_HOST_HANDLERS };
1102
+ // Bubble Actions — APARTE_DEFAULT_BUBBLE_ACTIONS is the single source of truth
996
1103
  // (init here, restored by reset(), and the fallback in getBubbleActions()).
997
- _bubbleActionsConfig = { ...DEFAULT_BUBBLE_ACTIONS };
1104
+ _bubbleActionsConfig = { ...APARTE_DEFAULT_BUBBLE_ACTIONS };
998
1105
  // ─────────────────────────────────────────────────────────────────────────
999
1106
  // Provider Setters (Dependency Injection)
1000
1107
  // ─────────────────────────────────────────────────────────────────────────
@@ -1036,15 +1143,15 @@ class AparteConfigClass {
1036
1143
  }
1037
1144
  /**
1038
1145
  * Configure which action buttons appear in message bubbles.
1039
- * Unset keys keep their defaults — see {@link DEFAULT_BUBBLE_ACTIONS}: `copy`
1146
+ * Unset keys keep their defaults — see {@link APARTE_DEFAULT_BUBBLE_ACTIONS}: `copy`
1040
1147
  * only, because every other button needs a host to honor it.
1041
1148
  *
1042
1149
  * @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
1150
+ * aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }) // you run AparteClient
1151
+ * aparteGlobalConfig.setBubbleActions({ feedback: true }) // you listen for aparte-feedback
1152
+ * aparteGlobalConfig.setBubbleActions({ copy: false }) // hide everything
1046
1153
  * // Explicit per-role ordered sets (replace the flag defaults for that role):
1047
- * AparteConfig.setBubbleActions({ user: ['edit', 'copy'], assistant: ['copy', 'thumbUp', 'thumbDown', 'retry'] })
1154
+ * aparteGlobalConfig.setBubbleActions({ user: ['edit', 'copy'], assistant: ['copy', 'thumbUp', 'thumbDown', 'retry'] })
1048
1155
  */
1049
1156
  setBubbleActions(config) {
1050
1157
  this._bubbleActionsConfig = { ...this._bubbleActionsConfig, ...config };
@@ -1055,28 +1162,34 @@ class AparteConfigClass {
1055
1162
  * trigger only for the ones you claim — see {@link AparteHostHandlersConfig}.
1056
1163
  *
1057
1164
  * @example
1058
- * AparteConfig.setHostHandlers({ attachmentPreview: true, terminalRun: true });
1165
+ * aparteGlobalConfig.setHostHandlers({ attachmentPreview: true, terminalRun: true });
1059
1166
  */
1060
1167
  setHostHandlers(config) {
1061
1168
  this._hostHandlers = { ...this._hostHandlers, ...config };
1062
1169
  this._notify();
1063
1170
  }
1064
1171
  /** Returns the resolved host-handler declarations (undeclared = false). */
1172
+ // `Required<...>` rather than a hand-written shape: the previous signature
1173
+ // listed the three keys literally, so adding a fourth to the interface left
1174
+ // this method silently returning a type without it — the caller's read did not
1175
+ // even typecheck. Derived from the interface, a new declaration cannot be
1176
+ // forgotten here.
1065
1177
  getHostHandlers() {
1066
1178
  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
1179
+ attachmentPreview: this._hostHandlers.attachmentPreview ?? APARTE_DEFAULT_HOST_HANDLERS.attachmentPreview,
1180
+ terminalRun: this._hostHandlers.terminalRun ?? APARTE_DEFAULT_HOST_HANDLERS.terminalRun,
1181
+ artifactRedownload: this._hostHandlers.artifactRedownload ?? APARTE_DEFAULT_HOST_HANDLERS.artifactRedownload,
1182
+ artifactRehydrate: this._hostHandlers.artifactRehydrate ?? APARTE_DEFAULT_HOST_HANDLERS.artifactRehydrate
1070
1183
  };
1071
1184
  }
1072
1185
  /** Returns the resolved bubble actions config (flag defaults applied; per-role sets passed through). */
1073
1186
  getBubbleActions() {
1074
1187
  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,
1188
+ copy: this._bubbleActionsConfig.copy ?? APARTE_DEFAULT_BUBBLE_ACTIONS.copy,
1189
+ retry: this._bubbleActionsConfig.retry ?? APARTE_DEFAULT_BUBBLE_ACTIONS.retry,
1190
+ edit: this._bubbleActionsConfig.edit ?? APARTE_DEFAULT_BUBBLE_ACTIONS.edit,
1191
+ feedback: this._bubbleActionsConfig.feedback ?? APARTE_DEFAULT_BUBBLE_ACTIONS.feedback,
1192
+ info: this._bubbleActionsConfig.info ?? APARTE_DEFAULT_BUBBLE_ACTIONS.info,
1080
1193
  user: this._bubbleActionsConfig.user,
1081
1194
  assistant: this._bubbleActionsConfig.assistant
1082
1195
  };
@@ -1129,9 +1242,14 @@ class AparteConfigClass {
1129
1242
  * hardened coverage, or `null` to DISABLE sanitization. Disabling exposes
1130
1243
  * you to XSS from LLM-authored content — only do so for content you fully
1131
1244
  * trust and have already sanitized upstream.
1132
- * @example AparteConfig.setHtmlSanitizer((html) => DOMPurify.sanitize(html));
1245
+ * @example aparteGlobalConfig.setHtmlSanitizer((html) => DOMPurify.sanitize(html));
1133
1246
  */
1134
1247
  setHtmlSanitizer(sanitizer) {
1248
+ if (sanitizer === null) {
1249
+ console.warn(
1250
+ "[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."
1251
+ );
1252
+ }
1135
1253
  this._sanitizer = sanitizer;
1136
1254
  }
1137
1255
  /**
@@ -1202,7 +1320,7 @@ class AparteConfigClass {
1202
1320
  }
1203
1321
  /**
1204
1322
  * The icon set as a **complete** provider: every name resolves, falling back
1205
- * to `DEFAULT_ICON_FALLBACKS` for anything the registered provider doesn't
1323
+ * to `APARTE_DEFAULT_ICON_FALLBACKS` for anything the registered provider doesn't
1206
1324
  * implement. Callers (bubble action bar, composer controls) can therefore
1207
1325
  * invoke `icons.copy()` unconditionally.
1208
1326
  *
@@ -1214,9 +1332,9 @@ class AparteConfigClass {
1214
1332
  getIconProvider() {
1215
1333
  const registered = this._iconProvider;
1216
1334
  const complete = {};
1217
- for (const name of Object.keys(DEFAULT_ICON_FALLBACKS)) {
1335
+ for (const name of Object.keys(APARTE_DEFAULT_ICON_FALLBACKS)) {
1218
1336
  const fn = registered?.[name];
1219
- complete[name] = fn ?? (() => DEFAULT_ICON_FALLBACKS[name]);
1337
+ complete[name] = fn ?? (() => APARTE_DEFAULT_ICON_FALLBACKS[name]);
1220
1338
  }
1221
1339
  return complete;
1222
1340
  }
@@ -1315,12 +1433,12 @@ class AparteConfigClass {
1315
1433
  this._notify();
1316
1434
  }
1317
1435
  /**
1318
- * Restore the built-in English locale (the same `DEFAULT_LOCALE` core ships
1436
+ * Restore the built-in English locale (the same `APARTE_DEFAULT_LOCALE` core ships
1319
1437
  * with). Counterpart of {@link setLocale} for language toggles — avoids
1320
- * having to import `DEFAULT_LOCALE` yourself.
1438
+ * having to import `APARTE_DEFAULT_LOCALE` yourself.
1321
1439
  */
1322
1440
  resetLocale() {
1323
- this._locale = DEFAULT_LOCALE;
1441
+ this._locale = APARTE_DEFAULT_LOCALE;
1324
1442
  this._notify();
1325
1443
  }
1326
1444
  /**
@@ -1345,7 +1463,7 @@ class AparteConfigClass {
1345
1463
  getIcon(name) {
1346
1464
  const icon = this._iconProvider?.[name];
1347
1465
  if (icon) return icon();
1348
- return DEFAULT_ICON_FALLBACKS[name];
1466
+ return APARTE_DEFAULT_ICON_FALLBACKS[name];
1349
1467
  }
1350
1468
  /**
1351
1469
  * Set a custom Key provider (e.g., AparteVault override)
@@ -1372,7 +1490,9 @@ class AparteConfigClass {
1372
1490
  if (!provider || !provider.fetchModels) return [];
1373
1491
  try {
1374
1492
  const apiKey = await this.getKey(providerId);
1375
- return await provider.fetchModels(apiKey);
1493
+ const models = await provider.fetchModels(apiKey);
1494
+ this._fetchedModels.set(providerId, models);
1495
+ return models;
1376
1496
  } catch (error) {
1377
1497
  console.warn(`[AparteConfig] Failed to refresh models for ${providerId}`, error);
1378
1498
  return [];
@@ -1385,8 +1505,8 @@ class AparteConfigClass {
1385
1505
  * Register one or more AI providers (e.g., OpenRouter, Gemini, Anthropic)
1386
1506
  * @param providers AparteAIProvider implementations
1387
1507
  * @example
1388
- * AparteConfig.registerAIProvider(OpenRouterProvider);
1389
- * AparteConfig.registerAIProvider(GeminiProvider, AnthropicProvider);
1508
+ * aparteGlobalConfig.registerAIProvider(OpenRouterProvider);
1509
+ * aparteGlobalConfig.registerAIProvider(GeminiProvider, AnthropicProvider);
1390
1510
  */
1391
1511
  registerAIProvider(...providers) {
1392
1512
  for (const provider of providers) {
@@ -1426,14 +1546,14 @@ class AparteConfigClass {
1426
1546
  }
1427
1547
  /**
1428
1548
  * 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
1549
+ * handled. Defaults to {@link AparteDirectTransport} (browser-direct — BYOK/local).
1550
+ * Use a `AparteBackendTransport` to keep API keys server-side (recommended for
1431
1551
  * production).
1432
1552
  */
1433
1553
  setTransport(transport) {
1434
1554
  this._transport = transport;
1435
1555
  }
1436
- /** Get the active transport (DirectTransport by default). */
1556
+ /** Get the active transport (AparteDirectTransport by default). */
1437
1557
  getTransport() {
1438
1558
  return this._transport;
1439
1559
  }
@@ -1441,7 +1561,7 @@ class AparteConfigClass {
1441
1561
  * Register a model preference provider for agnostic persistence.
1442
1562
  * The host app decides how/where to store the selected provider & model.
1443
1563
  * @example
1444
- * AparteConfig.setModelPreferenceProvider({
1564
+ * aparteGlobalConfig.setModelPreferenceProvider({
1445
1565
  * save: (p, m) => localStorage.setItem('model', JSON.stringify({p, m})),
1446
1566
  * load: () => JSON.parse(localStorage.getItem('model') ?? 'null')
1447
1567
  * });
@@ -1515,6 +1635,53 @@ class AparteConfigClass {
1515
1635
  * still fetching its list). Off by default so single-model / backend setups
1516
1636
  * that never select a model are unaffected.
1517
1637
  */
1638
+ /**
1639
+ * Elicitation policy: whether a choice offers a free-text "Other…" escape.
1640
+ *
1641
+ * This is the HOST's decision, not the model's. `ask_user` used to carry an
1642
+ * `allow_other` field in the schema it hands the model, which meant the model
1643
+ * decided your UX — and a small model fills a field it does not understand: one
1644
+ * sent two questions with `allow_other: true` and no options at all, so the
1645
+ * panel rendered a radio list whose only entry was "Other…", twice.
1646
+ *
1647
+ * Every serious implementation of this pattern makes the escape hatch a property
1648
+ * of the surface rather than of the request. Default `true`, which is what the
1649
+ * panel did before, so nothing a user sees changes — only who gets to say so.
1650
+ * A direct `requestUserInput` caller can still set `allowOther` per field; that
1651
+ * is the app talking, and it wins.
1652
+ *
1653
+ * `layout` decides how a form of SEVERAL questions is presented.
1654
+ * `'stepped'` (default) asks them one at a time with a chip per question;
1655
+ * `'stacked'` puts them all in the panel at once. Stacked was the only shape,
1656
+ * inherited unexamined from MCP elicitation — which describes a FORM for
1657
+ * collecting structured data, not two different questions asked mid-conversation.
1658
+ * No product asks a person two questions by stacking them in one box; it is kept
1659
+ * as an option because the form case is real, not because it was the right default.
1660
+ */
1661
+ setElicitationOptions(options) {
1662
+ if (options.allowOther !== void 0) this._elicitationAllowOther = options.allowOther;
1663
+ if (options.layout !== void 0) this._elicitationLayout = options.layout;
1664
+ this._notify();
1665
+ }
1666
+ /** The elicitation policy (see {@link setElicitationOptions}). */
1667
+ getElicitationOptions() {
1668
+ return { allowOther: this._elicitationAllowOther, layout: this._elicitationLayout };
1669
+ }
1670
+ /**
1671
+ * Render one field of the question panel yourself.
1672
+ *
1673
+ * See {@link AparteElicitationFieldRenderer}. Return `null` for a field to let
1674
+ * the built-in render it, which is what makes overriding a single kind practical.
1675
+ * Pass `null` here to remove the renderer.
1676
+ */
1677
+ setElicitationFieldRenderer(fn) {
1678
+ this._elicitationFieldRenderer = fn ?? void 0;
1679
+ this._notify();
1680
+ }
1681
+ /** The custom field renderer, if one is registered. */
1682
+ getElicitationFieldRenderer() {
1683
+ return this._elicitationFieldRenderer;
1684
+ }
1518
1685
  setRequireModelSelection(required) {
1519
1686
  if (this._requireModelSelection === required) return;
1520
1687
  this._requireModelSelection = required;
@@ -1534,10 +1701,13 @@ class AparteConfigClass {
1534
1701
  if (!defaultProvider || !defaultModel) return void 0;
1535
1702
  const provider = this._aiProviders.get(defaultProvider);
1536
1703
  if (!provider) return void 0;
1704
+ const fetched = this._fetchedModels.get(defaultProvider);
1705
+ const fromFetch = fetched?.find((m) => m.id === defaultModel);
1706
+ if (fromFetch) return fromFetch;
1537
1707
  const models = provider.getModels();
1538
1708
  if (models instanceof Promise) {
1539
1709
  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.`
1710
+ `[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
1711
  );
1542
1712
  return void 0;
1543
1713
  }
@@ -1550,7 +1720,7 @@ class AparteConfigClass {
1550
1720
  * Get translated string by key
1551
1721
  */
1552
1722
  t(key) {
1553
- const val = this._locale[key] || DEFAULT_LOCALE[key];
1723
+ const val = this._locale[key] || APARTE_DEFAULT_LOCALE[key];
1554
1724
  return val === void 0 ? "" : val;
1555
1725
  }
1556
1726
  /**
@@ -1601,7 +1771,7 @@ class AparteConfigClass {
1601
1771
  * Register a tool and its handler together.
1602
1772
  * The handler is called when the AI invokes the tool during streaming.
1603
1773
  * @example
1604
- * AparteConfig.registerTool(askQuestionTool, askQuestionHandler);
1774
+ * aparteGlobalConfig.registerTool(askUserTool, askUserHandler);
1605
1775
  */
1606
1776
  registerTool(tool, handler) {
1607
1777
  this._tools.set(tool.name, { tool, handler });
@@ -1624,12 +1794,12 @@ class AparteConfigClass {
1624
1794
  * Use this instead of the generic `tool_call` segment renderer for tool-specific UI.
1625
1795
  *
1626
1796
  * @example
1627
- * // Hide the segment entirely (UI-only tool like ask_question)
1628
- * AparteConfig.registerToolRenderer('ask_question', { render: () => '' });
1797
+ * // Hide the segment entirely (UI-only tool like ask_user)
1798
+ * aparteGlobalConfig.registerToolRenderer('ask_user', { render: () => '' });
1629
1799
  *
1630
1800
  * @example
1631
1801
  * // Custom pill for a web-search tool
1632
- * AparteConfig.registerToolRenderer('web_search', { render: (seg) => `<div class="tool-pill">Searching...</div>` });
1802
+ * aparteGlobalConfig.registerToolRenderer('web_search', { render: (seg) => `<div class="tool-pill">Searching...</div>` });
1633
1803
  */
1634
1804
  registerToolRenderer(toolName, renderer) {
1635
1805
  this._toolRenderers.set(toolName, renderer);
@@ -1646,13 +1816,13 @@ class AparteConfigClass {
1646
1816
  // Conversation Manager (optional, agnostic persistence layer)
1647
1817
  // ─────────────────────────────────────────────────────────────────────────
1648
1818
  /**
1649
- * Register a ConversationManager so any UI controller can persist & load
1819
+ * Register a AparteConversationManager so any UI controller can persist & load
1650
1820
  * conversations without coupling to a framework wrapper.
1651
1821
  */
1652
1822
  setConversationManager(manager) {
1653
1823
  this._conversationManager = manager;
1654
1824
  }
1655
- /** Returns the registered ConversationManager, or undefined if none. */
1825
+ /** Returns the registered AparteConversationManager, or undefined if none. */
1656
1826
  getConversationManager() {
1657
1827
  return this._conversationManager;
1658
1828
  }
@@ -1674,14 +1844,29 @@ class AparteConfigClass {
1674
1844
  }
1675
1845
  /**
1676
1846
  * 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
1847
+ * generic primitive behind `ask_user` and tool approval — the KIND of
1678
1848
  * question is the schema, not a bespoke tool. Resolves `accept` with the
1679
1849
  * 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.
1850
+ * cancelled.
1851
+ *
1852
+ * With NO presenter registered it resolves `cancel` rather than hanging — and
1853
+ * warns, once, because that `cancel` is otherwise a lie told quietly. The
1854
+ * model reads it as "the user refused"; the user was never asked, and the
1855
+ * developer sees nothing at all. The default presenter is
1856
+ * `<aparte-elicitation>`, which registers itself from its `connectedCallback`
1857
+ * — so it has to be IN THE DOM. A docs page of ours claimed it "installs
1858
+ * itself — nothing to register", which is how this failure mode was found.
1682
1859
  */
1683
1860
  requestUserInput(request) {
1684
- if (!this._elicitationPresenter) return Promise.resolve({ action: "cancel" });
1861
+ if (!this._elicitationPresenter) {
1862
+ if (!this._warnedNoPresenter) {
1863
+ this._warnedNoPresenter = true;
1864
+ console.warn(
1865
+ "[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."
1866
+ );
1867
+ }
1868
+ return Promise.resolve({ action: "cancel" });
1869
+ }
1685
1870
  return this._elicitationPresenter(request);
1686
1871
  }
1687
1872
  /**
@@ -1708,28 +1893,32 @@ class AparteConfigClass {
1708
1893
  this._keyProvider = void 0;
1709
1894
  this._conversationManager = void 0;
1710
1895
  this._elicitationPresenter = void 0;
1711
- this._locale = DEFAULT_LOCALE;
1896
+ this._locale = APARTE_DEFAULT_LOCALE;
1712
1897
  this._actions = [];
1713
1898
  this._sanitizer = defaultSanitizer;
1714
1899
  this._aiProviders.clear();
1900
+ this._fetchedModels.clear();
1715
1901
  this._tools.clear();
1716
1902
  this._toolRenderers.clear();
1717
1903
  this._modelConfig = {};
1718
1904
  this._requireModelSelection = false;
1905
+ this._elicitationAllowOther = true;
1906
+ this._elicitationLayout = "stepped";
1907
+ this._elicitationFieldRenderer = void 0;
1719
1908
  this._modelPreferenceProvider = void 0;
1720
- this._bubbleActionsConfig = { ...DEFAULT_BUBBLE_ACTIONS };
1721
- this._hostHandlers = { ...DEFAULT_HOST_HANDLERS };
1909
+ this._bubbleActionsConfig = { ...APARTE_DEFAULT_BUBBLE_ACTIONS };
1910
+ this._hostHandlers = { ...APARTE_DEFAULT_HOST_HANDLERS };
1722
1911
  this._notify();
1723
1912
  }
1724
1913
  // ─────────────────────────────────────────────────────────────────────────
1725
1914
  // Default Fallbacks (Zero-dependency)
1726
1915
  // ─────────────────────────────────────────────────────────────────────────
1727
1916
  _defaultMarkdownRenderer(raw) {
1728
- const escaped = this._escapeHtml(raw);
1917
+ const escaped = escapeHtml(raw);
1729
1918
  return escaped.replace(/\n/g, "<br>");
1730
1919
  }
1731
1920
  _defaultHighlightRenderer(code) {
1732
- return `<pre><code>${this._escapeHtml(code)}</code></pre>`;
1921
+ return `<pre><code>${escapeHtml(code)}</code></pre>`;
1733
1922
  }
1734
1923
  _defaultSkeletonRenderer(type) {
1735
1924
  const fallbacks = {
@@ -1742,34 +1931,44 @@ class AparteConfigClass {
1742
1931
  };
1743
1932
  return fallbacks[type] || fallbacks.text;
1744
1933
  }
1745
- _escapeHtml(text) {
1746
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1747
- }
1748
1934
  }
1749
- const GLOBAL_CONFIG_KEY = "__APARTE_CONFIG_SINGLETON__";
1935
+ const GLOBAL_CONFIG_KEY = Symbol.for("aparte.globalConfig.1");
1750
1936
  function getGlobalConfig() {
1751
1937
  if (typeof window !== "undefined") {
1752
1938
  if (!window[GLOBAL_CONFIG_KEY]) {
1753
- window[GLOBAL_CONFIG_KEY] = new AparteConfigClass();
1939
+ window[GLOBAL_CONFIG_KEY] = new AparteConfig();
1754
1940
  }
1755
1941
  return window[GLOBAL_CONFIG_KEY];
1756
1942
  }
1757
- return new AparteConfigClass();
1943
+ return new AparteConfig();
1758
1944
  }
1759
- const AparteConfig = getGlobalConfig();
1945
+ const aparteGlobalConfig = getGlobalConfig();
1760
1946
  const APARTE_HOST_ATTR = "data-aparte-host";
1761
1947
  const CONFIG_SLOT = Symbol.for("aparte.instanceConfig");
1948
+ function notifyConfigChanged(root, next, previous) {
1949
+ if (next === previous) return;
1950
+ const call = (el2) => {
1951
+ const hook = el2.aparteConfigChanged;
1952
+ if (typeof hook === "function") hook.call(el2, next, previous);
1953
+ };
1954
+ call(root);
1955
+ for (const el2 of root.querySelectorAll("*")) call(el2);
1956
+ }
1762
1957
  function attachConfig(el2, config) {
1958
+ const previous = el2[CONFIG_SLOT] ?? resolveConfig(el2.parentElement);
1763
1959
  el2[CONFIG_SLOT] = config;
1764
1960
  el2.setAttribute(APARTE_HOST_ATTR, "");
1961
+ notifyConfigChanged(el2, config, previous);
1765
1962
  }
1766
1963
  function detachConfig(el2) {
1964
+ const previous = el2[CONFIG_SLOT];
1767
1965
  delete el2[CONFIG_SLOT];
1768
1966
  el2.removeAttribute(APARTE_HOST_ATTR);
1967
+ if (previous) notifyConfigChanged(el2, resolveConfig(el2.parentElement), previous);
1769
1968
  }
1770
1969
  function resolveConfig(el2) {
1771
1970
  const host = el2?.closest?.(`[${APARTE_HOST_ATTR}]`);
1772
- return host?.[CONFIG_SLOT] ?? AparteConfig;
1971
+ return host?.[CONFIG_SLOT] ?? aparteGlobalConfig;
1773
1972
  }
1774
1973
  let _renderConfig = null;
1775
1974
  function runWithConfig(config, fn) {
@@ -1784,7 +1983,7 @@ function runWithConfig(config, fn) {
1784
1983
  function contextConfig(el2) {
1785
1984
  return _renderConfig ?? resolveConfig(el2);
1786
1985
  }
1787
- const DEFAULT_SKELETON_FALLBACKS = {
1986
+ const APARTE_DEFAULT_SKELETON_FALLBACKS = {
1788
1987
  message: '<div class="aparte-skeleton-fallback" style="padding:16px;color:#9ca3af;">Loading...</div>',
1789
1988
  code: '<div class="aparte-skeleton-fallback" style="padding:16px;background:#1e293b;color:#64748b;border-radius:8px;">Loading code...</div>',
1790
1989
  thinking: '<div class="aparte-skeleton-fallback" style="padding:8px;color:#9ca3af;">Thinking...</div>',
@@ -1792,103 +1991,45 @@ const DEFAULT_SKELETON_FALLBACKS = {
1792
1991
  list: '<div class="aparte-skeleton-fallback" style="padding:16px;color:#9ca3af;">Loading items...</div>',
1793
1992
  text: '<div class="aparte-skeleton-fallback" style="padding:8px;color:#9ca3af;">...</div>'
1794
1993
  };
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++;
1994
+ function writeStreamedMarkdown(host, contentEl, content, streaming) {
1995
+ if (streaming) {
1996
+ if (host._aparteSmd === void 0) {
1997
+ const renderer = contextConfig().createStreamingMarkdownRenderer(contentEl);
1998
+ if (renderer) {
1999
+ contentEl.textContent = "";
2000
+ host._aparteSmd = { renderer, written: 0 };
2001
+ } else {
2002
+ host._aparteSmd = null;
1834
2003
  }
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
- }
2004
+ }
2005
+ const smd = host._aparteSmd;
2006
+ if (smd) {
2007
+ const delta = content.slice(smd.written);
2008
+ if (delta) {
2009
+ smd.renderer.write(delta);
2010
+ smd.written = content.length;
1849
2011
  }
2012
+ return;
1850
2013
  }
1851
- while (i < s.length && s[i] !== "\n") i++;
1852
- if (i < s.length) i++;
2014
+ } else if (host._aparteSmd) {
2015
+ host._aparteSmd.renderer.end();
2016
+ host._aparteSmd = void 0;
1853
2017
  }
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);
1872
- }
1873
- injectRendererStyles();
1874
- }
1875
- function unregisterSegmentRenderer(type) {
1876
- renderers.delete(type);
2018
+ contentEl.innerHTML = contextConfig().renderMarkdown(content);
1877
2019
  }
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);
2020
+ function highlightMarkdownFences(contentEl) {
2021
+ const blocks = contentEl.querySelectorAll("pre > code:not([data-aparte-highlighted])");
2022
+ for (const block of blocks) {
2023
+ const pre = block.parentElement;
2024
+ if (!pre) continue;
2025
+ const lang = Array.from(block.classList).find((c) => c.startsWith("language-"))?.slice("language-".length) ?? "";
2026
+ const source = block.textContent ?? "";
2027
+ block.setAttribute("data-aparte-highlighted", "");
2028
+ void contextConfig().highlightCode(source, lang).then((html) => {
2029
+ if (pre.isConnected || pre.parentElement) pre.outerHTML = html;
2030
+ }).catch(() => {
2031
+ });
1890
2032
  }
1891
- styleElement.textContent = collectRendererStyles();
1892
2033
  }
1893
2034
  const textRenderer = {
1894
2035
  type: "text",
@@ -1896,41 +2037,41 @@ const textRenderer = {
1896
2037
  update: (el2, segment) => {
1897
2038
  const contentEl = el2.querySelector(".segment-content");
1898
2039
  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);
2040
+ writeStreamedMarkdown(
2041
+ el2,
2042
+ contentEl,
2043
+ segment.content,
2044
+ segment.isStreaming !== false
2045
+ );
1925
2046
  },
1926
2047
  getStyles: () => ``
1927
2048
  };
2049
+ const THINKING_ANCHOR_SLACK = 24;
1928
2050
  const thinkingRenderer = {
1929
2051
  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>`,
2052
+ /*
2053
+ * Markdown, like every other prose surface.
2054
+ *
2055
+ * The reasoning was rendered as escaped plain text in a `white-space: pre-wrap`
2056
+ * box, so a model that formats its thinking — headings, lists, `**bold**`, a
2057
+ * fenced snippet, which is most of them — showed its own syntax as literal
2058
+ * characters. Nothing said that was deliberate; it read as one, because the
2059
+ * `pre-wrap` made it look like a chosen preformatted block.
2060
+ *
2061
+ * `renderMarkdown` sanitizes its provider's output before it reaches innerHTML,
2062
+ * so this is exactly the text renderer's path, with the same guarantees and no
2063
+ * new class of risk. With no Markdown provider registered it degrades to the
2064
+ * zero-dependency default (escape + `<br>`), which is what it used to be.
2065
+ */
2066
+ 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
2067
  update: (el2, segment) => {
1932
2068
  const contentEl = el2.querySelector(".thinking-content");
1933
- if (contentEl) contentEl.textContent = segment.content;
2069
+ if (!contentEl) return;
2070
+ const wasAtBottom = contentEl.scrollHeight - contentEl.scrollTop - contentEl.clientHeight <= THINKING_ANCHOR_SLACK;
2071
+ const streaming = segment.isStreaming !== false;
2072
+ writeStreamedMarkdown(el2, contentEl, segment.content, streaming);
2073
+ if (!streaming) highlightMarkdownFences(contentEl);
2074
+ if (wasAtBottom) contentEl.scrollTop = contentEl.scrollHeight;
1934
2075
  },
1935
2076
  getStyles: () => ``
1936
2077
  };
@@ -1941,7 +2082,7 @@ const codeRenderer = {
1941
2082
  <div class="code-header">
1942
2083
  ${segment.filename ? `<span class="code-filename">${escapeHtml(segment.filename)}</span>` : `<span class="code-header-filler"></span>`}
1943
2084
  <span class="code-language">${escapeHtml(segment.language || "")}</span>
1944
- <button class="code-copy" data-action="copy" title="${contextConfig().t("copy")}">
2085
+ <button class="code-copy" data-action="copy" title="${escapeAttr(contextConfig().t("copy"))}">
1945
2086
  ${contextConfig().getIcon("copy")}
1946
2087
  </button>
1947
2088
  </div>
@@ -1961,7 +2102,8 @@ const codeRenderer = {
1961
2102
  const copyBtn = element.querySelector(".code-copy");
1962
2103
  if (copyBtn) {
1963
2104
  copyBtn.addEventListener("click", () => {
1964
- void navigator.clipboard.writeText(segment.content || "").catch(() => {
2105
+ const source = element.querySelector(".code-content-wrapper")?.textContent ?? "";
2106
+ void navigator.clipboard.writeText(source).catch(() => {
1965
2107
  });
1966
2108
  copyBtn.innerHTML = contextConfig(copyBtn).getIcon("check");
1967
2109
  copyBtn.setAttribute("title", contextConfig(copyBtn).t("copied"));
@@ -2003,8 +2145,8 @@ const terminalRenderer = {
2003
2145
  </div>
2004
2146
  <code class="terminal-command">${escapeHtml(segment.command || "")}</code>
2005
2147
  <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")}">
2148
+ ${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>` : ""}
2149
+ <button class="terminal-copy-btn" data-action="copy" aria-label="${escapeAttr(contextConfig().t("copy"))}" title="${escapeAttr(contextConfig().t("copy"))}">
2008
2150
  ${contextConfig().getIcon("copy")}
2009
2151
  </button>
2010
2152
  </div>
@@ -2013,7 +2155,7 @@ const terminalRenderer = {
2013
2155
  ${segment.exitCode !== void 0 && segment.exitCode !== 0 ? `<div class="terminal-error">Command failed with exit code ${segment.exitCode}</div>` : ""}
2014
2156
  </div>
2015
2157
  `,
2016
- setup: (element) => {
2158
+ setup: (element, segment) => {
2017
2159
  const copyBtn = element.querySelector(".terminal-copy-btn");
2018
2160
  const command = element.querySelector(".terminal-command");
2019
2161
  if (copyBtn && command) {
@@ -2031,12 +2173,12 @@ const terminalRenderer = {
2031
2173
  const runBtn = element.querySelector(".terminal-run-btn");
2032
2174
  if (runBtn) {
2033
2175
  runBtn.addEventListener("click", () => {
2034
- const segmentId = element.getAttribute("data-segment-id");
2035
2176
  element.dispatchEvent(new CustomEvent("aparte-terminal-run", {
2036
2177
  bubbles: true,
2037
2178
  composed: true,
2038
2179
  detail: {
2039
- segmentId,
2180
+ segmentId: segment.id,
2181
+ messageId: segment.messageId,
2040
2182
  command: command?.textContent || ""
2041
2183
  }
2042
2184
  }));
@@ -2131,13 +2273,13 @@ const toolCallRenderer = {
2131
2273
  }
2132
2274
  const customRenderer = contextConfig().getToolRenderer(segment.toolCall?.name);
2133
2275
  if (customRenderer) {
2134
- const html = customRenderer.render(segment);
2135
- if (html) return html;
2276
+ const out = customRenderer.render(segment);
2277
+ if (out) return out;
2136
2278
  }
2137
2279
  const statusIcon = status === "resolved" ? contextConfig().getIcon("check") : status === "aborted" || status === "rejected" ? contextConfig().getIcon("close") : "";
2138
2280
  const spinner = status === "pending" ? `<span class="tool-pill-spinner" aria-hidden="true"></span>` : "";
2139
2281
  return `
2140
- <div class="segment segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${status}">
2282
+ <div class="segment segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(status)}">
2141
2283
  <span class="tool-pill">
2142
2284
  <span class="tool-pill-icon">${contextConfig().getIcon("tool")}</span>
2143
2285
  <span class="tool-pill-name">${escapeHtml(name)}</span>
@@ -2151,10 +2293,11 @@ const toolCallRenderer = {
2151
2293
  if (segment.status === "awaiting-approval") {
2152
2294
  const toolCallId = segment.toolCall?.id;
2153
2295
  if (!toolCallId) return;
2296
+ const host = element.closest("[data-aparte-host], aparte-chat, aparte-chat-viewport");
2154
2297
  const decide = (approved) => element.dispatchEvent(new CustomEvent("aparte-tool-decision", {
2155
2298
  bubbles: true,
2156
2299
  composed: true,
2157
- detail: { toolCallId, approved }
2300
+ detail: { toolCallId, approved, targetId: host?.id || void 0 }
2158
2301
  }));
2159
2302
  element.querySelector('[data-tool-decision="approve"]')?.addEventListener("click", () => decide(true));
2160
2303
  element.querySelector('[data-tool-decision="reject"]')?.addEventListener("click", () => decide(false));
@@ -2260,442 +2403,182 @@ const pipelineWaitingRenderer = {
2260
2403
  }
2261
2404
  `
2262
2405
  };
2263
- function registerDefaultRenderers() {
2264
- defaultsInstalled = true;
2265
- for (const renderer of DEFAULT_RENDERERS) registerSegmentRenderer(renderer);
2406
+ function stripCodeFences(content) {
2407
+ let s = content;
2408
+ if (s.startsWith("```") || s.startsWith("~~~")) {
2409
+ const fenceChar = s[0];
2410
+ let i = 0;
2411
+ while (i < s.length && s[i] === fenceChar) i++;
2412
+ while (i < s.length && s[i] !== "\n") i++;
2413
+ if (i < s.length && s[i] === "\n") i++;
2414
+ s = s.slice(i);
2415
+ }
2416
+ const closeAt = findClosingFence(s);
2417
+ if (closeAt !== -1) s = s.slice(0, closeAt);
2418
+ return s.trim();
2266
2419
  }
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;
2420
+ function findClosingFence(s) {
2421
+ let i = 0;
2422
+ while (i < s.length) {
2423
+ const lineStart = i;
2424
+ let k = lineStart;
2425
+ while (k < s.length && (s[k] === " " || s[k] === " ")) k++;
2426
+ if (k < s.length && (s[k] === "`" || s[k] === "~")) {
2427
+ const fenceChar = s[k];
2428
+ let runs = 0;
2429
+ while (k < s.length && s[k] === fenceChar) {
2430
+ runs++;
2431
+ k++;
2432
+ }
2433
+ if (runs >= 3) {
2434
+ let onlyWs = true;
2435
+ while (k < s.length && s[k] !== "\n") {
2436
+ if (s[k] !== " " && s[k] !== " " && s[k] !== "\r") {
2437
+ onlyWs = false;
2438
+ break;
2439
+ }
2440
+ k++;
2441
+ }
2442
+ if (onlyWs) {
2443
+ let cut = lineStart;
2444
+ if (cut > 0 && s[cut - 1] === "\n") cut--;
2445
+ return cut;
2446
+ }
2447
+ }
2332
2448
  }
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
- });
2341
- }
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
- }
2449
+ while (i < s.length && s[i] !== "\n") i++;
2450
+ if (i < s.length) i++;
2451
+ }
2452
+ return -1;
2453
+ }
2454
+ const _lastHighlightAt = /* @__PURE__ */ new Map();
2455
+ function markThrottle(map, id, at) {
2456
+ map.delete(id);
2457
+ if (map.size >= MAX_THROTTLE_ENTRIES) {
2458
+ const oldest = map.keys().next().value;
2459
+ if (oldest !== void 0) map.delete(oldest);
2460
+ }
2461
+ map.set(id, at);
2462
+ }
2463
+ function debounceHighlight(element, paneSelector, content, lang, segId) {
2464
+ const now = Date.now();
2465
+ const last = _lastHighlightAt.get(segId) ?? 0;
2466
+ if (now - last < HIGHLIGHT_DEBOUNCE_MS) return;
2467
+ markThrottle(_lastHighlightAt, segId, now);
2468
+ void contextConfig(element).highlightCode(content, lang).then((html) => {
2469
+ const wrapper = element.querySelector(paneSelector);
2470
+ if (wrapper) wrapper.innerHTML = html;
2471
+ }).catch(() => {
2472
+ });
2473
+ }
2474
+ function labelForKind(kind) {
2475
+ switch (kind) {
2476
+ case "react":
2477
+ return "React component";
2478
+ case "html":
2479
+ return "HTML document";
2480
+ case "svg":
2481
+ return "SVG image";
2482
+ case "js":
2483
+ return "JavaScript snippet";
2484
+ case "css":
2485
+ return "CSS stylesheet";
2486
+ case "json":
2487
+ return "JSON document";
2488
+ case "markdown":
2489
+ return "Markdown document";
2490
+ case "csv":
2491
+ return "CSV table";
2492
+ case "text":
2493
+ return "Text file";
2494
+ case "python":
2495
+ return "Python script";
2496
+ case "typescript":
2497
+ return "TypeScript file";
2498
+ case "bash":
2499
+ return "Bash script";
2500
+ case "sql":
2501
+ return "SQL query";
2502
+ case "pdf":
2503
+ return "PDF generator";
2504
+ case "xlsx":
2505
+ return "Excel generator";
2506
+ case "docx":
2507
+ return "Word generator";
2508
+ default:
2509
+ return "Artifact";
2510
+ }
2511
+ }
2512
+ const MAX_THROTTLE_ENTRIES = 256;
2513
+ const HIGHLIGHT_DEBOUNCE_MS = 400;
2514
+ const PREVIEW_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:";
2515
+ function withMetaCsp(doc) {
2516
+ const meta = `<meta http-equiv="Content-Security-Policy" content="${escapeAttr(PREVIEW_CSP)}">`;
2517
+ const doctype = doc.match(/^\s*<!doctype[^>]*>/i);
2518
+ const at = doctype ? doctype[0].length : 0;
2519
+ return `${doc.slice(0, at)}<head>${meta}</head>${doc.slice(at)}`;
2520
+ }
2521
+ function buildSafePreviewDocument(kind, body, title) {
2522
+ return withMetaCsp(buildPreviewBody(kind, body, title));
2523
+ }
2524
+ function buildPreviewBody(kind, body, title) {
2525
+ switch (kind) {
2526
+ case "html": {
2527
+ if (startsWithDoctype(body)) return body;
2528
+ 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>`;
2410
2529
  }
2411
- if (isStreaming) {
2412
- const segId = element.getAttribute("data-segment-id") ?? segment.id;
2413
- debounceHighlight(element, ".code-content-wrapper", cleanContent, languageForKind(kind), segId);
2530
+ case "svg":
2531
+ return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
2532
+ <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>
2533
+ </head><body>${body}</body></html>`;
2534
+ case "js": {
2535
+ const safeBody = escapeClosingScriptTag(body);
2536
+ return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
2537
+ <style>body{margin:0;font-family:ui-sans-serif,system-ui,sans-serif;padding:1rem;background:#fff;color:#0f172a}</style>
2538
+ </head><body><div id="root"></div><script>
2539
+ try { ${safeBody}
2540
+ } catch (e) { document.getElementById('root').innerHTML = '<pre style="color:#b91c1c">' + (e && e.stack || e) + '</pre>'; }
2541
+ <\/script></body></html>`;
2414
2542
  }
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
- }
2543
+ case "css":
2544
+ return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
2545
+ <style>${body}</style></head><body>
2546
+ <div class="demo">
2547
+ <h1>Heading</h1>
2548
+ <p>Paragraph with a <a href="#">link</a> and <strong>strong</strong> text.</p>
2549
+ <button>Button</button>
2550
+ <input placeholder="Input"/>
2551
+ <ul><li>One</li><li>Two</li><li>Three</li></ul>
2552
+ </div></body></html>`;
2553
+ default:
2554
+ return `<!doctype html><html><head><meta charset="utf-8"/><title>${escapeAttr(title)}</title>
2555
+ <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>
2556
+ </head><body><pre>${escapeHtml(body)}</pre></body></html>`;
2557
+ }
2558
+ }
2559
+ function startsWithDoctype(s) {
2560
+ let i = 0;
2561
+ while (i < s.length && (s[i] === " " || s[i] === " " || s[i] === "\n" || s[i] === "\r")) i++;
2562
+ const probe = s.slice(i, i + 9).toLowerCase();
2563
+ return probe === "<!doctype";
2564
+ }
2565
+ function escapeClosingScriptTag(body) {
2566
+ let out = "";
2567
+ let i = 0;
2568
+ while (i < body.length) {
2569
+ if (body[i] === "<" && body.slice(i, i + 8).toLowerCase() === "<\/script") {
2570
+ const next = body[i + 8];
2571
+ if (next === void 0 || next === "/" || next === ">" || /\s/.test(next)) {
2572
+ out += "<\\/script";
2573
+ i += 8;
2574
+ continue;
2443
2575
  }
2444
2576
  }
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
- };
2577
+ out += body[i];
2578
+ i++;
2579
+ }
2580
+ return out;
2581
+ }
2699
2582
  const FILE_ICON_LABEL = {
2700
2583
  xlsx: "XLS",
2701
2584
  pdf: "PDF",
@@ -2715,29 +2598,10 @@ const _fileGenHandlers = /* @__PURE__ */ new Map();
2715
2598
  const MAX_FILE_GEN_HANDLERS = 32;
2716
2599
  const _lastDispatchAt = /* @__PURE__ */ new Map();
2717
2600
  const RE_DISPATCH_DEBOUNCE_MS = 3e4;
2718
- const _lastHighlightAt = /* @__PURE__ */ new Map();
2719
- const HIGHLIGHT_DEBOUNCE_MS = 400;
2720
- const MAX_THROTTLE_ENTRIES = 256;
2721
- function markThrottle(map, id, at) {
2722
- map.delete(id);
2723
- if (map.size >= MAX_THROTTLE_ENTRIES) {
2724
- const oldest = map.keys().next().value;
2725
- if (oldest !== void 0) map.delete(oldest);
2726
- }
2727
- map.set(id, at);
2728
- }
2729
- function debounceHighlight(element, paneSelector, content, lang, segId) {
2730
- const now = Date.now();
2731
- const last = _lastHighlightAt.get(segId) ?? 0;
2732
- if (now - last < HIGHLIGHT_DEBOUNCE_MS) return;
2733
- markThrottle(_lastHighlightAt, segId, now);
2734
- void contextConfig(element).highlightCode(content, lang).then((html) => {
2735
- const wrapper = element.querySelector(paneSelector);
2736
- if (wrapper) wrapper.innerHTML = html;
2737
- }).catch(() => {
2738
- });
2739
- }
2740
- if (typeof window !== "undefined") {
2601
+ let _artifactReadyHookInstalled = false;
2602
+ function installArtifactReadyHook() {
2603
+ if (_artifactReadyHookInstalled || typeof window === "undefined") return;
2604
+ _artifactReadyHookInstalled = true;
2741
2605
  window.addEventListener("aparte-artifact-ready", (event) => {
2742
2606
  const segId = event.detail?.segmentId;
2743
2607
  if (segId) markThrottle(_lastDispatchAt, segId, Date.now());
@@ -2865,6 +2729,7 @@ function setupBinaryFileArtifact(element, segment, kind) {
2865
2729
  );
2866
2730
  return;
2867
2731
  }
2732
+ if (!contextConfig().getHostHandlers().artifactRehydrate) return;
2868
2733
  const lastAt = _lastDispatchAt.get(segment.id) ?? 0;
2869
2734
  if (Date.now() - lastAt < RE_DISPATCH_DEBOUNCE_MS) return;
2870
2735
  markThrottle(_lastDispatchAt, segment.id, Date.now());
@@ -2953,44 +2818,425 @@ function showSandboxError(element, phase, errorMsg) {
2953
2818
  `;
2954
2819
  }
2955
2820
  }
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
- }
2821
+ const PREVIEWABLE_KINDS = /* @__PURE__ */ new Set(["react", "html", "svg", "js", "css"]);
2822
+ const BINARY_FILE_KINDS = /* @__PURE__ */ new Set(["pdf", "xlsx", "docx"]);
2823
+ const artifactRenderer = {
2824
+ type: "artifact",
2825
+ render: (segment) => {
2826
+ const kind = (segment.artifactType || "unknown").toLowerCase();
2827
+ if (BINARY_FILE_KINDS.has(kind)) {
2828
+ return renderBinaryFileArtifact(segment, kind);
2829
+ }
2830
+ const title = segment.title?.trim() || labelForKind(kind);
2831
+ const displayLang = languageForKind(kind);
2832
+ const isStreaming = !!segment.isStreaming;
2833
+ const previewable = PREVIEWABLE_KINDS.has(kind);
2834
+ const isBinary = BINARY_FILE_KINDS.has(kind);
2835
+ const cleanContent = stripCodeFences(segment.content || "");
2836
+ return `
2837
+ <div class="segment segment-artifact-card"
2838
+ data-segment-id="${escapeHtml(segment.id)}"
2839
+ data-artifact-type="${escapeHtml(kind)}"
2840
+ data-streaming="${isStreaming ? "true" : "false"}"
2841
+ data-tab="code"
2842
+ data-previewable="${previewable ? "true" : "false"}"
2843
+ data-binary="${isBinary ? "true" : "false"}">
2844
+ <header class="aparte-art-card__header">
2845
+ <div class="aparte-art-card__title-block">
2846
+ <span class="aparte-art-card__kind" data-kind="${escapeHtml(kind)}">${escapeHtml(displayLang)}</span>
2847
+ <span class="aparte-art-card__title">${escapeHtml(title)}</span>
2848
+ ${isStreaming ? '<span class="aparte-art-card__pulse" aria-label="Streaming"></span>' : ""}
2849
+ </div>
2850
+ <div class="aparte-art-card__actions">
2851
+ <button type="button" class="aparte-art-card__btn" data-action="copy" title="${escapeAttr(contextConfig().t("copy"))}" aria-label="Copy">
2852
+ ${contextConfig().getIcon("copy")}
2853
+ </button>
2854
+ <button type="button" class="aparte-art-card__btn" data-action="download" title="Download" aria-label="Download" ${isStreaming ? "disabled" : ""}>
2855
+ <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>
2856
+ </button>
2857
+ </div>
2858
+ </header>
2859
+ <nav class="aparte-art-card__tabs" role="tablist">
2860
+ ${previewable ? `<button type="button" role="tab" data-tab-target="preview" aria-selected="false" ${isStreaming ? "disabled" : ""}>Preview</button>` : ""}
2861
+ <button type="button" role="tab" data-tab-target="code" aria-selected="true">Code</button>
2862
+ </nav>
2863
+ <div class="aparte-art-card__body">
2864
+ <div class="aparte-art-card__pane" data-pane="code">
2865
+ <div class="code-content-wrapper">
2866
+ <pre><code class="language-${escapeHtml(displayLang)}">${escapeHtml(cleanContent)}</code></pre>
2867
+ </div>
2868
+ </div>
2869
+ ${previewable ? `
2870
+ <div class="aparte-art-card__pane" data-pane="preview">
2871
+ <div class="aparte-art-card__pending">Press Preview to run this artifact.</div>
2872
+ </div>
2873
+ ` : ""}
2874
+ </div>
2875
+ </div>
2876
+ `;
2877
+ },
2878
+ setup: (element, segment) => {
2879
+ latestSegment.set(element, segment);
2880
+ const kind = (segment.artifactType || "").toLowerCase();
2881
+ if (BINARY_FILE_KINDS.has(kind)) {
2882
+ setupBinaryFileArtifact(element, segment, kind);
2883
+ return;
2884
+ }
2885
+ const wrapper = element.querySelector(".code-content-wrapper");
2886
+ if (wrapper) {
2887
+ const displayLang = languageForKind(kind);
2888
+ const cleanContent = stripCodeFences(segment.content || "");
2889
+ void contextConfig().highlightCode(cleanContent, displayLang).then((html) => {
2890
+ wrapper.innerHTML = html;
2891
+ }).catch(() => {
2892
+ });
2893
+ }
2894
+ element.querySelectorAll("[data-tab-target]").forEach((btn) => {
2895
+ btn.addEventListener("click", () => {
2896
+ const target = btn.getAttribute("data-tab-target");
2897
+ if (!target) return;
2898
+ if (target === "preview") mountPreviewFrame(element, segment);
2899
+ element.setAttribute("data-tab", target);
2900
+ element.querySelectorAll("[data-tab-target]").forEach((b) => {
2901
+ b.setAttribute("aria-selected", b === btn ? "true" : "false");
2902
+ });
2903
+ });
2904
+ });
2905
+ const copyBtn = element.querySelector('[data-action="copy"]');
2906
+ if (copyBtn) {
2907
+ copyBtn.addEventListener("click", () => {
2908
+ const code = stripCodeFences(segment.content || "");
2909
+ void navigator.clipboard.writeText(code).catch(() => {
2910
+ });
2911
+ const original = copyBtn.innerHTML;
2912
+ copyBtn.innerHTML = contextConfig(copyBtn).getIcon("check");
2913
+ copyBtn.setAttribute("title", contextConfig(copyBtn).t("copied"));
2914
+ setTimeout(() => {
2915
+ copyBtn.innerHTML = original;
2916
+ copyBtn.setAttribute("title", contextConfig(copyBtn).t("copy"));
2917
+ }, 1500);
2918
+ });
2919
+ }
2920
+ const dlBtn = element.querySelector('[data-action="download"]');
2921
+ if (dlBtn) {
2922
+ dlBtn.addEventListener("click", () => {
2923
+ if (dlBtn.disabled) return;
2924
+ const kind2 = (segment.artifactType || "").toLowerCase();
2925
+ const isBinary = BINARY_FILE_KINDS.has(kind2);
2926
+ if (isBinary) {
2927
+ element.dispatchEvent(new CustomEvent("aparte-artifact-redownload", {
2928
+ bubbles: true,
2929
+ composed: true,
2930
+ detail: {
2931
+ segmentId: segment.id,
2932
+ mimeType: segment.mimeType,
2933
+ artifactType: segment.artifactType,
2934
+ title: segment.title,
2935
+ content: stripCodeFences(segment.content || "")
2936
+ }
2937
+ }));
2938
+ return;
2939
+ }
2940
+ downloadTextArtifact(segment);
2941
+ });
2942
+ }
2943
+ },
2944
+ update: (element, segment) => {
2945
+ const previous = latestSegment.get(element);
2946
+ latestSegment.set(element, segment);
2947
+ if (previous && previous.content !== segment.content) {
2948
+ element.querySelector('.aparte-art-card__pane[data-pane="preview"] iframe')?.remove();
2949
+ }
2950
+ const isStreaming = !!segment.isStreaming;
2951
+ const kind = (segment.artifactType || "").toLowerCase();
2952
+ if (BINARY_FILE_KINDS.has(kind)) {
2953
+ updateBinaryFileArtifact(element, segment, isStreaming);
2954
+ return;
2955
+ }
2956
+ const wasStreaming = element.getAttribute("data-streaming") === "true";
2957
+ const cleanContent = stripCodeFences(segment.content || "");
2958
+ const codeEl = element.querySelector(".code-content-wrapper code");
2959
+ if (codeEl) {
2960
+ codeEl.textContent = cleanContent;
2961
+ } else {
2962
+ const wrapper = element.querySelector(".code-content-wrapper");
2963
+ if (wrapper) {
2964
+ const displayLang = languageForKind(kind);
2965
+ wrapper.innerHTML = `<pre><code class="language-${escapeHtml(displayLang)}">${escapeHtml(cleanContent)}</code></pre>`;
2966
+ }
2967
+ }
2968
+ if (isStreaming) {
2969
+ const segId = element.getAttribute("data-segment-id") ?? segment.id;
2970
+ debounceHighlight(element, ".code-content-wrapper", cleanContent, languageForKind(kind), segId);
2971
+ }
2972
+ if (wasStreaming && !isStreaming) {
2973
+ element.setAttribute("data-streaming", "false");
2974
+ const wrapper = element.querySelector(".code-content-wrapper");
2975
+ if (wrapper) {
2976
+ const displayLang = languageForKind(kind);
2977
+ void contextConfig().highlightCode(cleanContent, displayLang).then((html) => {
2978
+ wrapper.innerHTML = html;
2979
+ }).catch(() => {
2980
+ });
2981
+ }
2982
+ element.querySelectorAll("button[disabled]").forEach((b) => {
2983
+ b.disabled = false;
2984
+ });
2985
+ }
2986
+ },
2987
+ getStyles: () => `
2988
+ .segment-artifact-card {
2989
+ display: flex; flex-direction: column;
2990
+ margin: 8px 0;
2991
+ border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
2992
+ border-radius: 12px;
2993
+ background: var(--aparte-surface, #fff);
2994
+ overflow: hidden;
2995
+ font: inherit;
2996
+ color: var(--aparte-text, inherit);
2997
+ }
2998
+ .aparte-art-card__header {
2999
+ display: flex; align-items: center; justify-content: space-between;
3000
+ padding: 8px 10px;
3001
+ border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
3002
+ background: var(--aparte-surface-2, rgba(0,0,0,0.02));
3003
+ min-height: 36px;
3004
+ }
3005
+ .aparte-art-card__title-block { display: flex; align-items: center; gap: 8px; min-width: 0; }
3006
+ .aparte-art-card__kind {
3007
+ font-size: 0.7rem; font-weight: 600;
3008
+ text-transform: uppercase; letter-spacing: 0.04em;
3009
+ padding: 2px 6px; border-radius: 4px;
3010
+ background: var(--aparte-surface, #fff);
3011
+ border: 1px solid var(--aparte-border, rgba(0,0,0,0.1));
3012
+ color: var(--aparte-text-muted, rgba(0,0,0,0.6));
3013
+ }
3014
+ .aparte-art-card__title {
3015
+ font-size: 0.85rem; font-weight: 500;
3016
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3017
+ }
3018
+ .aparte-art-card__pulse {
3019
+ width: 8px; height: 8px; border-radius: 50%;
3020
+ background: var(--aparte-accent, #3b82f6);
3021
+ animation: aparte-art-card-pulse 1.2s ease-in-out infinite;
3022
+ }
3023
+ @keyframes aparte-art-card-pulse {
3024
+ 0%, 100% { opacity: 0.35; transform: scale(0.85); }
3025
+ 50% { opacity: 1; transform: scale(1.1); }
3026
+ }
3027
+ .aparte-art-card__actions { display: flex; gap: 4px; }
3028
+ .aparte-art-card__btn {
3029
+ display: inline-flex; align-items: center; justify-content: center;
3030
+ width: 28px; height: 28px;
3031
+ background: transparent;
3032
+ border: 1px solid transparent;
3033
+ border-radius: 6px;
3034
+ color: var(--aparte-text-muted, rgba(0,0,0,0.55));
3035
+ cursor: pointer;
3036
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
3037
+ }
3038
+ .aparte-art-card__btn:hover:not(:disabled) {
3039
+ background: var(--aparte-surface-hover, rgba(0,0,0,0.04));
3040
+ border-color: var(--aparte-border, rgba(0,0,0,0.1));
3041
+ color: var(--aparte-text, inherit);
3042
+ }
3043
+ .aparte-art-card__btn:disabled { opacity: 0.4; cursor: not-allowed; }
3044
+ .aparte-art-card__tabs {
3045
+ display: flex; gap: 2px;
3046
+ padding: 4px 8px 0;
3047
+ border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
3048
+ background: var(--aparte-surface-2, rgba(0,0,0,0.02));
3049
+ }
3050
+ .aparte-art-card__tabs button {
3051
+ padding: 6px 12px;
3052
+ font-size: 0.78rem;
3053
+ background: transparent;
3054
+ border: 1px solid transparent;
3055
+ border-bottom: none;
3056
+ border-radius: 6px 6px 0 0;
3057
+ color: var(--aparte-text-muted, rgba(0,0,0,0.6));
3058
+ cursor: pointer;
3059
+ transition: background 0.12s, color 0.12s;
3060
+ }
3061
+ .aparte-art-card__tabs button[aria-selected="true"] {
3062
+ background: var(--aparte-surface, #fff);
3063
+ border-color: var(--aparte-border, rgba(0,0,0,0.08));
3064
+ color: var(--aparte-text, inherit);
3065
+ font-weight: 500;
3066
+ }
3067
+ .aparte-art-card__tabs button:disabled { opacity: 0.4; cursor: not-allowed; }
3068
+ .aparte-art-card__body {
3069
+ position: relative;
3070
+ min-height: 80px;
3071
+ max-height: 600px;
3072
+ overflow: hidden;
3073
+ }
3074
+ .aparte-art-card__pane { display: none; height: 100%; }
3075
+ .segment-artifact-card[data-tab="code"] .aparte-art-card__pane[data-pane="code"] { display: block; }
3076
+ .segment-artifact-card[data-tab="preview"] .aparte-art-card__pane[data-pane="preview"] { display: block; }
3077
+ .aparte-art-card__pane[data-pane="code"] {
3078
+ max-height: 600px; overflow: auto;
3079
+ }
3080
+ .aparte-art-card__pane[data-pane="code"] pre {
3081
+ margin: 0; padding: 12px;
3082
+ font-size: 0.82rem;
3083
+ background: var(--aparte-code-bg, #fafafa);
3084
+ }
3085
+ .aparte-art-card__frame {
3086
+ display: block;
3087
+ width: 100%; height: 480px;
3088
+ border: 0;
3089
+ background: #fff;
3090
+ }
3091
+ .aparte-art-card__pending {
3092
+ display: flex; align-items: center; justify-content: center;
3093
+ height: 120px;
3094
+ color: var(--aparte-text-muted, rgba(0,0,0,0.5));
3095
+ font-size: 0.85rem;
3096
+ font-style: italic;
3097
+ }
3098
+ /* ── Binary file artifact (xlsx/pdf/docx) ──────────────────── */
3099
+ .segment-artifact-file {
3100
+ display: flex; flex-direction: column;
3101
+ margin: 8px 0;
3102
+ border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
3103
+ border-radius: 12px;
3104
+ background: var(--aparte-surface, #fff);
3105
+ overflow: hidden;
3106
+ color: var(--aparte-text, inherit);
3107
+ font: inherit;
3108
+ }
3109
+ .aparte-art-file__card {
3110
+ display: flex; align-items: center; gap: 12px;
3111
+ padding: 12px 14px;
3112
+ background: var(--aparte-surface-2, rgba(0,0,0,0.02));
3113
+ border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
3114
+ }
3115
+ .aparte-art-file__body {
3116
+ position: relative;
3117
+ }
3118
+ .aparte-art-file__code-pane {
3119
+ max-height: 360px; overflow: auto;
3120
+ background: var(--aparte-code-bg, #fafafa);
3121
+ }
3122
+ .aparte-art-file__code-pane pre {
3123
+ margin: 0; padding: 12px;
3124
+ font-size: 0.82rem;
3125
+ font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
3126
+ }
3127
+ .aparte-art-file__preview-pane {
3128
+ max-height: 460px;
3129
+ overflow: auto;
3130
+ background: #fff;
3131
+ /* Preview is a document view — force light scheme regardless of
3132
+ the app theme, with a dark text colour so cells stay readable. */
3133
+ color: #1f2937;
3134
+ }
3135
+ .aparte-art-file__icon {
3136
+ width: 40px; height: 40px;
3137
+ border-radius: 8px;
3138
+ display: flex; align-items: center; justify-content: center;
3139
+ background: linear-gradient(135deg, #1d6f42, #0f5132);
3140
+ color: #fff;
3141
+ font-weight: 700;
3142
+ font-size: 0.78rem;
3143
+ letter-spacing: 0.04em;
3144
+ flex-shrink: 0;
3145
+ }
3146
+ .aparte-art-file__icon[data-kind="pdf"] { background: linear-gradient(135deg, #c0392b, #7d1f17); }
3147
+ .aparte-art-file__icon[data-kind="docx"] { background: linear-gradient(135deg, #1e5288, #0f3060); }
3148
+ .aparte-art-file__meta { flex: 1 1 auto; min-width: 0; }
3149
+ .aparte-art-file__meta-name {
3150
+ font-weight: 600; font-size: 0.92rem;
3151
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
3152
+ }
3153
+ .aparte-art-file__meta-sub {
3154
+ font-size: 0.78rem;
3155
+ color: var(--aparte-text-muted, rgba(0,0,0,0.55));
3156
+ }
3157
+ .aparte-art-file__actions { display: flex; gap: 6px; flex-shrink: 0; }
3158
+ .aparte-art-file__btn {
3159
+ border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
3160
+ background: var(--aparte-surface, #fff);
3161
+ color: var(--aparte-text, inherit);
3162
+ padding: 6px 10px;
3163
+ border-radius: 6px;
3164
+ font-size: 0.8rem;
3165
+ cursor: pointer;
3166
+ transition: background 0.12s ease;
3167
+ }
3168
+ .aparte-art-file__btn:hover { background: var(--aparte-surface-2, rgba(0,0,0,0.05)); }
3169
+ .aparte-art-file__btn--primary {
3170
+ background: var(--aparte-primary, #6366f1);
3171
+ color: #fff;
3172
+ border-color: var(--aparte-primary, #6366f1);
3173
+ }
3174
+ .aparte-art-file__btn--primary:hover { filter: brightness(1.07); }
3175
+ /* Hide the download button until the sandbox has produced the buffer.
3176
+ Avoids confusing the user with a disabled-but-styled-primary button
3177
+ during streaming / compiling. */
3178
+ .segment-artifact-file:not([data-state="ready"]) .aparte-art-file__btn[data-action="download"] {
3179
+ display: none;
3180
+ }
3181
+ .aparte-art-file__preview-pane table {
3182
+ border-collapse: collapse;
3183
+ width: 100%;
3184
+ font-size: 0.82rem;
3185
+ font-family: inherit;
3186
+ }
3187
+ .aparte-art-file__preview-pane th,
3188
+ .aparte-art-file__preview-pane td {
3189
+ border: 1px solid rgba(0,0,0,0.10);
3190
+ padding: 6px 10px;
3191
+ text-align: left;
3192
+ white-space: nowrap;
3193
+ color: #1f2937;
3194
+ background: #fff;
3195
+ }
3196
+ .aparte-art-file__preview-pane tr:nth-child(odd) td { background: #f9fafb; }
3197
+ .aparte-art-file__preview-pane tr:first-child td {
3198
+ background: #f3f4f6;
3199
+ font-weight: 600;
3200
+ position: sticky; top: 0;
3201
+ color: #111827;
3202
+ }
3203
+ .aparte-art-file__preview-empty {
3204
+ padding: 20px; text-align: center;
3205
+ color: var(--aparte-text-muted, rgba(0,0,0,0.5));
3206
+ font-size: 0.85rem; font-style: italic;
3207
+ }
3208
+ .segment-artifact-file[data-state="error"] .aparte-art-file__icon {
3209
+ background: linear-gradient(135deg, #c0392b, #7d1f17);
3210
+ }
3211
+ .aparte-art-file__error {
3212
+ padding: 16px 18px;
3213
+ background: rgba(239, 68, 68, 0.05);
3214
+ border-top: 1px solid rgba(239, 68, 68, 0.2);
3215
+ color: var(--aparte-text, inherit);
3216
+ }
3217
+ .aparte-art-file__error-title {
3218
+ font-weight: 600;
3219
+ font-size: 0.9rem;
3220
+ margin-bottom: 6px;
3221
+ color: #b91c1c;
3222
+ }
3223
+ .aparte-art-file__error-msg {
3224
+ font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
3225
+ font-size: 0.78rem;
3226
+ padding: 6px 10px;
3227
+ background: rgba(0,0,0,0.04);
3228
+ border-radius: 4px;
3229
+ margin-bottom: 8px;
3230
+ color: #7f1d1d;
3231
+ word-break: break-word;
3232
+ }
3233
+ .aparte-art-file__error-hint {
3234
+ font-size: 0.78rem;
3235
+ color: var(--aparte-text-muted, rgba(0,0,0,0.55));
3236
+ font-style: italic;
3237
+ }
3238
+ `
3239
+ };
2994
3240
  function languageForKind(kind) {
2995
3241
  if (kind === "react") return "jsx";
2996
3242
  if (kind === "markdown") return "md";
@@ -3030,19 +3276,6 @@ function downloadTextArtifact(segment) {
3030
3276
  document.body.removeChild(a);
3031
3277
  setTimeout(() => URL.revokeObjectURL(url), 1e3);
3032
3278
  }
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
3279
  function slugifyForFilename(text) {
3047
3280
  const lower = text.trim().toLowerCase();
3048
3281
  let out = "";
@@ -3063,63 +3296,90 @@ function slugifyForFilename(text) {
3063
3296
  if (out.endsWith("-")) out = out.slice(0, -1);
3064
3297
  return out;
3065
3298
  }
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>`;
3299
+ const latestSegment = /* @__PURE__ */ new WeakMap();
3300
+ function mountPreviewFrame(element, fallback) {
3301
+ const pane = element.querySelector('.aparte-art-card__pane[data-pane="preview"]');
3302
+ if (!pane || pane.querySelector("iframe")) return;
3303
+ const segment = latestSegment.get(element) ?? fallback;
3304
+ const kind = segment.artifactType || deriveArtifactKind(segment.mimeType ?? "", "text");
3305
+ if (!PREVIEWABLE_KINDS.has(kind)) return;
3306
+ const title = segment.title?.trim() || labelForKind(kind);
3307
+ const build = contextConfig().getArtifactPreviewBuilder() ?? buildSafePreviewDocument;
3308
+ const srcdoc = build(kind, stripCodeFences(segment.content || ""), title);
3309
+ 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>`;
3310
+ }
3311
+ const registries = /* @__PURE__ */ new WeakMap();
3312
+ function registryFor(config) {
3313
+ let reg = registries.get(config);
3314
+ if (!reg) {
3315
+ reg = { renderers: /* @__PURE__ */ new Map(), defaultsInstalled: false, defaultsDeclined: false };
3316
+ registries.set(config, reg);
3317
+ }
3318
+ return reg;
3319
+ }
3320
+ function effectiveRenderers(config) {
3321
+ const own = registryFor(config);
3322
+ if (config === aparteGlobalConfig || own.defaultsDeclined) return own.renderers;
3323
+ const merged = new Map(registryFor(aparteGlobalConfig).renderers);
3324
+ for (const [type, renderer] of own.renderers) merged.set(type, renderer);
3325
+ return merged;
3326
+ }
3327
+ let styleElement = null;
3328
+ function registerSegmentRenderer(renderer, config = contextConfig()) {
3329
+ registryFor(config).renderers.set(renderer.type, renderer);
3330
+ injectRendererStyles(config);
3331
+ }
3332
+ function declineDefaultRenderers(config = contextConfig()) {
3333
+ registryFor(config).defaultsDeclined = true;
3334
+ }
3335
+ function installDefaultRenderersOnce(config = contextConfig()) {
3336
+ const reg = registryFor(config);
3337
+ if (reg.defaultsInstalled || reg.defaultsDeclined) return;
3338
+ reg.defaultsInstalled = true;
3339
+ for (const renderer of DEFAULT_RENDERERS) {
3340
+ if (!reg.renderers.has(renderer.type)) reg.renderers.set(renderer.type, renderer);
3099
3341
  }
3342
+ injectRendererStyles(config);
3343
+ installArtifactReadyHook();
3100
3344
  }
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";
3345
+ function unregisterSegmentRenderer(type, config = contextConfig()) {
3346
+ registryFor(config).renderers.delete(type);
3106
3347
  }
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++;
3348
+ function getSegmentRenderer(type, config = contextConfig()) {
3349
+ return effectiveRenderers(config).get(type);
3350
+ }
3351
+ function getAllRenderers(config = contextConfig()) {
3352
+ return Array.from(effectiveRenderers(config).values());
3353
+ }
3354
+ function collectRendererStyles(config = contextConfig()) {
3355
+ return Array.from(registryFor(config).renderers.values()).map((r) => r.getStyles?.() || "").filter(Boolean).join("\n");
3356
+ }
3357
+ const injectedRendererStyles = /* @__PURE__ */ new Set();
3358
+ function injectRendererStyles(config = contextConfig()) {
3359
+ if (typeof document === "undefined") return;
3360
+ if (!styleElement || !styleElement.isConnected) {
3361
+ styleElement = document.createElement("style");
3362
+ styleElement.id = "aparte-renderer-styles";
3363
+ document.head.appendChild(styleElement);
3364
+ styleElement.textContent = Array.from(injectedRendererStyles).join("\n");
3121
3365
  }
3122
- return out;
3366
+ let added = false;
3367
+ for (const renderer of registryFor(config).renderers.values()) {
3368
+ const css = renderer.getStyles?.();
3369
+ if (!css || injectedRendererStyles.has(css)) continue;
3370
+ injectedRendererStyles.add(css);
3371
+ added = true;
3372
+ }
3373
+ if (added) styleElement.textContent = Array.from(injectedRendererStyles).join("\n");
3374
+ }
3375
+ function registerDefaultRenderers(config = contextConfig()) {
3376
+ const reg = registryFor(config);
3377
+ reg.defaultsInstalled = true;
3378
+ for (const renderer of DEFAULT_RENDERERS) {
3379
+ if (!reg.renderers.has(renderer.type)) registerSegmentRenderer(renderer, config);
3380
+ }
3381
+ injectRendererStyles(config);
3382
+ installArtifactReadyHook();
3123
3383
  }
3124
3384
  const DEFAULT_RENDERERS = [
3125
3385
  textRenderer,
@@ -3133,11 +3393,64 @@ const DEFAULT_RENDERERS = [
3133
3393
  artifactRenderer,
3134
3394
  pipelineWaitingRenderer
3135
3395
  ];
3396
+ const OPEN_TOOL_STATUSES = /* @__PURE__ */ new Set(["pending", "awaiting-approval"]);
3397
+ const ACTIVITY_KEYS = ["content", "output", "result"];
3398
+ function isActivity(updates) {
3399
+ return ACTIVITY_KEYS.some((key) => key in updates);
3400
+ }
3401
+ function stampSegmentOnInsert(segments, segment, messageId) {
3402
+ return {
3403
+ ...segment,
3404
+ messageId: segment.messageId ?? messageId,
3405
+ index: segment.index ?? segments.length,
3406
+ startedAt: segment.startedAt ?? Date.now()
3407
+ };
3408
+ }
3409
+ function isSegmentSettled(segment) {
3410
+ if (segment.type === "tool_call") return !OPEN_TOOL_STATUSES.has(segment.status);
3411
+ return segment.isStreaming === false;
3412
+ }
3413
+ function stampSegmentOnUpdate(segment, updates) {
3414
+ if (isSegmentSettled(segment)) return updates;
3415
+ const merged = { ...segment, ...updates };
3416
+ if (isSegmentSettled(merged)) {
3417
+ return { ...updates, endedAt: segment.endedAt ?? Date.now() };
3418
+ }
3419
+ return isActivity(updates) ? { ...updates, endedAt: Date.now() } : updates;
3420
+ }
3421
+ function segmentDuration(segment) {
3422
+ const { startedAt, endedAt } = segment;
3423
+ if (startedAt == null || endedAt == null) return void 0;
3424
+ return endedAt - startedAt;
3425
+ }
3426
+ function stampSegmentActivity(segment) {
3427
+ return isSegmentSettled(segment) ? {} : { endedAt: Date.now() };
3428
+ }
3429
+ function segmentContentUpdate(segment) {
3430
+ const content = segment.content;
3431
+ return {
3432
+ content,
3433
+ ...segment.isStreaming === false ? { isStreaming: false } : {}
3434
+ };
3435
+ }
3436
+ function isTerminalStatus(status) {
3437
+ return status != null && status !== "streaming" && status !== "pending";
3438
+ }
3439
+ function openSegmentIds(segments) {
3440
+ return segments.filter((segment) => !isSegmentSettled(segment)).map((segment) => segment.id);
3441
+ }
3442
+ function renumberSegments(segments) {
3443
+ for (let i = 0; i < segments.length; i++) {
3444
+ const segment = segments[i];
3445
+ if (segment.index === i) continue;
3446
+ segments[i] = { ...segment, index: i };
3447
+ }
3448
+ }
3136
3449
  function populateBubbleFromMessage(bubble, message, siblingInfo) {
3137
3450
  if (message.segments && message.segments.length > 0) {
3138
3451
  const existing = bubble.getSegments?.() ?? [];
3139
3452
  if (existing.length === message.segments.length) {
3140
- const settled = message.status != null && message.status !== "streaming" && message.status !== "pending";
3453
+ const settled = isTerminalStatus(message.status);
3141
3454
  for (const seg of message.segments) {
3142
3455
  if ("content" in seg) {
3143
3456
  const patch = { content: seg.content };
@@ -3191,7 +3504,7 @@ function applyRetention(messages, tree, maxMessages) {
3191
3504
  };
3192
3505
  return { messages: trimmedFlat, tree: trimmedTree };
3193
3506
  }
3194
- class ConversationManager {
3507
+ class AparteConversationManager {
3195
3508
  _adapter;
3196
3509
  _conversations = [];
3197
3510
  _activeId = null;
@@ -3243,7 +3556,7 @@ class ConversationManager {
3243
3556
  async createNew(title = "New Chat") {
3244
3557
  const now = Date.now();
3245
3558
  const conv = {
3246
- id: crypto.randomUUID(),
3559
+ id: uuid(),
3247
3560
  title,
3248
3561
  createdAt: now,
3249
3562
  updatedAt: now,
@@ -3374,7 +3687,7 @@ class ConversationManager {
3374
3687
  try {
3375
3688
  l(snapshot);
3376
3689
  } catch (err) {
3377
- console.error("[ConversationManager] listener threw:", err);
3690
+ console.error("[AparteConversationManager] listener threw:", err);
3378
3691
  }
3379
3692
  });
3380
3693
  }
@@ -3384,12 +3697,9 @@ class ConversationManager {
3384
3697
  return text.trim() || "New Chat";
3385
3698
  }
3386
3699
  }
3387
- function attachmentId() {
3388
- return typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
3389
- }
3390
3700
  function filesToAttachments(files) {
3391
3701
  return files.map((file) => ({
3392
- id: attachmentId(),
3702
+ id: uuid(),
3393
3703
  name: file.name,
3394
3704
  type: file.type || "application/octet-stream",
3395
3705
  url: URL.createObjectURL(file),
@@ -3397,6 +3707,13 @@ function filesToAttachments(files) {
3397
3707
  blob: file
3398
3708
  }));
3399
3709
  }
3710
+ function revokeAttachmentUrls(attachments) {
3711
+ if (!attachments?.length) return;
3712
+ if (typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function") return;
3713
+ for (const attachment of attachments) {
3714
+ if (attachment.url?.startsWith("blob:")) URL.revokeObjectURL(attachment.url);
3715
+ }
3716
+ }
3400
3717
  class AparteConversationController {
3401
3718
  _binding;
3402
3719
  _options;
@@ -3428,7 +3745,30 @@ class AparteConversationController {
3428
3745
  * conversation lifecycle yet.
3429
3746
  */
3430
3747
  _manager() {
3431
- return this._options.manager ?? resolveConfig(this._binding.host).getConversationManager();
3748
+ if (this._options.manager) return this._options.manager;
3749
+ const scoped = resolveConfig(this._binding.host);
3750
+ const manager = scoped.getConversationManager();
3751
+ if (!manager) this._warnManagerOnTheWrongConfig(scoped);
3752
+ return manager;
3753
+ }
3754
+ /**
3755
+ * Degraded mode is legitimate — a host that never opted into the conversation
3756
+ * lifecycle has no manager and should not be nagged. Registering one on the
3757
+ * WRONG config is not: the four wrapper hooks used to write it to the global
3758
+ * singleton unconditionally, so `config` + persistence looked like it worked
3759
+ * (the optimistic UI still appends) and saved nothing at all.
3760
+ *
3761
+ * Warned once per controller, and only in the case that is unambiguously a
3762
+ * mistake: no manager here, but one on the global.
3763
+ */
3764
+ _warnedNoManager = false;
3765
+ _warnManagerOnTheWrongConfig(scoped) {
3766
+ if (this._warnedNoManager) return;
3767
+ if (scoped === aparteGlobalConfig || !aparteGlobalConfig.getConversationManager()) return;
3768
+ this._warnedNoManager = true;
3769
+ console.warn(
3770
+ "[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."
3771
+ );
3432
3772
  }
3433
3773
  /**
3434
3774
  * Attach event listeners to the binding host. Returns an unbind function.
@@ -3443,7 +3783,7 @@ class AparteConversationController {
3443
3783
  const files = evt.detail?.files;
3444
3784
  const attachments = files?.length ? filesToAttachments(files) : void 0;
3445
3785
  const userMsg = {
3446
- id: typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
3786
+ id: uuid(),
3447
3787
  role: "user",
3448
3788
  content,
3449
3789
  timestamp: evt.detail?.timestamp ?? Date.now(),
@@ -3510,8 +3850,12 @@ class AparteConversationController {
3510
3850
  return () => this.unbind();
3511
3851
  }
3512
3852
  /** Detach event listeners. */
3853
+ /** Release handle for the hydration-retry subscription (see setConversationId). */
3854
+ _stopHydrationRetry = null;
3513
3855
  unbind() {
3514
3856
  this._persistActive();
3857
+ this._stopHydrationRetry?.();
3858
+ this._stopHydrationRetry = null;
3515
3859
  const host = this._binding.host;
3516
3860
  if (this._onSendCapture) {
3517
3861
  host.removeEventListener("aparte-send", this._onSendCapture, { capture: true });
@@ -3589,8 +3933,10 @@ class AparteConversationController {
3589
3933
  const conv = manager?.conversations.find((c) => c.id === id);
3590
3934
  if (!manager || !conv) {
3591
3935
  if (manager && !manager.initialized) {
3936
+ this._stopHydrationRetry?.();
3592
3937
  const stop = manager.subscribe(() => {
3593
3938
  stop();
3939
+ this._stopHydrationRetry = null;
3594
3940
  if (manager.conversations.some((c) => c.id === id)) {
3595
3941
  void this.setConversationId(id);
3596
3942
  } else if (this._activeId === null) {
@@ -3599,6 +3945,7 @@ class AparteConversationController {
3599
3945
  this._binding.clearMessages();
3600
3946
  }
3601
3947
  });
3948
+ this._stopHydrationRetry = stop;
3602
3949
  return;
3603
3950
  }
3604
3951
  console.warn("[ConversationController] unknown id or no manager — clearing. manager:", !!manager, "conv:", !!conv);
@@ -3680,6 +4027,9 @@ class AparteConversationController {
3680
4027
  await manager.addMessage(convId, userMsg);
3681
4028
  }
3682
4029
  }
4030
+ function cssEscape(value) {
4031
+ return value.replace(/["\\]/g, "\\$&");
4032
+ }
3683
4033
  class AparteChatHost {
3684
4034
  binding;
3685
4035
  options;
@@ -3786,7 +4136,7 @@ class AparteChatHost {
3786
4136
  const convBinding = {
3787
4137
  hostId: this.binding.hostId,
3788
4138
  host,
3789
- // Controller-driven flushes mirror ConversationManager state, not
4139
+ // Controller-driven flushes mirror AparteConversationManager state, not
3790
4140
  // user intent — deliberately NOT emitted as `messagesChange` to
3791
4141
  // avoid a feedback loop with hosts using a two-way `messages` input.
3792
4142
  setMessages: (msgs) => {
@@ -3856,12 +4206,18 @@ class AparteChatHost {
3856
4206
  const index = msgs.findIndex((m) => m.id === messageId);
3857
4207
  if (index === -1) return;
3858
4208
  const next = [...msgs];
3859
- next[index] = { ...next[index], ...updates };
4209
+ const merged = { ...next[index], ...updates };
4210
+ next[index] = merged;
3860
4211
  this.binding.setMessages(next);
3861
4212
  this.binding.onMessagesChange?.(next);
3862
4213
  if (updates.status) {
3863
4214
  this._bubbleById(messageId)?.updateMessage?.({ status: updates.status });
3864
4215
  }
4216
+ if (isTerminalStatus(merged.status) && merged.segments) {
4217
+ for (const id of openSegmentIds(merged.segments)) {
4218
+ this.updateSegment(id, { isStreaming: false });
4219
+ }
4220
+ }
3865
4221
  }
3866
4222
  /** Append/replace the last message content (streaming text). */
3867
4223
  updateLastMessage(content, options) {
@@ -3878,13 +4234,16 @@ class AparteChatHost {
3878
4234
  /** Add a segment to the last message (immediate bubble + state sync). */
3879
4235
  addSegment(segment) {
3880
4236
  this._flushStreamState();
3881
- const msgs = this.binding.getMessages();
3882
- const last = msgs[msgs.length - 1];
3883
- if (last && this._isOrphan(last.id)) return;
4237
+ const before = this.binding.getMessages();
4238
+ const target = before[before.length - 1];
4239
+ if (target && this._isOrphan(target.id)) return;
3884
4240
  this.binding.onTypingChange?.(false);
3885
- this._lastBubble()?.addSegment?.(segment);
4241
+ const msgs = before;
4242
+ const last = target;
4243
+ const stamped = last ? stampSegmentOnInsert(last.segments || [], segment, last.id) : segment;
4244
+ this._lastBubble()?.addSegment?.(stamped);
3886
4245
  if (!last) return;
3887
- const segments = [...last.segments || [], segment];
4246
+ const segments = [...last.segments || [], stamped];
3888
4247
  const next = [...msgs.slice(0, -1), { ...last, segments }];
3889
4248
  this.binding.setMessages(next);
3890
4249
  this.binding.onMessagesChange?.(next);
@@ -3895,10 +4254,12 @@ class AparteChatHost {
3895
4254
  const msgs = this.binding.getMessages();
3896
4255
  const last = msgs[msgs.length - 1];
3897
4256
  if (last && this._isOrphan(last.id)) return;
3898
- this._lastBubble()?.updateSegment?.(segmentId, updates);
4257
+ const current = last?.segments?.find((s) => s.id === segmentId);
4258
+ const stamped = current ? stampSegmentOnUpdate(current, updates) : updates;
4259
+ this._lastBubble()?.updateSegment?.(segmentId, stamped);
3899
4260
  if (!last || !last.segments) return;
3900
4261
  const segments = last.segments.map(
3901
- (s) => s.id === segmentId ? { ...s, ...updates } : s
4262
+ (s) => s.id === segmentId ? { ...s, ...stamped } : s
3902
4263
  );
3903
4264
  const next = [...msgs.slice(0, -1), { ...last, segments }];
3904
4265
  this.binding.setMessages(next);
@@ -3913,6 +4274,7 @@ class AparteChatHost {
3913
4274
  if (!last?.segments) return;
3914
4275
  const segments = last.segments.filter((s) => s.id !== segmentId);
3915
4276
  if (segments.length === last.segments.length) return;
4277
+ renumberSegments(segments);
3916
4278
  const next = [...msgs.slice(0, -1), { ...last, segments }];
3917
4279
  this.binding.setMessages(next);
3918
4280
  this.binding.onMessagesChange?.(next);
@@ -3994,7 +4356,12 @@ class AparteChatHost {
3994
4356
  if (last?.segments) {
3995
4357
  const segments = last.segments.map((s) => {
3996
4358
  const buffered = pending.get(s.id);
3997
- return buffered !== void 0 && "content" in s ? { ...s, content: buffered.base + buffered.text } : s;
4359
+ if (buffered === void 0 || !("content" in s)) return s;
4360
+ return {
4361
+ ...s,
4362
+ content: buffered.base + buffered.text,
4363
+ ...stampSegmentActivity(s)
4364
+ };
3998
4365
  });
3999
4366
  msgs = [...msgs.slice(0, -1), { ...last, segments }];
4000
4367
  changed = true;
@@ -4173,7 +4540,7 @@ class AparteChatHost {
4173
4540
  const vp = this.binding.viewport;
4174
4541
  if (!vp) return null;
4175
4542
  return vp.querySelector(
4176
- `aparte-chat-bubble[message-id="${id}"]`
4543
+ `aparte-chat-bubble[message-id="${cssEscape(id)}"]`
4177
4544
  );
4178
4545
  }
4179
4546
  _lastBubble() {
@@ -4257,31 +4624,34 @@ class AparteChatHost {
4257
4624
  });
4258
4625
  }
4259
4626
  }
4260
- function assertNever(value) {
4261
- throw new Error(`Unhandled union member: ${JSON.stringify(value)}`);
4262
- }
4263
4627
  function dispatchLifecycleEvent(target, name, detail) {
4264
- target.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
4628
+ const id = target.id || void 0;
4629
+ const stamped = detail && typeof detail === "object" ? { targetId: id, ...detail } : detail;
4630
+ target.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail: stamped }));
4265
4631
  }
4266
4632
  function dispatchArtifactLifecycle(target, messageId, segment, progress, isFinal) {
4267
4633
  const id = segment.id;
4268
4634
  const content = segment.content ?? "";
4269
- const seen = progress.get(id);
4270
- if (seen === void 0) {
4635
+ if (progress.get(id) === void 0) {
4271
4636
  target.dispatchEvent(new CustomEvent("aparte-artifact-start", {
4272
4637
  bubbles: true,
4273
4638
  composed: true,
4274
- detail: { messageId, segmentId: id, mimeType: segment.mimeType, artifactType: segment.artifactType, title: segment.title }
4639
+ detail: {
4640
+ messageId,
4641
+ segmentId: id,
4642
+ mimeType: segment.mimeType,
4643
+ artifactType: segment.artifactType,
4644
+ title: segment.title
4645
+ }
4275
4646
  }));
4276
4647
  progress.set(id, 0);
4277
4648
  }
4278
4649
  const lastLen = progress.get(id) ?? 0;
4279
4650
  if (content.length > lastLen) {
4280
- const chunk = content.slice(lastLen);
4281
4651
  target.dispatchEvent(new CustomEvent("aparte-artifact-delta", {
4282
4652
  bubbles: true,
4283
4653
  composed: true,
4284
- detail: { segmentId: id, chunk }
4654
+ detail: { segmentId: id, chunk: content.slice(lastLen) }
4285
4655
  }));
4286
4656
  progress.set(id, content.length);
4287
4657
  }
@@ -4289,10 +4659,187 @@ function dispatchArtifactLifecycle(target, messageId, segment, progress, isFinal
4289
4659
  target.dispatchEvent(new CustomEvent("aparte-artifact-ready", {
4290
4660
  bubbles: true,
4291
4661
  composed: true,
4292
- detail: { messageId, segmentId: id, mimeType: segment.mimeType, artifactType: segment.artifactType, title: segment.title, content }
4662
+ detail: {
4663
+ messageId,
4664
+ segmentId: id,
4665
+ mimeType: segment.mimeType,
4666
+ artifactType: segment.artifactType,
4667
+ title: segment.title,
4668
+ content
4669
+ }
4293
4670
  }));
4294
4671
  }
4295
4672
  }
4673
+ const INLINE_MAX_LINES = 15;
4674
+ const XML_OPEN_TAG = "<artifact";
4675
+ function partialXmlOpenTagLength(text) {
4676
+ const max = Math.min(text.length, XML_OPEN_TAG.length - 1);
4677
+ for (let k = max; k > 0; k--) {
4678
+ if (text.endsWith(XML_OPEN_TAG.slice(0, k))) return k;
4679
+ }
4680
+ return 0;
4681
+ }
4682
+ function feedXmlArtifactDelta(delta, xml, ctx) {
4683
+ const { targetElement, messageId, textParser, streamingSegmentIds, artifactProgress, artifactXmlHint } = ctx;
4684
+ let remaining = delta;
4685
+ const emitChatText = (text, syncActive = true) => {
4686
+ if (!text) return;
4687
+ const r = textParser.parse(text);
4688
+ for (const seg of r.segments) {
4689
+ if (!streamingSegmentIds.has(seg.id)) {
4690
+ targetElement.addSegment?.(seg);
4691
+ streamingSegmentIds.add(seg.id);
4692
+ } else if ("content" in seg) {
4693
+ targetElement.updateSegment?.(seg.id, segmentContentUpdate(seg));
4694
+ }
4695
+ }
4696
+ const active = textParser.getState().activeSegment;
4697
+ if (syncActive && active) {
4698
+ if (!streamingSegmentIds.has(active.id)) {
4699
+ targetElement.addSegment?.(active);
4700
+ streamingSegmentIds.add(active.id);
4701
+ } else {
4702
+ targetElement.updateSegment?.(active.id, segmentContentUpdate(active));
4703
+ }
4704
+ } else if (!r.segments.length && !active) ;
4705
+ };
4706
+ while (remaining.length > 0) {
4707
+ if (xml.state === "normal") {
4708
+ const tagStart = remaining.indexOf(XML_OPEN_TAG);
4709
+ if (tagStart === -1) {
4710
+ const held = partialXmlOpenTagLength(remaining);
4711
+ if (held > 0) {
4712
+ emitChatText(remaining.slice(0, remaining.length - held), false);
4713
+ xml.scanBuf = remaining.slice(remaining.length - held);
4714
+ xml.state = "scanning";
4715
+ } else {
4716
+ emitChatText(remaining);
4717
+ }
4718
+ remaining = "";
4719
+ } else {
4720
+ emitChatText(remaining.slice(0, tagStart), false);
4721
+ xml.scanBuf = "";
4722
+ remaining = remaining.slice(tagStart);
4723
+ xml.state = "scanning";
4724
+ }
4725
+ } else if (xml.state === "scanning") {
4726
+ xml.scanBuf += remaining;
4727
+ remaining = "";
4728
+ const cmp = Math.min(xml.scanBuf.length, XML_OPEN_TAG.length);
4729
+ if (xml.scanBuf.slice(0, cmp) !== XML_OPEN_TAG.slice(0, cmp)) {
4730
+ emitChatText(xml.scanBuf);
4731
+ xml.scanBuf = "";
4732
+ xml.state = "normal";
4733
+ continue;
4734
+ }
4735
+ const gtIdx = xml.scanBuf.indexOf(">");
4736
+ if (gtIdx !== -1) {
4737
+ const tag = xml.scanBuf.slice(0, gtIdx + 1);
4738
+ const mimeMatch = /mimeType=['"]([^'"]+)['"]/.exec(tag);
4739
+ const titleMatch = /title=['"]([^'"]+)['"]/.exec(tag);
4740
+ xml.mime = mimeMatch?.[1] ?? artifactXmlHint.mimeType;
4741
+ xml.title = titleMatch?.[1] ?? artifactXmlHint.kind;
4742
+ xml.kind = deriveArtifactKind(xml.mime, artifactXmlHint.kind);
4743
+ xml.segId = `artifact-xml-${uuid()}`;
4744
+ xml.content = "";
4745
+ const openSeg = {
4746
+ id: xml.segId,
4747
+ type: "artifact",
4748
+ mimeType: xml.mime,
4749
+ artifactType: xml.kind,
4750
+ title: xml.title,
4751
+ content: ""
4752
+ };
4753
+ targetElement.addSegment?.(openSeg);
4754
+ streamingSegmentIds.add(xml.segId);
4755
+ dispatchArtifactLifecycle(targetElement, messageId, openSeg, artifactProgress, false);
4756
+ xml.state = "in-artifact";
4757
+ remaining = xml.scanBuf.slice(gtIdx + 1);
4758
+ xml.scanBuf = "";
4759
+ }
4760
+ } else {
4761
+ const CLOSE = "</artifact>";
4762
+ const combined = xml.closeBuf + remaining;
4763
+ const closeIdx = combined.indexOf(CLOSE);
4764
+ if (closeIdx !== -1) {
4765
+ xml.content += combined.slice(0, closeIdx);
4766
+ const lineCount = xml.content.split("\n").length;
4767
+ const isInline = lineCount < INLINE_MAX_LINES;
4768
+ const finalSeg = {
4769
+ id: xml.segId,
4770
+ mimeType: xml.mime,
4771
+ artifactType: xml.kind,
4772
+ title: xml.title,
4773
+ content: xml.content
4774
+ };
4775
+ targetElement.updateSegment?.(xml.segId, { content: xml.content, inline: isInline });
4776
+ dispatchArtifactLifecycle(targetElement, messageId, finalSeg, artifactProgress, true);
4777
+ xml.state = "normal";
4778
+ xml.closeBuf = "";
4779
+ remaining = combined.slice(closeIdx + CLOSE.length);
4780
+ } else {
4781
+ const safeLen = Math.max(0, combined.length - CLOSE.length + 1);
4782
+ const safe = combined.slice(0, safeLen);
4783
+ xml.content += safe;
4784
+ xml.closeBuf = combined.slice(safeLen);
4785
+ remaining = "";
4786
+ if (xml.segId) {
4787
+ targetElement.updateSegment?.(xml.segId, { content: xml.content });
4788
+ dispatchArtifactLifecycle(targetElement, messageId, {
4789
+ id: xml.segId,
4790
+ mimeType: xml.mime,
4791
+ artifactType: xml.kind,
4792
+ title: xml.title,
4793
+ content: xml.content
4794
+ }, artifactProgress, false);
4795
+ }
4796
+ }
4797
+ }
4798
+ }
4799
+ }
4800
+ function finalizeXmlArtifact(xml, ctx) {
4801
+ const { targetElement, messageId, artifactProgress, artifactXmlHint } = ctx;
4802
+ if (xml.state === "scanning") {
4803
+ const held = xml.scanBuf;
4804
+ xml.scanBuf = "";
4805
+ xml.state = "normal";
4806
+ if (held) {
4807
+ const r = ctx.textParser.parse(held);
4808
+ for (const seg of r.segments) {
4809
+ if (!ctx.streamingSegmentIds.has(seg.id)) {
4810
+ targetElement.addSegment?.(seg);
4811
+ ctx.streamingSegmentIds.add(seg.id);
4812
+ } else if ("content" in seg) {
4813
+ targetElement.updateSegment?.(seg.id, segmentContentUpdate(seg));
4814
+ }
4815
+ }
4816
+ const active = ctx.textParser.getState().activeSegment;
4817
+ if (active) {
4818
+ if (!ctx.streamingSegmentIds.has(active.id)) {
4819
+ targetElement.addSegment?.(active);
4820
+ ctx.streamingSegmentIds.add(active.id);
4821
+ } else {
4822
+ targetElement.updateSegment?.(active.id, segmentContentUpdate(active));
4823
+ }
4824
+ }
4825
+ }
4826
+ return;
4827
+ }
4828
+ if (artifactXmlHint && xml.state === "in-artifact" && xml.segId) {
4829
+ xml.content += xml.closeBuf;
4830
+ const lineCount = xml.content.split("\n").length;
4831
+ const isInline = lineCount < INLINE_MAX_LINES;
4832
+ targetElement.updateSegment?.(xml.segId, { content: xml.content, inline: isInline });
4833
+ dispatchArtifactLifecycle(targetElement, messageId, {
4834
+ id: xml.segId,
4835
+ mimeType: xml.mime,
4836
+ artifactType: xml.kind,
4837
+ title: xml.title,
4838
+ content: xml.content
4839
+ }, artifactProgress, true);
4840
+ console.warn("[AparteClient] XML artifact finalized without closing tag — content may be partial.");
4841
+ }
4842
+ }
4296
4843
  function createStreamAdapter(opts) {
4297
4844
  const { target, config, messageId, artifactHint } = opts;
4298
4845
  let parser = new AparteStreamParser();
@@ -4322,7 +4869,7 @@ function createStreamAdapter(opts) {
4322
4869
  thinkingContent += e.delta;
4323
4870
  if (!thinkingId) {
4324
4871
  const seg = {
4325
- id: `think-${crypto.randomUUID()}`,
4872
+ id: `think-${uuid()}`,
4326
4873
  type: "thinking",
4327
4874
  content: thinkingContent,
4328
4875
  collapsed: true,
@@ -4338,7 +4885,7 @@ function createStreamAdapter(opts) {
4338
4885
  }
4339
4886
  case "text-delta": {
4340
4887
  if (thinkingId && !thinkingCollapsed) {
4341
- target.updateSegment?.(thinkingId, { collapsed: true });
4888
+ target.updateSegment?.(thinkingId, { collapsed: true, isStreaming: false });
4342
4889
  thinkingCollapsed = true;
4343
4890
  }
4344
4891
  if (e.reduced) {
@@ -4347,12 +4894,10 @@ function createStreamAdapter(opts) {
4347
4894
  if (!streaming.has(segment.id)) {
4348
4895
  target.addSegment?.(segment);
4349
4896
  streaming.add(segment.id);
4897
+ } else if ("content" in segment) {
4898
+ target.updateSegment?.(segment.id, segmentContentUpdate(segment));
4350
4899
  }
4351
4900
  }
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
4901
  break;
4357
4902
  }
4358
4903
  const result = parser.parse(e.delta);
@@ -4374,7 +4919,7 @@ function createStreamAdapter(opts) {
4374
4919
  target.addSegment?.(segment);
4375
4920
  streaming.add(segment.id);
4376
4921
  } else if ("content" in segment) {
4377
- target.updateSegment?.(segment.id, { content: segment.content });
4922
+ target.updateSegment?.(segment.id, segmentContentUpdate(segment));
4378
4923
  }
4379
4924
  if (segment.type === "artifact") {
4380
4925
  dispatchArtifactLifecycle(target, messageId, segment, artifactProgress, true);
@@ -4389,15 +4934,12 @@ function createStreamAdapter(opts) {
4389
4934
  dispatchArtifactLifecycle(target, messageId, active, artifactProgress, false);
4390
4935
  }
4391
4936
  } else {
4392
- target.updateSegment?.(active.id, { content: active.content });
4937
+ target.updateSegment?.(active.id, segmentContentUpdate(active));
4393
4938
  if (active.type === "artifact") {
4394
4939
  dispatchArtifactLifecycle(target, messageId, active, artifactProgress, false);
4395
4940
  }
4396
4941
  }
4397
- } else if (result.segments.length === 0) {
4398
- if (target.typeName) target.typeName(e.delta);
4399
- else target.updateLastMessage?.(e.delta, { append: true });
4400
- }
4942
+ } else if (result.segments.length === 0) ;
4401
4943
  break;
4402
4944
  }
4403
4945
  case "text-flush": {
@@ -4423,7 +4965,7 @@ function createStreamAdapter(opts) {
4423
4965
  }
4424
4966
  for (const s of finals) {
4425
4967
  if (!streaming.has(s.id)) target.addSegment?.(s);
4426
- else if ("content" in s) target.updateSegment?.(s.id, { content: s.content });
4968
+ else if ("content" in s) target.updateSegment?.(s.id, segmentContentUpdate(s));
4427
4969
  if (s.type === "artifact") {
4428
4970
  dispatchArtifactLifecycle(target, messageId, s, artifactProgress, true);
4429
4971
  }
@@ -4517,7 +5059,7 @@ function createStreamAdapter(opts) {
4517
5059
  case "turn-limit-exceeded":
4518
5060
  if (e.scope === "global") {
4519
5061
  target.addSegment?.({
4520
- id: `max-turns-${crypto.randomUUID()}`,
5062
+ id: `max-turns-${uuid()}`,
4521
5063
  type: "error",
4522
5064
  content: `Stopped after ${e.limit} tool calls to prevent an infinite loop.`,
4523
5065
  details: "MAX_TURNS_EXCEEDED"
@@ -4527,7 +5069,7 @@ function createStreamAdapter(opts) {
4527
5069
  }
4528
5070
  break;
4529
5071
  case "phase-advance":
4530
- target.addSegment?.({ id: `pw-${crypto.randomUUID()}`, type: "pipeline-waiting" });
5072
+ target.addSegment?.({ id: `pw-${uuid()}`, type: "pipeline-waiting" });
4531
5073
  break;
4532
5074
  case "run-aborted":
4533
5075
  dispatchLifecycleEvent(target, "aparte-message-aborted", { messageId });
@@ -4564,6 +5106,10 @@ function readableToAsyncIterable(stream, signal) {
4564
5106
  },
4565
5107
  async return() {
4566
5108
  signal.removeEventListener("abort", onAbort);
5109
+ try {
5110
+ await reader.cancel();
5111
+ } catch {
5112
+ }
4567
5113
  try {
4568
5114
  reader.releaseLock();
4569
5115
  } catch {
@@ -4574,7 +5120,20 @@ function readableToAsyncIterable(stream, signal) {
4574
5120
  }
4575
5121
  };
4576
5122
  }
4577
- const TOOL_HANDLER_TIMEOUT_MS = 5 * 60 * 1e3;
5123
+ const DEFAULT_TOOL_HANDLER_TIMEOUT_MS = 5 * 60 * 1e3;
5124
+ function withToolTimeout(work, controller, timeoutMs) {
5125
+ let timer;
5126
+ const timedOut = new Promise((_resolve, reject) => {
5127
+ timer = setTimeout(() => {
5128
+ controller.abort();
5129
+ reject(Object.assign(new Error(`tool handler exceeded ${timeoutMs}ms`), { name: "AbortError" }));
5130
+ }, timeoutMs);
5131
+ });
5132
+ return {
5133
+ raced: Promise.race([work(controller.signal), timedOut]),
5134
+ done: () => clearTimeout(timer)
5135
+ };
5136
+ }
4578
5137
  class AparteClient {
4579
5138
  _boundHandler = null;
4580
5139
  _boundAbortHandler = null;
@@ -4583,6 +5142,30 @@ class AparteClient {
4583
5142
  _boundEditHandler = null;
4584
5143
  _activeToolControllers = /* @__PURE__ */ new Set();
4585
5144
  _isAborted = false;
5145
+ /**
5146
+ * Does this window event belong to this client?
5147
+ *
5148
+ * One rule for all five handlers, because it used to be four near-copies and
5149
+ * one omission: `aparte-compact` had no guard at all, so in the two-client
5150
+ * layout the JSDoc documents, a single compact event made BOTH clients run —
5151
+ * two paid summarisation calls against whichever chat the DOM scan found
5152
+ * first, and a global reset that wiped the other conversation.
5153
+ *
5154
+ * A scoped client also answers only events ADDRESSED to it. The old guard let
5155
+ * an untargeted event through to every scoped client, which turned one
5156
+ * broadcast into an action on every chat on the page.
5157
+ */
5158
+ _isForThisInstance(e) {
5159
+ if (!e) return true;
5160
+ const detail = e.detail;
5161
+ const scope = this.options.scopeToTargetId;
5162
+ if (scope) return detail?.targetId === scope;
5163
+ if (this._config === aparteGlobalConfig) return true;
5164
+ const target = this._resolveTarget(detail?.targetId);
5165
+ if (!target) return true;
5166
+ const owner = resolveConfig(target);
5167
+ return owner === this._config || owner === aparteGlobalConfig;
5168
+ }
4586
5169
  /** Aborts the in-flight vendor/transport fetch when the user stops a stream. */
4587
5170
  _streamController = null;
4588
5171
  options;
@@ -4593,11 +5176,11 @@ class AparteClient {
4593
5176
  autoRegister: true,
4594
5177
  ...options
4595
5178
  };
4596
- this._config = options.config ?? AparteConfig;
5179
+ this._config = options.config ?? aparteGlobalConfig;
4597
5180
  if (this.options.autoRegister) {
4598
- registerDefaultRenderers();
5181
+ registerDefaultRenderers(this._config);
4599
5182
  } else {
4600
- declineDefaultRenderers();
5183
+ declineDefaultRenderers(this._config);
4601
5184
  }
4602
5185
  this._setupListeners();
4603
5186
  }
@@ -4610,10 +5193,7 @@ class AparteClient {
4610
5193
  this._boundHandler = (e) => {
4611
5194
  const event = e;
4612
5195
  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
- }
5196
+ if (!this._isForThisInstance(event)) return;
4617
5197
  this._isAborted = false;
4618
5198
  for (const controller of this._activeToolControllers) {
4619
5199
  controller.abort();
@@ -4634,27 +5214,23 @@ class AparteClient {
4634
5214
  }
4635
5215
  if (!this._boundAbortHandler) {
4636
5216
  this._boundAbortHandler = (e) => {
4637
- if (this.options.scopeToTargetId) {
4638
- const evtTargetId = e?.detail?.targetId;
4639
- if (evtTargetId && evtTargetId !== this.options.scopeToTargetId) return;
4640
- }
5217
+ if (!this._isForThisInstance(e)) return;
4641
5218
  this.abort();
4642
5219
  };
4643
5220
  }
4644
5221
  window.addEventListener("aparte-abort", this._boundAbortHandler);
4645
5222
  if (!this._boundCompactHandler) {
4646
- this._boundCompactHandler = () => {
4647
- void this.compact();
5223
+ this._boundCompactHandler = (e) => {
5224
+ if (!this._isForThisInstance(e)) return;
5225
+ const detail = e.detail;
5226
+ void this.compact(detail?.targetId);
4648
5227
  };
4649
5228
  }
4650
5229
  window.addEventListener("aparte-compact", this._boundCompactHandler);
4651
5230
  if (!this._boundRetryHandler) {
4652
5231
  this._boundRetryHandler = (e) => {
4653
5232
  const evt = e;
4654
- if (this.options.scopeToTargetId) {
4655
- const evtTargetId = evt.detail?.targetId;
4656
- if (evtTargetId && evtTargetId !== this.options.scopeToTargetId) return;
4657
- }
5233
+ if (!this._isForThisInstance(evt)) return;
4658
5234
  void this._handleRetry(evt);
4659
5235
  };
4660
5236
  }
@@ -4662,10 +5238,7 @@ class AparteClient {
4662
5238
  if (!this._boundEditHandler) {
4663
5239
  this._boundEditHandler = (e) => {
4664
5240
  const evt = e;
4665
- if (this.options.scopeToTargetId) {
4666
- const evtTargetId = evt.detail?.targetId;
4667
- if (evtTargetId && evtTargetId !== this.options.scopeToTargetId) return;
4668
- }
5241
+ if (!this._isForThisInstance(evt)) return;
4669
5242
  void this._handleEdit(evt);
4670
5243
  };
4671
5244
  }
@@ -4675,6 +5248,7 @@ class AparteClient {
4675
5248
  * Stop listening.
4676
5249
  */
4677
5250
  stop() {
5251
+ this.abort();
4678
5252
  if (!this._boundHandler) return;
4679
5253
  window.removeEventListener("aparte-send", this._boundHandler);
4680
5254
  this._boundHandler = null;
@@ -4718,7 +5292,27 @@ class AparteClient {
4718
5292
  * approval to `{ approved: false }` — there is no timeout, since a human may
4719
5293
  * take any amount of time to decide.
4720
5294
  */
4721
- _awaitToolDecision(toolCallId, signal) {
5295
+ /**
5296
+ * Await the human's Approve/Reject for one tool call.
5297
+ *
5298
+ * `target` is not decoration: it is the scope of the consent. The listener sits
5299
+ * on `document` and used to accept any `aparte-tool-decision` whose
5300
+ * `detail.toolCallId` matched — and that id is the tool-call id the MODEL chose.
5301
+ * The built-in buttons dispatch with `bubbles: true, composed: true`, so on a
5302
+ * page with two chats a click aimed at one tool could satisfy the gate awaiting
5303
+ * a different tool in a different conversation. The consented action and the
5304
+ * executed action came apart, which is the entire failure mode an approval gate
5305
+ * exists to prevent — and the handler behind it is arbitrary consumer code.
5306
+ *
5307
+ * The check is DOM CONTAINMENT rather than a `targetId` string comparison. A
5308
+ * model can choose an id; it cannot choose where in the tree the click happened.
5309
+ * It also needs no change on the dispatch side, so a consumer's own Approve
5310
+ * button keeps working as long as it fires from inside its own chat.
5311
+ *
5312
+ * The request half of this handshake was hardened with `targetId` for exactly
5313
+ * this hazard. This is its sibling, and it was missed.
5314
+ */
5315
+ _awaitToolDecision(toolCallId, signal, target) {
4722
5316
  return new Promise((resolve) => {
4723
5317
  if (signal.aborted) {
4724
5318
  resolve({ approved: false });
@@ -4731,6 +5325,7 @@ class AparteClient {
4731
5325
  const onDecision = (e) => {
4732
5326
  const detail = e.detail;
4733
5327
  if (detail?.toolCallId !== toolCallId) return;
5328
+ if (target && e.target instanceof Node && e.target !== document && !target.contains(e.target)) return;
4734
5329
  cleanup();
4735
5330
  resolve({ approved: detail?.approved === true, payload: detail?.payload });
4736
5331
  };
@@ -4744,7 +5339,7 @@ class AparteClient {
4744
5339
  }
4745
5340
  /**
4746
5341
  * Resolve the auth for a provider: `options.keyResolver` takes precedence,
4747
- * then the AparteConfig key channel (`setKeyProvider`) so a key registered
5342
+ * then the aparteGlobalConfig key channel (`setKeyProvider`) so a key registered
4748
5343
  * there reaches the request. One key source on the happy path.
4749
5344
  */
4750
5345
  async _resolveAuth(providerId) {
@@ -4781,12 +5376,24 @@ class AparteClient {
4781
5376
  if (baseRequest.toolChoice === "none") {
4782
5377
  baseRequest = { ...baseRequest, tools: void 0 };
4783
5378
  }
4784
- this._isAborted = false;
4785
- this._dispatchLifecycleEvent(targetElement, "aparte-message-start", { messageId, role: "assistant" });
5379
+ if (this._isAborted) {
5380
+ dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5381
+ return;
5382
+ }
5383
+ dispatchLifecycleEvent(targetElement, "aparte-message-start", { messageId, role: "assistant" });
4786
5384
  try {
4787
5385
  const usage = await this._streamLoop(targetElement, messageId, provider, baseRequest, authConfig);
4788
- this._dispatchLifecycleEvent(targetElement, "aparte-message-done", { messageId, role: "assistant", usage });
5386
+ if (this._isAborted) {
5387
+ this._updateMessage(targetElement, messageId, { status: "completed" });
5388
+ } else {
5389
+ dispatchLifecycleEvent(targetElement, "aparte-message-done", { messageId, role: "assistant", usage });
5390
+ }
4789
5391
  } catch (error) {
5392
+ if (this._isAborted) {
5393
+ dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5394
+ this._updateMessage(targetElement, messageId, { status: "completed" });
5395
+ return;
5396
+ }
4790
5397
  const aparteError = AparteError.from(error, AparteErrorCode.UNKNOWN_ERROR);
4791
5398
  this._handleLifecycleError(targetElement, messageId, aparteError);
4792
5399
  }
@@ -4798,20 +5405,9 @@ class AparteClient {
4798
5405
  * Triggered programmatically or by dispatching `window.dispatchEvent(new CustomEvent('aparte-compact'))`.
4799
5406
  * Dispatches `aparte-compact-done` on window when complete, or `aparte-compact-error` on failure.
4800
5407
  */
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
- }
5408
+ async compact(targetId) {
5409
+ let target = this._resolveTarget(targetId);
5410
+ if (target && typeof target.getMessages !== "function") target = null;
4815
5411
  if (!target) {
4816
5412
  window.dispatchEvent(new CustomEvent("aparte-compact-error", { detail: { error: "No aparte-chat target found" } }));
4817
5413
  return;
@@ -4860,7 +5456,14 @@ class AparteClient {
4860
5456
  modelId: config.defaultModel || "",
4861
5457
  stream: false
4862
5458
  };
4863
- const response = await this._config.getTransport().chat(provider, summarizeRequest, authConfig, { providerId: provider.id });
5459
+ const compactController = new AbortController();
5460
+ this._streamController = compactController;
5461
+ const response = await this._config.getTransport().chat(
5462
+ provider,
5463
+ summarizeRequest,
5464
+ authConfig,
5465
+ { providerId: provider.id, signal: compactController.signal }
5466
+ );
4864
5467
  let summary;
4865
5468
  if (typeof response === "string") {
4866
5469
  summary = response;
@@ -4878,10 +5481,12 @@ class AparteClient {
4878
5481
  if (!summary.trim()) {
4879
5482
  throw new Error("Empty summary returned by model");
4880
5483
  }
4881
- window.dispatchEvent(new CustomEvent("aparte-reset"));
5484
+ const clearAll = target.clearAll;
5485
+ if (typeof clearAll === "function") clearAll.call(target);
5486
+ else window.dispatchEvent(new CustomEvent("aparte-reset"));
4882
5487
  await new Promise((resolve) => setTimeout(resolve, 50));
4883
5488
  target.appendMessage?.({
4884
- id: crypto.randomUUID(),
5489
+ id: uuid(),
4885
5490
  role: "assistant",
4886
5491
  content: `📝 **Conversation summary**
4887
5492
 
@@ -4904,17 +5509,41 @@ ${summary}`,
4904
5509
  * using the same conversation history minus the retried reply.
4905
5510
  */
4906
5511
  /**
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.
5512
+ * The registered tools, unless the current model says it cannot call one.
5513
+ *
5514
+ * Single source for the gate, so send / retry / edit cannot drift — that drift
5515
+ * is what once shipped `tools` on the initial send while retry and edit omitted
5516
+ * them.
5517
+ *
5518
+ * The gate used to be "the model DECLARES function_calling", defaulting to
5519
+ * stripping — and that made the whole tool surface unreachable on the primary
5520
+ * documented path. Three things had to line up for a tool to be sent, and one
5521
+ * never did: the app sets a model id by hand (`setModelConfig`) or a selector
5522
+ * sets it from a fetched list, and in both cases the model's declared
5523
+ * capabilities are commonly unknown. `undefined` capabilities then read as "no
5524
+ * function calling", so `tools: []` went on the wire while `getTools()` held
5525
+ * the tool the app had explicitly registered. The model answered, correctly,
5526
+ * that it had no such tool, and nothing anywhere said why.
5527
+ *
5528
+ * So the question is now "did this model say it cannot", not "did it say it
5529
+ * can". Registering a tool is an explicit act by the app; silently dropping it
5530
+ * because a `/models` listing is terse is the library second-guessing the
5531
+ * developer. A model that declares its capabilities and omits function calling
5532
+ * is still honoured — that is a statement, and it is respected.
5533
+ *
5534
+ * Failure modes, both ways round: over-sending means a model that cannot use
5535
+ * tools does not call one, or its own endpoint rejects the array with an error
5536
+ * the developer can read. Under-sending was silent, total, and looked like a
5537
+ * lying model.
4911
5538
  */
4912
5539
  _toolsForCurrentModel() {
4913
- const supportsFunctionCalling = this._config.getCurrentModel()?.capabilities?.includes("function_calling") ?? false;
4914
- return supportsFunctionCalling ? this._config.getTools() : [];
5540
+ const capabilities = this._config.getCurrentModel()?.capabilities;
5541
+ const declinesTools = capabilities ? !capabilities.includes("function_calling") : false;
5542
+ return declinesTools ? [] : this._config.getTools();
4915
5543
  }
4916
5544
  async _handleRetry(event) {
4917
5545
  const { messageId, targetId } = event.detail ?? {};
5546
+ this._isAborted = false;
4918
5547
  if (!messageId) return;
4919
5548
  const targetElement = this._resolveTarget(targetId);
4920
5549
  if (!targetElement) {
@@ -4925,9 +5554,9 @@ ${summary}`,
4925
5554
  const retryIdx = allMessages.findIndex((m) => m.id === messageId);
4926
5555
  const retryMsg = retryIdx >= 0 ? allMessages[retryIdx] : void 0;
4927
5556
  const sliceEnd = retryMsg?.role === "user" ? retryIdx + 1 : retryIdx;
4928
- const historyMessages = retryIdx > 0 ? allMessages.slice(0, sliceEnd) : allMessages;
5557
+ const historyMessages = retryIdx >= 0 ? allMessages.slice(0, sliceEnd) : allMessages;
4929
5558
  const newMsg = {
4930
- id: crypto.randomUUID(),
5559
+ id: uuid(),
4931
5560
  role: "assistant",
4932
5561
  content: "",
4933
5562
  status: "pending",
@@ -4960,6 +5589,7 @@ ${summary}`,
4960
5589
  */
4961
5590
  async _handleEdit(event) {
4962
5591
  const { messageId, content: newContent, targetId } = event.detail ?? {};
5592
+ this._isAborted = false;
4963
5593
  if (!messageId || newContent === void 0) return;
4964
5594
  const targetElement = this._resolveTarget(targetId);
4965
5595
  if (!targetElement) {
@@ -4988,7 +5618,7 @@ ${summary}`,
4988
5618
  const provider = this._config.getAIProvider(providerId);
4989
5619
  if (!provider) return;
4990
5620
  const authConfig = await this._resolveAuth(providerId);
4991
- const newMessageId = crypto.randomUUID();
5621
+ const newMessageId = uuid();
4992
5622
  targetElement.appendMessage?.({
4993
5623
  id: newMessageId,
4994
5624
  role: "assistant",
@@ -5051,6 +5681,12 @@ ${summary}`,
5051
5681
  }
5052
5682
  async _handleSend(event) {
5053
5683
  const { content, modelId, providerId: explicitProviderId } = event.detail;
5684
+ if (this._config.getRequireModelSelection() && !this._config.hasSelectedModel() && !modelId) {
5685
+ console.warn(
5686
+ "[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."
5687
+ );
5688
+ return;
5689
+ }
5054
5690
  let targetElement = null;
5055
5691
  const targetId = event.detail?.targetId;
5056
5692
  if (targetId) {
@@ -5093,7 +5729,7 @@ ${summary}`,
5093
5729
  console.warn("[AparteClient] ⚠️ No target element found with appendMessage support. Provide a targetResolver in AparteClientOptions or ensure the composer has a `target` attribute.");
5094
5730
  return;
5095
5731
  }
5096
- const messageId = crypto.randomUUID();
5732
+ const messageId = uuid();
5097
5733
  const config = this._config.getModelConfig();
5098
5734
  const providerId = explicitProviderId || config.defaultProvider;
5099
5735
  if (!providerId) {
@@ -5177,10 +5813,69 @@ ${summary}`,
5177
5813
  content: this._extractText(m)
5178
5814
  })).filter((m) => m.content.length > 0);
5179
5815
  }
5816
+ /**
5817
+ * What the model is told it said last turn — reconstructed from what was
5818
+ * RENDERED, not from the raw-text field.
5819
+ *
5820
+ * The order used to be the other way round, and it silently corrupted the
5821
+ * context on the most common opening in a coding chat: a reply starting with a
5822
+ * code fence was sent back as three backticks and nothing else. Three
5823
+ * correct-in-isolation decisions composed into it:
5824
+ *
5825
+ * 1. the parser withholds an ambiguous prefix (``` ``` ```, `` ` ``, `<`)
5826
+ * waiting for the next delta, and creates no active segment while it waits;
5827
+ * 2. so `_streamLoop` sees zero segments, concludes the parser produced
5828
+ * nothing, and appends the raw delta straight to `message.content`;
5829
+ * 3. and this method preferred `content`.
5830
+ *
5831
+ * The bubble hides its content element as soon as segments exist, so the UI was
5832
+ * perfect and only the NEXT request showed it. No unit test, no example and
5833
+ * no browser test could see it.
5834
+ *
5835
+ * `content` stays as the fallback, and that is not vestigial: a non-streaming
5836
+ * transport writes the whole reply there and creates no segments at all.
5837
+ */
5180
5838
  _extractText(message) {
5839
+ const rendered = this._segmentsToText(message.segments);
5840
+ if (rendered) return rendered;
5181
5841
  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();
5842
+ return "";
5843
+ }
5844
+ /**
5845
+ * Segments → the text a model can read back.
5846
+ *
5847
+ * Fences and the language tag are kept. Without them the model re-reads its own
5848
+ * code as prose — which is how it starts explaining a snippet it believes it
5849
+ * wrote in English. Artifacts are included for the same reason: dropping them
5850
+ * made an artifact the model had just produced invisible on the very next turn,
5851
+ * so it could not be asked to change the thing it had built.
5852
+ *
5853
+ * `thinking` stays out on purpose — it is the model's own scratchpad, and most
5854
+ * APIs neither want it back nor bill for it kindly.
5855
+ */
5856
+ _segmentsToText(segments) {
5857
+ if (!segments?.length) return "";
5858
+ const fence = "```";
5859
+ const parts = [];
5860
+ for (const segment of segments) {
5861
+ const content = segment.content ?? "";
5862
+ if (segment.type === "text") {
5863
+ if (content) parts.push(content);
5864
+ } else if (segment.type === "code") {
5865
+ const lang = segment.language ?? "";
5866
+ parts.push(`${fence}${lang}
5867
+ ${content}
5868
+ ${fence}`);
5869
+ } else if (segment.type === "artifact") {
5870
+ const title = segment.title ?? "artifact";
5871
+ const kind = segment.artifactType ?? "";
5872
+ parts.push(`${fence}${kind}
5873
+ <!-- artifact: ${title} -->
5874
+ ${content}
5875
+ ${fence}`);
5876
+ }
5877
+ }
5878
+ return parts.join("\n").trim();
5184
5879
  }
5185
5880
  /**
5186
5881
  * Which of the send's pending files get inlined into the request: the
@@ -5238,135 +5933,6 @@ ${content}
5238
5933
  );
5239
5934
  return parts.filter((p) => p !== null);
5240
5935
  }
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
5936
  /**
5371
5937
  * Turn-1 forced tool call. When `toolChoice = { name, input }`
5372
5938
  * (orchestrator-driven), execute the handler directly instead of consulting
@@ -5380,7 +5946,7 @@ ${content}
5380
5946
  if (!(turns === 1 && toolChoice && typeof toolChoice === "object" && toolChoice.input !== void 0)) {
5381
5947
  return { baseRequest, skip: false };
5382
5948
  }
5383
- const syntheticId = crypto.randomUUID();
5949
+ const syntheticId = uuid();
5384
5950
  const syntheticCall = { id: syntheticId, name: toolChoice.name, input: toolChoice.input };
5385
5951
  const toolSeg = {
5386
5952
  id: `tool-${syntheticId}`,
@@ -5403,9 +5969,16 @@ ${content}
5403
5969
  }
5404
5970
  const controller = new AbortController();
5405
5971
  this._activeToolControllers.add(controller);
5406
- const timeout = setTimeout(() => controller.abort(), TOOL_HANDLER_TIMEOUT_MS);
5972
+ const { raced, done: clearToolTimeout } = withToolTimeout(
5973
+ (sig) => handler(syntheticCall, sig, {
5974
+ target: targetElement,
5975
+ config: this._config
5976
+ }),
5977
+ controller,
5978
+ this.options.toolTimeoutMs ?? DEFAULT_TOOL_HANDLER_TIMEOUT_MS
5979
+ );
5407
5980
  try {
5408
- const result = await handler(syntheticCall, controller.signal);
5981
+ const result = await raced;
5409
5982
  targetElement.updateSegment?.(toolSeg.id, { status: "resolved", result: result.content });
5410
5983
  messages.push({ role: "tool_call", content: "", toolCalls: [syntheticCall] });
5411
5984
  messages.push({ role: "tool_result", content: result.content, toolCallId: syntheticId });
@@ -5417,7 +5990,7 @@ ${content}
5417
5990
  }
5418
5991
  throw err;
5419
5992
  } finally {
5420
- clearTimeout(timeout);
5993
+ clearToolTimeout();
5421
5994
  this._activeToolControllers.delete(controller);
5422
5995
  }
5423
5996
  }
@@ -5435,7 +6008,7 @@ ${content}
5435
6008
  if (event.name === "create_artifact") {
5436
6009
  const input = event.input;
5437
6010
  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";
6011
+ const kind = deriveArtifactKind(mimeType, "text");
5439
6012
  const artifactSeg = {
5440
6013
  id: `artifact-${event.id}`,
5441
6014
  type: "artifact",
@@ -5445,7 +6018,7 @@ ${content}
5445
6018
  content: input.content ?? ""
5446
6019
  };
5447
6020
  targetElement.addSegment?.(artifactSeg);
5448
- this._dispatchArtifactLifecycle(targetElement, messageId, artifactSeg, artifactProgress, true);
6021
+ dispatchArtifactLifecycle(targetElement, messageId, artifactSeg, artifactProgress, true);
5449
6022
  messages.push({
5450
6023
  role: "tool_call",
5451
6024
  content: "",
@@ -5499,13 +6072,13 @@ ${content}
5499
6072
  const approvalController = new AbortController();
5500
6073
  this._activeToolControllers.add(approvalController);
5501
6074
  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
- }));
6075
+ dispatchLifecycleEvent(targetElement, "aparte-tool-approval-request", {
6076
+ toolCallId: event.id,
6077
+ toolName: event.name,
6078
+ input: event.input
6079
+ });
5507
6080
  let decision;
5508
- const resolveApproval = this.options.approvalResolver ?? ((id, sig) => this._awaitToolDecision(id, sig));
6081
+ const resolveApproval = this.options.approvalResolver ?? ((id, sig) => this._awaitToolDecision(id, sig, targetElement));
5509
6082
  try {
5510
6083
  decision = await resolveApproval(event.id, approvalController.signal);
5511
6084
  } finally {
@@ -5535,12 +6108,17 @@ ${content}
5535
6108
  }
5536
6109
  const controller = new AbortController();
5537
6110
  this._activeToolControllers.add(controller);
5538
- const timeout = setTimeout(() => controller.abort(), TOOL_HANDLER_TIMEOUT_MS);
5539
- try {
5540
- const result = await handler(
6111
+ const { raced, done: clearToolTimeout } = withToolTimeout(
6112
+ (sig) => handler(
5541
6113
  { id: event.id, name: event.name, input: effectiveInput },
5542
- controller.signal
5543
- );
6114
+ sig,
6115
+ { target: targetElement, config: this._config }
6116
+ ),
6117
+ controller,
6118
+ this.options.toolTimeoutMs ?? DEFAULT_TOOL_HANDLER_TIMEOUT_MS
6119
+ );
6120
+ try {
6121
+ const result = await raced;
5544
6122
  targetElement.updateSegment?.(toolSeg.id, { status: "resolved", result: result.content });
5545
6123
  const existingToolCallMsg = messages.find(
5546
6124
  (m) => m.role === "tool_call" && m.toolCalls?.some((tc) => tc.id === event.id)
@@ -5566,7 +6144,7 @@ ${content}
5566
6144
  throw err;
5567
6145
  }
5568
6146
  } finally {
5569
- clearTimeout(timeout);
6147
+ clearToolTimeout();
5570
6148
  this._activeToolControllers.delete(controller);
5571
6149
  }
5572
6150
  } else {
@@ -5576,7 +6154,12 @@ ${content}
5576
6154
  }
5577
6155
  return { continueLoop };
5578
6156
  }
6157
+ /**
6158
+ * Stream loop: runs one provider.chat() call and repeats if a tool was called.
6159
+ * Maintains a running messages array to inject tool_call / tool_result turns.
6160
+ */
5579
6161
  async _streamLoop(targetElement, messageId, provider, baseRequest, authConfig) {
6162
+ this._streamController?.abort();
5580
6163
  const streamController = new AbortController();
5581
6164
  this._streamController = streamController;
5582
6165
  if (this.options.streamRunner) {
@@ -5595,14 +6178,14 @@ ${content}
5595
6178
  }
5596
6179
  while (continueLoop) {
5597
6180
  if (this._isAborted) {
5598
- this._dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
6181
+ dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5599
6182
  break;
5600
6183
  }
5601
6184
  turns++;
5602
6185
  if (turns > globalMaxTurns) {
5603
6186
  console.warn(`[AparteClient] maxTurns (${globalMaxTurns}) exceeded — stopping loop.`);
5604
6187
  targetElement.addSegment?.({
5605
- id: `max-turns-${crypto.randomUUID()}`,
6188
+ id: `max-turns-${uuid()}`,
5606
6189
  type: "error",
5607
6190
  content: `Stopped after ${globalMaxTurns} tool calls to prevent an infinite loop.`,
5608
6191
  details: "MAX_TURNS_EXCEEDED"
@@ -5630,7 +6213,17 @@ ${content}
5630
6213
  const request = { ...baseRequest, messages: phaseMessages, _meta: phaseMeta };
5631
6214
  const response = await this._config.getTransport().chat(provider, request, authConfig, { providerId: provider.id, signal: streamController.signal });
5632
6215
  if (typeof response === "string") {
5633
- this._updateMessage(targetElement, messageId, { content: response, status: "completed" });
6216
+ const wholeParser = new AparteStreamParser();
6217
+ const parsed = [
6218
+ ...wholeParser.parse(response).segments,
6219
+ ...wholeParser.finalize()
6220
+ ];
6221
+ if (parsed.length > 0) {
6222
+ for (const segment of parsed) targetElement.addSegment?.(segment);
6223
+ this._updateMessage(targetElement, messageId, { status: "completed" });
6224
+ } else {
6225
+ this._updateMessage(targetElement, messageId, { content: response, status: "completed" });
6226
+ }
5634
6227
  return void 0;
5635
6228
  }
5636
6229
  const reader = response.getReader();
@@ -5646,7 +6239,7 @@ ${content}
5646
6239
  let rawSegId = null;
5647
6240
  let rawContent = "";
5648
6241
  if (artifactRawHint) {
5649
- rawSegId = `artifact-raw-${crypto.randomUUID()}`;
6242
+ rawSegId = `artifact-raw-${uuid()}`;
5650
6243
  const rawSeg = {
5651
6244
  id: rawSegId,
5652
6245
  type: "artifact",
@@ -5657,7 +6250,7 @@ ${content}
5657
6250
  };
5658
6251
  targetElement.addSegment?.(rawSeg);
5659
6252
  streamingSegmentIds.add(rawSegId);
5660
- this._dispatchArtifactLifecycle(targetElement, messageId, rawSeg, artifactProgress, false);
6253
+ dispatchArtifactLifecycle(targetElement, messageId, rawSeg, artifactProgress, false);
5661
6254
  }
5662
6255
  const artifactXmlHint = baseRequest._meta?.artifactXml;
5663
6256
  const xmlCtx = {
@@ -5672,25 +6265,32 @@ ${content}
5672
6265
  };
5673
6266
  let precedingText = "";
5674
6267
  const toolCallsThisTurn = [];
6268
+ const bailOnAbort = () => {
6269
+ if (!this._isAborted) return false;
6270
+ try {
6271
+ void reader.cancel();
6272
+ } catch {
6273
+ }
6274
+ dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
6275
+ continueLoop = false;
6276
+ return true;
6277
+ };
6278
+ let streamDrained = false;
5675
6279
  try {
5676
6280
  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;
6281
+ if (bailOnAbort()) break;
6282
+ const { done, value: event } = await reader.read();
6283
+ if (bailOnAbort()) break;
6284
+ if (done) {
6285
+ streamDrained = true;
5684
6286
  break;
5685
6287
  }
5686
- const { done, value: event } = await reader.read();
5687
- if (done) break;
5688
6288
  switch (event.type) {
5689
6289
  case "thinking": {
5690
6290
  thinkingContent += event.delta;
5691
6291
  if (!thinkingSegmentId) {
5692
6292
  const seg = {
5693
- id: `think-${crypto.randomUUID()}`,
6293
+ id: `think-${uuid()}`,
5694
6294
  type: "thinking",
5695
6295
  content: thinkingContent,
5696
6296
  collapsed: true,
@@ -5706,14 +6306,14 @@ ${content}
5706
6306
  }
5707
6307
  case "text": {
5708
6308
  if (thinkingSegmentId && !thinkingCollapsed) {
5709
- targetElement.updateSegment?.(thinkingSegmentId, { collapsed: true });
6309
+ targetElement.updateSegment?.(thinkingSegmentId, { collapsed: true, isStreaming: false });
5710
6310
  thinkingCollapsed = true;
5711
6311
  }
5712
6312
  precedingText += event.delta;
5713
6313
  if (artifactRawHint && rawSegId) {
5714
6314
  rawContent += event.delta;
5715
6315
  targetElement.updateSegment?.(rawSegId, { content: rawContent });
5716
- this._dispatchArtifactLifecycle(targetElement, messageId, {
6316
+ dispatchArtifactLifecycle(targetElement, messageId, {
5717
6317
  id: rawSegId,
5718
6318
  type: "artifact",
5719
6319
  mimeType: artifactRawHint.mimeType,
@@ -5724,7 +6324,7 @@ ${content}
5724
6324
  break;
5725
6325
  }
5726
6326
  if (artifactXmlHint) {
5727
- this._feedXmlArtifactDelta(event.delta, xmlCtx, {
6327
+ feedXmlArtifactDelta(event.delta, xmlCtx, {
5728
6328
  targetElement,
5729
6329
  messageId,
5730
6330
  textParser,
@@ -5753,10 +6353,10 @@ ${content}
5753
6353
  targetElement.addSegment?.(segment);
5754
6354
  streamingSegmentIds.add(segment.id);
5755
6355
  } else if ("content" in segment) {
5756
- targetElement.updateSegment?.(segment.id, { content: segment.content });
6356
+ targetElement.updateSegment?.(segment.id, segmentContentUpdate(segment));
5757
6357
  }
5758
6358
  if (segment.type === "artifact") {
5759
- this._dispatchArtifactLifecycle(targetElement, messageId, segment, artifactProgress, true);
6359
+ dispatchArtifactLifecycle(targetElement, messageId, segment, artifactProgress, true);
5760
6360
  }
5761
6361
  }
5762
6362
  const active = textParser.getState().activeSegment;
@@ -5765,17 +6365,15 @@ ${content}
5765
6365
  targetElement.addSegment?.(active);
5766
6366
  streamingSegmentIds.add(active.id);
5767
6367
  if (active.type === "artifact") {
5768
- this._dispatchArtifactLifecycle(targetElement, messageId, active, artifactProgress, false);
6368
+ dispatchArtifactLifecycle(targetElement, messageId, active, artifactProgress, false);
5769
6369
  }
5770
6370
  } else {
5771
- targetElement.updateSegment?.(active.id, { content: active.content });
6371
+ targetElement.updateSegment?.(active.id, segmentContentUpdate(active));
5772
6372
  if (active.type === "artifact") {
5773
- this._dispatchArtifactLifecycle(targetElement, messageId, active, artifactProgress, false);
6373
+ dispatchArtifactLifecycle(targetElement, messageId, active, artifactProgress, false);
5774
6374
  }
5775
6375
  }
5776
6376
  } else if (result.segments.length === 0) {
5777
- if (targetElement.typeName) targetElement.typeName(event.delta);
5778
- else targetElement.updateLastMessage?.(event.delta, { append: true });
5779
6377
  }
5780
6378
  break;
5781
6379
  }
@@ -5792,16 +6390,22 @@ ${content}
5792
6390
  case "done":
5793
6391
  if (event.usage) lastUsage = event.usage;
5794
6392
  break;
5795
- default:
5796
- assertNever(event);
6393
+ default: {
6394
+ const exhaustive = event;
6395
+ void exhaustive;
6396
+ this._warnUnknownStreamEvent(event);
6397
+ break;
6398
+ }
5797
6399
  }
6400
+ if (!continueLoop) break;
5798
6401
  }
6402
+ finalizeXmlArtifact(xmlCtx, { targetElement, messageId, artifactProgress, artifactXmlHint, textParser, streamingSegmentIds });
5799
6403
  const finals = textParser.finalize();
5800
6404
  if (artifactRawHint && rawSegId) {
5801
6405
  const lineCount = rawContent.split("\n").length;
5802
6406
  const isInline = lineCount < 15;
5803
6407
  targetElement.updateSegment?.(rawSegId, { content: rawContent, inline: isInline });
5804
- this._dispatchArtifactLifecycle(targetElement, messageId, {
6408
+ dispatchArtifactLifecycle(targetElement, messageId, {
5805
6409
  id: rawSegId,
5806
6410
  type: "artifact",
5807
6411
  mimeType: artifactRawHint.mimeType,
@@ -5811,22 +6415,6 @@ ${content}
5811
6415
  inline: isInline
5812
6416
  }, artifactProgress, true);
5813
6417
  }
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
6418
  if (artifactHint && !artifactPromoted) {
5831
6419
  const codeIdx = finals.findIndex((s) => s.type === "code");
5832
6420
  if (codeIdx !== -1) {
@@ -5850,10 +6438,10 @@ ${content}
5850
6438
  if (!streamingSegmentIds.has(s.id)) {
5851
6439
  targetElement.addSegment?.(s);
5852
6440
  } else if ("content" in s) {
5853
- targetElement.updateSegment?.(s.id, { content: s.content });
6441
+ targetElement.updateSegment?.(s.id, segmentContentUpdate(s));
5854
6442
  }
5855
6443
  if (s.type === "artifact") {
5856
- this._dispatchArtifactLifecycle(targetElement, messageId, s, artifactProgress, true);
6444
+ dispatchArtifactLifecycle(targetElement, messageId, s, artifactProgress, true);
5857
6445
  }
5858
6446
  }
5859
6447
  if (toolCallsThisTurn.length === 0) {
@@ -5862,13 +6450,19 @@ ${content}
5862
6450
  messages.push({ role: "assistant", content: precedingText.trim() });
5863
6451
  }
5864
6452
  pipelineIndex++;
5865
- const pwId = `pw-${crypto.randomUUID()}`;
6453
+ const pwId = `pw-${uuid()}`;
5866
6454
  targetElement.addSegment?.({ id: pwId, type: "pipeline-waiting" });
5867
6455
  } else {
5868
6456
  continueLoop = false;
5869
6457
  }
5870
6458
  }
5871
6459
  } finally {
6460
+ if (!streamDrained) {
6461
+ try {
6462
+ await reader.cancel();
6463
+ } catch {
6464
+ }
6465
+ }
5872
6466
  reader.releaseLock();
5873
6467
  }
5874
6468
  }
@@ -5908,12 +6502,17 @@ ${content}
5908
6502
  const response = await this._config.getTransport().chat(provider, request, authConfig, { providerId: provider.id, signal });
5909
6503
  return typeof response === "string" ? response : readableToAsyncIterable(response, signal);
5910
6504
  };
5911
- const toolLookup = (name) => this._config.getToolHandler(name);
6505
+ const toolLookup = (name) => {
6506
+ const handler = this._config.getToolHandler(name);
6507
+ if (!handler) return void 0;
6508
+ const context = { target: targetElement, config: this._config };
6509
+ return (call, sig) => handler(call, sig, context);
6510
+ };
5912
6511
  const toolConfigLookup = (name) => {
5913
6512
  const tool = this._config.getTools().find((t) => t.name === name);
5914
6513
  return tool ? { maxTurns: tool.maxTurns, needsApproval: tool.needsApproval } : void 0;
5915
6514
  };
5916
- const approvalResolver = this.options.approvalResolver ?? ((id, sig) => this._awaitToolDecision(id, sig));
6515
+ const approvalResolver = this.options.approvalResolver ?? ((id, sig) => this._awaitToolDecision(id, sig, targetElement));
5917
6516
  const usage = await streamRunner({
5918
6517
  messageId,
5919
6518
  baseRequest,
@@ -5924,9 +6523,14 @@ ${content}
5924
6523
  emitter,
5925
6524
  signal,
5926
6525
  maxTurns: this.options.maxTurns,
6526
+ // Forwarded, because the option's own JSDoc promises it means one thing
6527
+ // whichever loop runs — and the fix that introduced it only wired the
6528
+ // inline path, so setting it was silently ignored here. Exactly the
6529
+ // asymmetry the option was added to remove.
6530
+ toolTimeoutMs: this.options.toolTimeoutMs,
5927
6531
  // Match the inline loop's id conventions: prefixed artifact ids, but a
5928
6532
  // BARE uuid for the synthetic tool (the adapter renders `tool-<id>`).
5929
- idGen: (prefix) => prefix === "synthetic-tool" ? crypto.randomUUID() : `${prefix}-${crypto.randomUUID()}`
6533
+ idGen: (prefix) => prefix === "synthetic-tool" ? uuid() : `${prefix}-${uuid()}`
5930
6534
  });
5931
6535
  return usage ?? void 0;
5932
6536
  }
@@ -5943,15 +6547,16 @@ ${content}
5943
6547
  */
5944
6548
  _handleLifecycleError(target, messageId, error) {
5945
6549
  const errorSegment = {
5946
- id: `error-${crypto.randomUUID()}`,
6550
+ id: `error-${uuid()}`,
5947
6551
  type: "error",
5948
6552
  content: error.message,
5949
6553
  details: error.code
5950
6554
  };
5951
6555
  if (target.updateMessage) {
6556
+ const rendered = target.getMessages?.()?.find((m) => m.id === messageId)?.segments ?? [];
5952
6557
  target.updateMessage(messageId, {
5953
6558
  status: "error",
5954
- segments: [errorSegment]
6559
+ segments: [...rendered, errorSegment]
5955
6560
  });
5956
6561
  } else if (target.appendMessage) {
5957
6562
  target.appendMessage({
@@ -5962,65 +6567,17 @@ ${content}
5962
6567
  segments: [errorSegment]
5963
6568
  });
5964
6569
  }
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
- }));
6570
+ dispatchLifecycleEvent(target, "aparte-message-error", { messageId, error });
5976
6571
  }
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
- }
6572
+ /** Warn once per client for an unrecognised stream event, then carry on. */
6573
+ _warnedUnknownEvents = /* @__PURE__ */ new Set();
6574
+ _warnUnknownStreamEvent(event) {
6575
+ const type = String(event?.type ?? "undefined");
6576
+ if (this._warnedUnknownEvents.has(type)) return;
6577
+ this._warnedUnknownEvents.add(type);
6578
+ console.warn(
6579
+ `[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.`
6580
+ );
6024
6581
  }
6025
6582
  }
6026
6583
  function findHead(node) {
@@ -6028,7 +6585,7 @@ function findHead(node) {
6028
6585
  if ("current" in node) return node;
6029
6586
  return null;
6030
6587
  }
6031
- class MessageRepository {
6588
+ class AparteMessageRepository {
6032
6589
  _nodes = /* @__PURE__ */ new Map();
6033
6590
  _head = null;
6034
6591
  _root = { next: null, children: [] };
@@ -6095,7 +6652,7 @@ class MessageRepository {
6095
6652
  const existing = this._nodes.get(message.id);
6096
6653
  const parentNode = parentId ? this._nodes.get(parentId) : void 0;
6097
6654
  if (parentId !== null && parentNode === void 0) {
6098
- throw new Error(`MessageRepository: parent "${parentId}" not found.`);
6655
+ throw new Error(`AparteMessageRepository: parent "${parentId}" not found.`);
6099
6656
  }
6100
6657
  const parent = parentNode ?? null;
6101
6658
  if (existing) {
@@ -6260,7 +6817,7 @@ class MessageRepository {
6260
6817
  node.next = null;
6261
6818
  }
6262
6819
  }
6263
- const DEFAULT_UI_EVENTS = [
6820
+ const APARTE_DEFAULT_UI_EVENTS = [
6264
6821
  "aparte-send",
6265
6822
  "aparte-action",
6266
6823
  "aparte-retry",
@@ -6273,8 +6830,9 @@ function applyElementProps(el2, props, transformValue = (value) => value) {
6273
6830
  for (const [key, value] of Object.entries(props)) {
6274
6831
  if (key.startsWith("--")) {
6275
6832
  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) {
6833
+ } else if (key.startsWith("on")) {
6834
+ el2.removeAttribute(key);
6835
+ } else if (value === null || value === void 0 || value === false) {
6278
6836
  el2.removeAttribute(key);
6279
6837
  } else if (value === true) {
6280
6838
  el2.setAttribute(key, "");
@@ -6295,17 +6853,30 @@ function el(tag, className, text) {
6295
6853
  if (text != null) node.textContent = text;
6296
6854
  return node;
6297
6855
  }
6298
- function fieldHeader(parent, field) {
6299
- if (field.title) parent.appendChild(el("p", "aparte-elic-title", field.title));
6856
+ function fieldHeader(parent, field, skipTitle) {
6857
+ let titleId;
6858
+ if (field.title && field.title.trim() !== skipTitle?.trim()) {
6859
+ const title = el("p", "aparte-elic-title", field.title);
6860
+ titleId = `elic-title-${uuid()}`;
6861
+ title.id = titleId;
6862
+ parent.appendChild(title);
6863
+ }
6300
6864
  if (field.description) parent.appendChild(el("p", "aparte-elic-desc", field.description));
6865
+ return titleId;
6301
6866
  }
6302
- function buildEnumField(field, onChange) {
6867
+ function labelGroup(list, opts) {
6868
+ list.setAttribute("role", opts.multiple ? "group" : "radiogroup");
6869
+ if (opts.titleId) list.setAttribute("aria-labelledby", opts.titleId);
6870
+ else if (opts.fallbackLabel) list.setAttribute("aria-label", opts.fallbackLabel);
6871
+ }
6872
+ function buildEnumField(field, onChange, fallbackLabel) {
6303
6873
  const wrap = el("div", "aparte-elic-field aparte-elic-enum");
6304
- fieldHeader(wrap, field);
6874
+ const titleId = fieldHeader(wrap, field, fallbackLabel);
6305
6875
  const list = el("div", "aparte-elic-options");
6306
- const name = `elic-${crypto.randomUUID()}`;
6876
+ labelGroup(list, { multiple: field.multiple, titleId, fallbackLabel });
6877
+ const name = `elic-${uuid()}`;
6307
6878
  const type = field.multiple ? "checkbox" : "radio";
6308
- const allowOther = field.allowOther ?? true;
6879
+ const allowOther = field.allowOther ?? contextConfig().getElicitationOptions().allowOther;
6309
6880
  const defaults = new Set(Array.isArray(field.default) ? field.default : field.default != null ? [field.default] : []);
6310
6881
  const buildOption = (value, label, description, recommended) => {
6311
6882
  const row = el("label", "aparte-elic-option" + (recommended ? " aparte-elic-option--recommended" : ""));
@@ -6331,12 +6902,13 @@ function buildEnumField(field, onChange) {
6331
6902
  control.name = name;
6332
6903
  control.value = "__other__";
6333
6904
  const body = el("span", "aparte-elic-option-body");
6334
- body.appendChild(el("span", "aparte-elic-option-title", "Other…"));
6905
+ const t = contextConfig();
6906
+ body.appendChild(el("span", "aparte-elic-option-title", t.t("elicitationOther")));
6335
6907
  otherText = el("input", "aparte-elic-other-input");
6336
6908
  otherText.type = "text";
6337
- otherText.placeholder = "Type your answer…";
6909
+ otherText.placeholder = t.t("elicitationOtherPlaceholder");
6338
6910
  otherText.style.display = "none";
6339
- otherText.setAttribute("aria-label", "Custom answer");
6911
+ otherText.setAttribute("aria-label", t.t("elicitationOtherLabel"));
6340
6912
  body.appendChild(otherText);
6341
6913
  row.append(control, body);
6342
6914
  list.appendChild(row);
@@ -6374,11 +6946,12 @@ function buildEnumField(field, onChange) {
6374
6946
  focus: () => controls()[0]?.focus()
6375
6947
  };
6376
6948
  }
6377
- function buildBooleanField(field, onChange) {
6949
+ function buildBooleanField(field, onChange, fallbackLabel) {
6378
6950
  const wrap = el("div", "aparte-elic-field aparte-elic-boolean");
6379
- fieldHeader(wrap, field);
6951
+ const titleId = fieldHeader(wrap, field, fallbackLabel);
6380
6952
  const list = el("div", "aparte-elic-options");
6381
- const name = `elic-${crypto.randomUUID()}`;
6953
+ labelGroup(list, { titleId, fallbackLabel });
6954
+ const name = `elic-${uuid()}`;
6382
6955
  const mk = (val, label) => {
6383
6956
  const row = el("label", "aparte-elic-option");
6384
6957
  const control = el("input", "aparte-elic-control");
@@ -6391,8 +6964,9 @@ function buildBooleanField(field, onChange) {
6391
6964
  row.append(control, body);
6392
6965
  list.appendChild(row);
6393
6966
  };
6394
- mk("true", field.trueLabel ?? "Yes");
6395
- mk("false", field.falseLabel ?? "No");
6967
+ const t = contextConfig();
6968
+ mk("true", field.trueLabel ?? t.t("elicitationYes"));
6969
+ mk("false", field.falseLabel ?? t.t("elicitationNo"));
6396
6970
  wrap.appendChild(list);
6397
6971
  list.addEventListener("change", onChange);
6398
6972
  const checked = () => list.querySelector("input:checked");
@@ -6403,15 +6977,15 @@ function buildBooleanField(field, onChange) {
6403
6977
  focus: () => list.querySelector("input")?.focus()
6404
6978
  };
6405
6979
  }
6406
- function buildStringField(field, onChange) {
6980
+ function buildStringField(field, onChange, fallbackLabel) {
6407
6981
  const wrap = el("div", "aparte-elic-field aparte-elic-string");
6408
- fieldHeader(wrap, field);
6982
+ fieldHeader(wrap, field, fallbackLabel);
6409
6983
  const input = field.multiline ? el("textarea", "aparte-elic-text") : el("input", "aparte-elic-text");
6410
6984
  if (!field.multiline) input.type = "text";
6411
6985
  if (field.placeholder) input.setAttribute("placeholder", field.placeholder);
6412
6986
  if (field.default) input.value = field.default;
6413
6987
  if (field.maxLength != null) input.setAttribute("maxlength", String(field.maxLength));
6414
- input.setAttribute("aria-label", field.title ?? field.description ?? "Your answer");
6988
+ input.setAttribute("aria-label", field.title ?? field.description ?? fallbackLabel ?? contextConfig().t("elicitationAnswerLabel"));
6415
6989
  wrap.appendChild(input);
6416
6990
  input.addEventListener("input", onChange);
6417
6991
  const required = field.required ?? true;
@@ -6427,96 +7001,190 @@ function buildStringField(field, onChange) {
6427
7001
  focus: () => input.focus()
6428
7002
  };
6429
7003
  }
6430
- function buildField(field, onChange) {
7004
+ function buildField(field, onChange, fallbackLabel, key) {
7005
+ const custom = contextConfig().getElicitationFieldRenderer()?.(field, { key, notifyChange: onChange });
7006
+ if (custom) {
7007
+ return {
7008
+ el: custom.el,
7009
+ getValue: () => custom.getValue(),
7010
+ isComplete: () => custom.isComplete(),
7011
+ focus: () => custom.focus?.()
7012
+ };
7013
+ }
6431
7014
  switch (field.type) {
6432
7015
  case "enum":
6433
- return buildEnumField(field, onChange);
7016
+ return buildEnumField(field, onChange, fallbackLabel);
6434
7017
  case "boolean":
6435
- return buildBooleanField(field, onChange);
7018
+ return buildBooleanField(field, onChange, fallbackLabel);
6436
7019
  case "string":
6437
- return buildStringField(field, onChange);
7020
+ return buildStringField(field, onChange, fallbackLabel);
6438
7021
  }
6439
7022
  }
6440
7023
  function buildElicitationPanel(message, schema, onChange) {
6441
7024
  const panel = el("div", "aparte-elic-panel");
6442
7025
  if (message) panel.appendChild(el("p", "aparte-elic-message", message));
7026
+ const body = el("div", "aparte-elic-body");
7027
+ panel.appendChild(body);
7028
+ const dismiss = el("div", "aparte-elic-dismiss");
7029
+ panel.appendChild(dismiss);
6443
7030
  if (schema.type === "object") {
7031
+ let show = function(index) {
7032
+ current = index;
7033
+ fields.forEach((f, i) => {
7034
+ f.field.el.hidden = i !== index;
7035
+ });
7036
+ syncNav();
7037
+ };
6444
7038
  const entries = Object.entries(schema.properties);
6445
7039
  const requiredKeys = new Set(schema.required ?? entries.map(([k]) => k));
7040
+ const cfg = contextConfig();
7041
+ let syncNav = () => {
7042
+ };
7043
+ const notify = () => {
7044
+ syncNav();
7045
+ onChange();
7046
+ };
6446
7047
  const fields = entries.map(([key, field2]) => {
6447
- const built = buildField(field2, onChange);
7048
+ const built = buildField(field2, notify, void 0, key);
6448
7049
  if (!field2.title && !built.el.querySelector(".aparte-elic-title")) {
6449
7050
  built.el.insertBefore(el("p", "aparte-elic-title", key), built.el.firstChild);
6450
7051
  }
6451
- panel.appendChild(built.el);
6452
- return { key, field: built, required: requiredKeys.has(key) };
7052
+ body.appendChild(built.el);
7053
+ return { key, field: built, required: requiredKeys.has(key), header: field2.header };
6453
7054
  });
6454
- return {
7055
+ const isComplete = () => fields.every((f) => !f.required || f.field.isComplete());
7056
+ const api = {
6455
7057
  el: panel,
7058
+ dismiss,
6456
7059
  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()
7060
+ isComplete,
7061
+ focus: () => fields[0]?.field.focus(),
7062
+ // A form of one behaves like a single field; only a real form steps.
7063
+ mode: () => "submit",
7064
+ canProceed: isComplete,
7065
+ proceed: () => {
7066
+ }
7067
+ };
7068
+ if (fields.length < 2 || cfg.getElicitationOptions().layout !== "stepped") return api;
7069
+ panel.classList.add("aparte-elic-panel--stepped");
7070
+ let current = 0;
7071
+ const steps = el("div", "aparte-elic-steps");
7072
+ steps.setAttribute("role", "tablist");
7073
+ const chips = fields.map((f, i) => {
7074
+ const chip = el("button", "aparte-elic-step");
7075
+ chip.type = "button";
7076
+ chip.setAttribute("role", "tab");
7077
+ chip.textContent = f.header?.trim() || String(i + 1);
7078
+ chip.addEventListener("click", () => {
7079
+ show(i);
7080
+ f.field.focus();
7081
+ });
7082
+ steps.appendChild(chip);
7083
+ return chip;
7084
+ });
7085
+ panel.insertBefore(steps, body);
7086
+ syncNav = () => {
7087
+ chips.forEach((chip, i) => {
7088
+ chip.setAttribute("aria-selected", String(i === current));
7089
+ chip.toggleAttribute("data-answered", fields[i].field.isComplete());
7090
+ });
7091
+ };
7092
+ show(0);
7093
+ const last = () => current === fields.length - 1;
7094
+ return {
7095
+ ...api,
7096
+ focus: () => fields[current]?.field.focus(),
7097
+ mode: () => last() ? "submit" : "advance",
7098
+ // Advancing needs only THIS question answered; submitting needs them all,
7099
+ // which is what stops the last question from accepting a form with a hole
7100
+ // in it.
7101
+ canProceed: () => last() ? isComplete() : !!fields[current]?.field.isComplete(),
7102
+ proceed: () => {
7103
+ if (last()) return;
7104
+ show(current + 1);
7105
+ fields[current]?.field.focus();
7106
+ }
6459
7107
  };
6460
7108
  }
6461
- const field = buildField(schema, onChange);
6462
- panel.appendChild(field.el);
7109
+ const field = buildField(schema, onChange, message);
7110
+ body.appendChild(field.el);
6463
7111
  return {
6464
7112
  el: panel,
7113
+ dismiss,
6465
7114
  getContent: () => field.getValue(),
6466
7115
  isComplete: () => field.isComplete(),
6467
- focus: () => field.focus()
7116
+ focus: () => field.focus(),
7117
+ mode: () => "submit",
7118
+ canProceed: () => field.isComplete(),
7119
+ proceed: () => {
7120
+ }
6468
7121
  };
6469
7122
  }
6470
7123
  function requestUserInput(request) {
6471
7124
  return resolveConfig(request.target ?? null).requestUserInput(request);
6472
7125
  }
6473
7126
  export {
7127
+ segmentDuration as $,
6474
7128
  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
7129
+ createStreamAdapter as B,
7130
+ cssEscape as C,
7131
+ declineDefaultRenderers as D,
7132
+ defaultSanitizer as E,
7133
+ deriveArtifactKind as F,
7134
+ detachConfig as G,
7135
+ escapeAttr as H,
7136
+ escapeHtml as I,
7137
+ filesToAttachments as J,
7138
+ getAllRenderers as K,
7139
+ getSegmentRenderer as L,
7140
+ installDefaultRenderersOnce as M,
7141
+ isAwaitingReply as N,
7142
+ isFormatAdapter as O,
7143
+ isSafeUrl as P,
7144
+ isSegmentSettled as Q,
7145
+ parseAparteEventStream as R,
7146
+ parseMarkdownToSegments as S,
7147
+ populateBubbleFromMessage as T,
7148
+ readableToAsyncIterable as U,
7149
+ registerDefaultRenderers as V,
7150
+ registerSegmentRenderer as W,
7151
+ requestUserInput as X,
7152
+ resolveConfig as Y,
7153
+ revokeAttachmentUrls as Z,
7154
+ runWithConfig as _,
7155
+ APARTE_DEFAULT_BUBBLE_ACTIONS as a,
7156
+ unregisterSegmentRenderer as a0,
7157
+ uuid as a1,
7158
+ stampSegmentActivity as a2,
7159
+ stampSegmentOnInsert as a3,
7160
+ stampSegmentOnUpdate as a4,
7161
+ renumberSegments as a5,
7162
+ openSegmentIds as a6,
7163
+ isTerminalStatus as a7,
7164
+ APARTE_DEFAULT_HOST_HANDLERS as b,
7165
+ APARTE_DEFAULT_ICON_FALLBACKS as c,
7166
+ APARTE_DEFAULT_LOCALE as d,
7167
+ APARTE_DEFAULT_SKELETON_FALLBACKS as e,
7168
+ APARTE_DEFAULT_UI_EVENTS as f,
7169
+ APARTE_HOST_ATTR as g,
7170
+ AparteBackendTransport as h,
7171
+ AparteChatHost as i,
7172
+ AparteClient as j,
7173
+ AparteConfig as k,
7174
+ AparteConversationController as l,
7175
+ AparteConversationManager as m,
7176
+ AparteDirectTransport as n,
7177
+ AparteError as o,
7178
+ AparteErrorCode as p,
7179
+ AparteMessageRepository as q,
7180
+ AparteStreamParser as r,
7181
+ aparteGlobalConfig as s,
7182
+ applyElementProps as t,
7183
+ attachConfig as u,
7184
+ buildElicitationPanel as v,
7185
+ collectRendererStyles as w,
7186
+ contentToText as x,
7187
+ contextConfig as y,
7188
+ createAparteChatHandler as z
6521
7189
  };
6522
- //# sourceMappingURL=index-Dn7TH14G.js.map
7190
+ //# sourceMappingURL=index-hQQljwfX.js.map