@runtypelabs/persona 4.7.0 → 4.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 (185) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -2
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -2
  6. package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
  7. package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
  8. package/dist/animations/wipe.cjs +2 -2
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/chunk-5EIIHQLQ.js +1 -0
  12. package/dist/chunk-IO5VVUKP.js +3 -0
  13. package/dist/chunk-IPVK3KOM.js +1 -0
  14. package/dist/chunk-UPO4GUFC.js +1 -0
  15. package/dist/codegen.cjs +13 -13
  16. package/dist/codegen.js +14 -14
  17. package/dist/context-mentions-7S5KVUTG.js +169 -0
  18. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  19. package/dist/context-mentions-inline.cjs +4 -0
  20. package/dist/context-mentions-inline.d.cts +203 -0
  21. package/dist/context-mentions-inline.d.ts +203 -0
  22. package/dist/context-mentions-inline.js +4 -0
  23. package/dist/context-mentions.cjs +295 -0
  24. package/dist/context-mentions.d.cts +7025 -0
  25. package/dist/context-mentions.d.ts +7025 -0
  26. package/dist/context-mentions.js +295 -0
  27. package/dist/index.cjs +99 -68
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1507 -182
  30. package/dist/index.d.ts +1507 -182
  31. package/dist/index.global.js +88 -56
  32. package/dist/index.global.js.map +1 -1
  33. package/dist/index.js +99 -68
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.global.js +1 -1
  36. package/dist/install.global.js.map +1 -1
  37. package/dist/launcher.global.js +3 -2
  38. package/dist/launcher.global.js.map +1 -1
  39. package/dist/markdown-parsers.js +24 -24
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +17 -0
  42. package/dist/plugin-kit.d.ts +17 -0
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  45. package/dist/runtype-tts.js +1 -1
  46. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  47. package/dist/smart-dom-reader.cjs +18 -17
  48. package/dist/smart-dom-reader.d.cts +1260 -16
  49. package/dist/smart-dom-reader.d.ts +1260 -16
  50. package/dist/smart-dom-reader.js +18 -17
  51. package/dist/testing.cjs +3 -3
  52. package/dist/testing.js +3 -3
  53. package/dist/theme-editor-preview.cjs +260 -58
  54. package/dist/theme-editor-preview.d.cts +1234 -15
  55. package/dist/theme-editor-preview.d.ts +1234 -15
  56. package/dist/theme-editor-preview.js +87 -58
  57. package/dist/theme-editor.cjs +10 -10
  58. package/dist/theme-editor.d.cts +1226 -15
  59. package/dist/theme-editor.d.ts +1226 -15
  60. package/dist/theme-editor.js +10 -10
  61. package/dist/theme-reference.cjs +1 -1
  62. package/dist/theme-reference.d.cts +76 -0
  63. package/dist/theme-reference.d.ts +76 -0
  64. package/dist/theme-reference.js +1 -1
  65. package/dist/voice-worklet-player.cjs +2 -2
  66. package/dist/voice-worklet-player.js +2 -2
  67. package/dist/webmcp-polyfill.js +2 -2
  68. package/dist/widget.css +1 -1
  69. package/package.json +16 -5
  70. package/src/artifacts-session.test.ts +178 -0
  71. package/src/client.test.ts +255 -1
  72. package/src/client.ts +157 -60
  73. package/src/components/artifact-card.test.ts +333 -0
  74. package/src/components/artifact-card.ts +75 -28
  75. package/src/components/artifact-inline.test.ts +1328 -0
  76. package/src/components/artifact-inline.ts +920 -0
  77. package/src/components/artifact-pane.test.ts +1042 -0
  78. package/src/components/artifact-pane.ts +440 -131
  79. package/src/components/artifact-preview.test.ts +1155 -0
  80. package/src/components/artifact-preview.ts +994 -0
  81. package/src/components/composer-parts.ts +3 -12
  82. package/src/components/context-mention-button.test.ts +70 -0
  83. package/src/components/context-mention-button.ts +82 -0
  84. package/src/components/context-mention-chip.ts +134 -0
  85. package/src/components/context-mention-menu.test.ts +508 -0
  86. package/src/components/context-mention-menu.ts +0 -0
  87. package/src/components/message-bubble.test.ts +175 -0
  88. package/src/components/message-bubble.ts +177 -19
  89. package/src/components/panel.ts +7 -10
  90. package/src/components/pill-composer-builder.test.ts +6 -2
  91. package/src/components/pill-composer-builder.ts +6 -6
  92. package/src/components/reasoning-bubble.ts +1 -13
  93. package/src/components/registry.ts +38 -3
  94. package/src/components/tool-bubble.ts +1 -13
  95. package/src/context-mentions-bundle.test.ts +163 -0
  96. package/src/context-mentions-entry.ts +185 -0
  97. package/src/context-mentions-inline-entry.test.ts +136 -0
  98. package/src/context-mentions-inline-entry.ts +226 -0
  99. package/src/context-mentions-inline-loader.test.ts +30 -0
  100. package/src/context-mentions-inline-loader.ts +36 -0
  101. package/src/context-mentions-inline.ts +15 -0
  102. package/src/context-mentions-loader.ts +32 -0
  103. package/src/context-mentions.ts +16 -0
  104. package/src/defaults.ts +1 -0
  105. package/src/generated/runtype-openapi-contract.ts +55 -3
  106. package/src/index-core.ts +46 -1
  107. package/src/index-global.ts +51 -0
  108. package/src/index.ts +8 -0
  109. package/src/markdown-parsers-loader.test.ts +158 -0
  110. package/src/markdown-parsers-loader.ts +83 -27
  111. package/src/plugin-kit.test.ts +40 -0
  112. package/src/plugin-kit.ts +39 -5
  113. package/src/runtime/host-layout.test.ts +163 -0
  114. package/src/runtime/host-layout.ts +110 -7
  115. package/src/runtime/init.ts +18 -61
  116. package/src/session.mentions.test.ts +175 -0
  117. package/src/session.test.ts +52 -4
  118. package/src/session.ts +197 -27
  119. package/src/smart-dom-reader.test.ts +129 -2
  120. package/src/smart-dom-reader.ts +127 -1
  121. package/src/styles/context-mention-menu-css.ts +176 -0
  122. package/src/styles/widget.css +1002 -152
  123. package/src/theme-editor/preview.ts +2 -0
  124. package/src/theme-editor/sections.test.ts +26 -1
  125. package/src/theme-editor/sections.ts +13 -5
  126. package/src/theme-reference.ts +2 -2
  127. package/src/tool-call-display-defaults.test.ts +1 -0
  128. package/src/types/theme.ts +79 -0
  129. package/src/types.ts +1041 -17
  130. package/src/ui.artifact-pane-gating.test.ts +636 -0
  131. package/src/ui.component-directive.test.ts +104 -0
  132. package/src/ui.composer-bar.test.ts +60 -2
  133. package/src/ui.detached-panel.test.ts +1049 -0
  134. package/src/ui.mention-submit.test.ts +235 -0
  135. package/src/ui.tool-display.test.ts +51 -0
  136. package/src/ui.ts +998 -159
  137. package/src/utils/artifact-custom-actions.ts +128 -0
  138. package/src/utils/artifact-display.test.ts +42 -0
  139. package/src/utils/artifact-display.ts +84 -0
  140. package/src/utils/artifact-file.test.ts +116 -0
  141. package/src/utils/artifact-file.ts +117 -0
  142. package/src/utils/artifact-gate.test.ts +112 -5
  143. package/src/utils/artifact-gate.ts +39 -14
  144. package/src/utils/artifact-loading-status.ts +55 -0
  145. package/src/utils/artifact-status-label.ts +190 -0
  146. package/src/utils/buttons.ts +7 -1
  147. package/src/utils/chunk-loader.test.ts +97 -0
  148. package/src/utils/chunk-loader.ts +88 -0
  149. package/src/utils/code-highlight.test.ts +186 -0
  150. package/src/utils/code-highlight.ts +400 -0
  151. package/src/utils/composer-contenteditable.test.ts +507 -0
  152. package/src/utils/composer-contenteditable.ts +626 -0
  153. package/src/utils/composer-document.test.ts +280 -0
  154. package/src/utils/composer-document.ts +293 -0
  155. package/src/utils/composer-history.test.ts +35 -7
  156. package/src/utils/composer-history.ts +30 -20
  157. package/src/utils/composer-input.ts +159 -0
  158. package/src/utils/context-mention-controller.test.ts +1215 -0
  159. package/src/utils/context-mention-controller.ts +1186 -0
  160. package/src/utils/context-mention-manager.test.ts +422 -0
  161. package/src/utils/context-mention-manager.ts +410 -0
  162. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  163. package/src/utils/context-mention-orchestrator.ts +348 -0
  164. package/src/utils/icons.ts +2 -0
  165. package/src/utils/live-region.test.ts +108 -0
  166. package/src/utils/live-region.ts +94 -0
  167. package/src/utils/mention-channels.ts +63 -0
  168. package/src/utils/mention-llm-format.test.ts +91 -0
  169. package/src/utils/mention-llm-format.ts +79 -0
  170. package/src/utils/mention-matcher.test.ts +86 -0
  171. package/src/utils/mention-matcher.ts +221 -0
  172. package/src/utils/mention-token.ts +72 -0
  173. package/src/utils/mention-trigger.test.ts +155 -0
  174. package/src/utils/mention-trigger.ts +156 -0
  175. package/src/utils/roving-tablist.test.ts +152 -0
  176. package/src/utils/roving-tablist.ts +111 -0
  177. package/src/utils/spinner.ts +45 -0
  178. package/src/utils/theme.test.ts +76 -4
  179. package/src/utils/theme.ts +7 -0
  180. package/src/utils/tokens.ts +118 -11
  181. package/src/utils/tool-loading-animation.test.ts +32 -0
  182. package/src/utils/tool-loading-animation.ts +24 -0
  183. package/dist/chunk-DFBSCFYN.js +0 -1
  184. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  185. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -1,4 +1,4 @@
1
- import { createElement } from "../utils/dom";
1
+ import { createElement, createNode } from "../utils/dom";
2
2
  import {
3
3
  AgentWidgetMessage,
4
4
  AgentWidgetMessageLayoutConfig,
@@ -11,9 +11,14 @@ import {
11
11
  AudioContentPart,
12
12
  VideoContentPart,
13
13
  FileContentPart,
14
+ AgentWidgetContextMentionRef,
15
+ AgentWidgetContentSegment,
16
+ AgentWidgetContextMentionTokenRenderContext,
14
17
  StopReasonKind
15
18
  } from "../types";
19
+ import { createMentionTokenElement } from "../utils/mention-token";
16
20
  import { createIconButton } from "../utils/buttons";
21
+ import { renderLucideIcon } from "../utils/icons";
17
22
  import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT } from "../utils/content";
18
23
  import {
19
24
  applyStreamBuffer,
@@ -186,6 +191,126 @@ const getMessageFileParts = (message: AgentWidgetMessage): FileContentPart[] =>
186
191
  );
187
192
  };
188
193
 
194
+ /**
195
+ * Read-only context-mention pills for a sent user bubble (from
196
+ * `message.contextMentions`). Same compact pill look as the composer chips, but
197
+ * no spinner / remove control. Returns null when the message carries no mentions.
198
+ */
199
+ const createMessageMentionChips = (
200
+ mentions: AgentWidgetContextMentionRef[] | undefined
201
+ ): HTMLElement | null => {
202
+ if (!mentions || mentions.length === 0) return null;
203
+ const row = createNode("div", {
204
+ className: "persona-mention-context-row persona-message-mentions",
205
+ attrs: { "data-message-mentions": "" },
206
+ });
207
+ row.style.display = "flex";
208
+ for (const ref of mentions) {
209
+ const chip = createNode("div", {
210
+ className: "persona-mention-chip persona-mention-chip-readonly",
211
+ attrs: { "data-status": "ready", title: ref.label },
212
+ });
213
+ const iconHost = createElement("span", "persona-mention-chip-icon");
214
+ const icon = renderLucideIcon(ref.iconName ?? "at-sign", 13, "currentColor", 2);
215
+ if (icon) iconHost.appendChild(icon);
216
+ chip.appendChild(iconHost);
217
+ chip.appendChild(
218
+ createNode("span", { className: "persona-mention-chip-label", text: ref.label })
219
+ );
220
+ row.appendChild(chip);
221
+ }
222
+ return row;
223
+ };
224
+
225
+ /**
226
+ * Render a sent user bubble's prose with atomic mention tokens in place (inline
227
+ * display mode, from `message.contentSegments`). Text runs become text nodes;
228
+ * mention segments become read-only pill tokens (shared with the composer, so a
229
+ * host `renderMentionToken` + per-item `color` apply here too). Display/transcript
230
+ * concern only — the model saw the resolved bodies via `llmContent`. Returns a
231
+ * document fragment for `replaceChildren`.
232
+ */
233
+ export const createMessageInlineMentions = (
234
+ segments: AgentWidgetContentSegment[],
235
+ render?: (
236
+ ctx: AgentWidgetContextMentionTokenRenderContext
237
+ ) => HTMLElement
238
+ ): DocumentFragment => {
239
+ const frag = document.createDocumentFragment();
240
+ for (const seg of segments) {
241
+ if (seg.kind === "text") {
242
+ frag.appendChild(document.createTextNode(seg.text));
243
+ continue;
244
+ }
245
+ frag.appendChild(
246
+ createMentionTokenElement(seg.ref, { readonly: true, render })
247
+ );
248
+ }
249
+ return frag;
250
+ };
251
+
252
+ /* PUA sentinels survive escapeHtml, markdown, and DOMPurify as plain text. */
253
+ const MENTION_PH_OPEN = "\uE000";
254
+ const MENTION_PH_CLOSE = "\uE001";
255
+
256
+ /**
257
+ * Render segmented prose through the full message transform (actions, markdown,
258
+ * postprocess, sanitize): mention slots ride the pipeline as placeholder
259
+ * sentinels and are swapped for atomic token elements in the parsed output.
260
+ * Falls back to verbatim segment rendering when a transform drops or rewrites
261
+ * a slot, so a mention is never lost.
262
+ */
263
+ export const renderSegmentsWithTransform = (
264
+ contentDiv: HTMLElement,
265
+ segments: AgentWidgetContentSegment[],
266
+ transformSource: (source: string) => string,
267
+ render?: (
268
+ ctx: AgentWidgetContextMentionTokenRenderContext
269
+ ) => HTMLElement
270
+ ): void => {
271
+ // Nonce keeps user-typed sentinel lookalikes from matching a real slot.
272
+ const nonce = Math.random().toString(36).slice(2, 8);
273
+ const placeholder = (i: number) =>
274
+ `${MENTION_PH_OPEN}${nonce}:${i}${MENTION_PH_CLOSE}`;
275
+ const source = segments
276
+ .map((seg, i) => (seg.kind === "text" ? seg.text : placeholder(i)))
277
+ .join("");
278
+ const html = transformSource(source);
279
+ // String-level check runs before the parse: a slot that was dropped,
280
+ // entity-encoded, or split by an inserted tag fails `includes`.
281
+ const survived = segments.every(
282
+ (seg, i) => seg.kind === "text" || html.includes(placeholder(i))
283
+ );
284
+ if (!survived) {
285
+ contentDiv.replaceChildren(createMessageInlineMentions(segments, render));
286
+ return;
287
+ }
288
+ contentDiv.innerHTML = html;
289
+ const pattern = new RegExp(
290
+ `${MENTION_PH_OPEN}${nonce}:(\\d+)${MENTION_PH_CLOSE}`
291
+ );
292
+ const walker = document.createTreeWalker(contentDiv, NodeFilter.SHOW_TEXT);
293
+ const textNodes: Text[] = [];
294
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
295
+ textNodes.push(n as Text);
296
+ }
297
+ for (const node of textNodes) {
298
+ let current = node;
299
+ for (let match = pattern.exec(current.data); match; match = pattern.exec(current.data)) {
300
+ const seg = segments[Number(match[1])];
301
+ const after = current.splitText(match.index);
302
+ after.data = after.data.slice(match[0].length);
303
+ if (seg?.kind === "mention") {
304
+ after.parentNode?.insertBefore(
305
+ createMentionTokenElement(seg.ref, { readonly: true, render }),
306
+ after
307
+ );
308
+ }
309
+ current = after;
310
+ }
311
+ }
312
+ };
313
+
189
314
  const createMessageImagePreviews = (
190
315
  imageParts: ImageContentPart[],
191
316
  hasVisibleText: boolean,
@@ -820,33 +945,57 @@ export const createStandardBubble = (
820
945
  )
821
946
  : (message.content ?? "");
822
947
 
823
- const transformedContent = transform({
824
- text: bufferedContent,
825
- message,
826
- streaming: Boolean(message.streaming),
827
- raw: message.rawContent
828
- });
829
-
830
- let animatedContent = transformedContent;
831
- if (streamAnimationActive && streamPlugin?.wrap === "char") {
832
- animatedContent = wrapStreamAnimation(transformedContent, "char", message.id, {
833
- skipTags: streamPlugin.skipTags,
834
- });
835
- } else if (streamAnimationActive && streamPlugin?.wrap === "word") {
836
- animatedContent = wrapStreamAnimation(transformedContent, "word", message.id, {
837
- skipTags: streamPlugin.skipTags,
948
+ // Feeds the two text branches below; the inline-mention branch calls the
949
+ // transform itself with a placeholder-bearing source built from
950
+ // `contentSegments` instead of `bufferedContent`. Kept lazy so that branch
951
+ // doesn't pay for output it never reads. `bufferedContent` stays eager —
952
+ // the streaming skeleton logic reads it.
953
+ const computeAnimatedContent = (): string => {
954
+ const transformedContent = transform({
955
+ text: bufferedContent,
956
+ message,
957
+ streaming: Boolean(message.streaming),
958
+ raw: message.rawContent
838
959
  });
839
- }
960
+ if (streamAnimationActive && streamPlugin?.wrap === "char") {
961
+ return wrapStreamAnimation(transformedContent, "char", message.id, {
962
+ skipTags: streamPlugin.skipTags,
963
+ });
964
+ }
965
+ if (streamAnimationActive && streamPlugin?.wrap === "word") {
966
+ return wrapStreamAnimation(transformedContent, "word", message.id, {
967
+ skipTags: streamPlugin.skipTags,
968
+ });
969
+ }
970
+ return transformedContent;
971
+ };
840
972
 
841
973
  let textContentDiv: HTMLElement | null = null;
842
974
 
843
975
  if (shouldHideTextUntilPreviewFails) {
844
976
  textContentDiv = document.createElement("div");
845
- textContentDiv.innerHTML = animatedContent;
977
+ textContentDiv.innerHTML = computeAnimatedContent();
846
978
  textContentDiv.style.display = "none";
847
979
  contentDiv.appendChild(textContentDiv);
980
+ } else if (message.contentSegments?.length) {
981
+ // Inline-mention user bubble: prose runs through the same transform
982
+ // pipeline as every other bubble (markdown/postprocess/sanitize apply);
983
+ // mention slots ride through as placeholders and come back as atomic
984
+ // tokens. The resolved context reached the model via `llmContent`.
985
+ renderSegmentsWithTransform(
986
+ contentDiv,
987
+ message.contentSegments,
988
+ (source) =>
989
+ transform({
990
+ text: source,
991
+ message,
992
+ streaming: Boolean(message.streaming),
993
+ raw: message.rawContent,
994
+ }),
995
+ options?.widgetConfig?.contextMentions?.renderMentionToken
996
+ );
848
997
  } else {
849
- contentDiv.innerHTML = animatedContent;
998
+ contentDiv.innerHTML = computeAnimatedContent();
850
999
  }
851
1000
 
852
1001
  if (
@@ -931,6 +1080,15 @@ export const createStandardBubble = (
931
1080
  }
932
1081
  }
933
1082
 
1083
+ // Context mention pills (read-only) for the sent user bubble. Skipped in inline
1084
+ // mode — the tokens render within the prose (above) instead of a separate row.
1085
+ const mentionChips = message.contentSegments?.length
1086
+ ? null
1087
+ : createMessageMentionChips(message.contextMentions);
1088
+ if (mentionChips) {
1089
+ bubble.appendChild(mentionChips);
1090
+ }
1091
+
934
1092
  bubble.appendChild(contentDiv);
935
1093
 
936
1094
  // Add timestamp below if configured
@@ -282,10 +282,8 @@ const buildComposerBarPanel = (
282
282
  className: "persona-rounded-2xl persona-p-6",
283
283
  attrs: { "data-persona-intro-card": "" },
284
284
  style: {
285
- background:
286
- "var(--persona-intro-card-bg, var(--persona-surface, #ffffff))",
287
- boxShadow:
288
- "var(--persona-intro-card-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))",
285
+ background: "var(--persona-intro-card-bg, transparent)",
286
+ boxShadow: "var(--persona-intro-card-shadow, none)",
289
287
  },
290
288
  },
291
289
  introTitle,
@@ -416,20 +414,19 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
416
414
  "Ask anything about your account or products.",
417
415
  });
418
416
  // Background and box-shadow flow through the themable `components.introCard`
419
- // tokens (--persona-intro-card-bg / --persona-intro-card-shadow), each
420
- // falling back to its legacy value (`--persona-surface` / `persona-shadow-sm`)
421
- // when no token is set. Docked mode keeps a flat shadow by default.
417
+ // tokens (--persona-intro-card-bg / --persona-intro-card-shadow); both
418
+ // default to flat (transparent / none). Docked mode always stays flat,
419
+ // even when a theme sets an introCard shadow.
422
420
  const introCard = createNode(
423
421
  "div",
424
422
  {
425
423
  className: "persona-rounded-2xl persona-p-6",
426
424
  attrs: { "data-persona-intro-card": "" },
427
425
  style: {
428
- background:
429
- "var(--persona-intro-card-bg, var(--persona-surface, #ffffff))",
426
+ background: "var(--persona-intro-card-bg, transparent)",
430
427
  boxShadow: isDockedMountMode(config)
431
428
  ? "none"
432
- : "var(--persona-intro-card-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))",
429
+ : "var(--persona-intro-card-shadow, none)",
433
430
  },
434
431
  },
435
432
  introTitle,
@@ -26,9 +26,13 @@ describe("buildPillComposer (single-row pill composer)", () => {
26
26
  expect(elements.sendButton.getAttribute("data-persona-composer-submit")).toBe("");
27
27
  });
28
28
 
29
- it("hides suggestions and status text by default in pill mode", () => {
29
+ it("keeps suggestions available above the pill and hides status text", () => {
30
30
  const elements = buildPillComposer({ config: { apiUrl: "/api" } });
31
- expect(elements.suggestions.style.display).toBe("none");
31
+ const footerChildren = Array.from(elements.footer.children);
32
+ expect(elements.suggestions.style.display).toBe("");
33
+ expect(footerChildren.indexOf(elements.suggestions)).toBeLessThan(
34
+ footerChildren.indexOf(elements.composerForm)
35
+ );
32
36
  expect(elements.statusText.style.display).toBe("none");
33
37
  });
34
38
 
@@ -72,9 +72,10 @@ export const buildPillPeekBanner = (): PillPeekBanner => {
72
72
  * Returns the same `ComposerElements` shape as `buildComposer` so panel.ts
73
73
  * and ui.ts plumbing is unconditional past the choice of builder.
74
74
  *
75
- * Suggestions row + status text are built (so plugin code that mutates
76
- * them keeps working and `bindComposerRefsFromFooter` finds them) but are
77
- * `display: none` by default: pill UX is just textarea + 3 buttons.
75
+ * Suggestions row + status text are built so plugin code that mutates them
76
+ * keeps working and `bindComposerRefsFromFooter` finds them. CSS hides the
77
+ * suggestions while collapsed and reveals them above the pill when expanded;
78
+ * status text stays hidden because the pill UX has no room for it.
78
79
  *
79
80
  * Attachment previews float ABOVE the pill in their own row when
80
81
  * AttachmentManager toggles the previews container's `display` property
@@ -89,7 +90,6 @@ export const buildPillComposer = (context: ComposerBuildContext): ComposerElemen
89
90
  });
90
91
 
91
92
  const suggestions = createSuggestionsRow();
92
- suggestions.style.display = "none";
93
93
  const statusText = createStatusText(config);
94
94
  statusText.style.display = "none";
95
95
 
@@ -150,11 +150,11 @@ export const buildPillComposer = (context: ComposerBuildContext): ComposerElemen
150
150
 
151
151
  // Footer assembly:
152
152
  // [previews row, hidden until attachments exist]
153
+ // [suggestions, visible only while expanded]
153
154
  // [pill form]
154
- // [hidden suggestions]
155
155
  // [hidden status]
156
156
  if (attachment) footer.append(attachment.previewsContainer);
157
- footer.append(composerForm, suggestions, statusText);
157
+ footer.append(suggestions, composerForm, statusText);
158
158
 
159
159
  // The pill flattens left/right into the form's grid; there's no separate
160
160
  // wrapper. Surface the form itself as `actionsRow` to satisfy the
@@ -2,6 +2,7 @@ import { createElement } from "../utils/dom";
2
2
  import { AgentWidgetConfig, AgentWidgetMessage } from "../types";
3
3
  import { describeReasonStatus, computeReasoningElapsed, parseFormattedTemplate } from "../utils/formatting";
4
4
  import { renderLucideIcon } from "../utils/icons";
5
+ import { appendCharSpans } from "../utils/tool-loading-animation";
5
6
 
6
7
  // Expansion state per widget instance
7
8
  export const reasoningExpansionState = new Set<string>();
@@ -156,19 +157,6 @@ export const createReasoningBubble = (message: AgentWidgetMessage, config?: Agen
156
157
  const currentTemplate = isActive ? activeTemplate : completeTemplate;
157
158
  const skipCustomElement = customSummary instanceof HTMLElement;
158
159
 
159
- // Helper: append text as individual animated character spans
160
- const appendCharSpans = (container: HTMLElement, text: string, startIndex: number): number => {
161
- let idx = startIndex;
162
- for (const char of text) {
163
- const span = createElement("span", "persona-tool-char");
164
- span.style.setProperty("--char-index", String(idx));
165
- span.textContent = char === " " ? "\u00A0" : char;
166
- container.appendChild(span);
167
- idx++;
168
- }
169
- return idx;
170
- };
171
-
172
160
  /**
173
161
  * Renders a template into the title element, handling:
174
162
  * - Inline formatting markers: **bold**, *italic*, ~dim~
@@ -1,5 +1,6 @@
1
1
  import { AgentWidgetConfig, AgentWidgetMessage } from "../types";
2
2
  import { PersonaArtifactCard } from "./artifact-card";
3
+ import { PersonaArtifactInline } from "./artifact-inline";
3
4
 
4
5
  /**
5
6
  * Context provided to component renderers
@@ -21,20 +22,42 @@ export type ComponentRenderer = (
21
22
  context: ComponentContext
22
23
  ) => HTMLElement;
23
24
 
25
+ /**
26
+ * Per-component registration options
27
+ */
28
+ export interface ComponentRegistrationOptions {
29
+ /**
30
+ * When false, the component directive renders bare in the thread (no
31
+ * persona-message-bubble chrome).
32
+ * @default true
33
+ */
34
+ bubbleChrome?: boolean;
35
+ }
36
+
24
37
  /**
25
38
  * Component registry for managing custom components
26
39
  */
27
40
  class ComponentRegistry {
28
41
  private components: Map<string, ComponentRenderer> = new Map();
42
+ private options: Map<string, ComponentRegistrationOptions> = new Map();
29
43
 
30
44
  /**
31
45
  * Register a custom component
32
46
  */
33
- register(name: string, renderer: ComponentRenderer): void {
47
+ register(
48
+ name: string,
49
+ renderer: ComponentRenderer,
50
+ options?: ComponentRegistrationOptions
51
+ ): void {
34
52
  if (this.components.has(name)) {
35
53
  console.warn(`[ComponentRegistry] Component "${name}" is already registered. Overwriting.`);
36
54
  }
37
55
  this.components.set(name, renderer);
56
+ if (options) {
57
+ this.options.set(name, options);
58
+ } else {
59
+ this.options.delete(name);
60
+ }
38
61
  }
39
62
 
40
63
  /**
@@ -42,6 +65,7 @@ class ComponentRegistry {
42
65
  */
43
66
  unregister(name: string): void {
44
67
  this.components.delete(name);
68
+ this.options.delete(name);
45
69
  }
46
70
 
47
71
  /**
@@ -58,6 +82,13 @@ class ComponentRegistry {
58
82
  return this.components.has(name);
59
83
  }
60
84
 
85
+ /**
86
+ * Get the registration options for a component, if any were supplied
87
+ */
88
+ getOptions(name: string): ComponentRegistrationOptions | undefined {
89
+ return this.options.get(name);
90
+ }
91
+
61
92
  /**
62
93
  * Get all registered component names
63
94
  */
@@ -70,6 +101,7 @@ class ComponentRegistry {
70
101
  */
71
102
  clear(): void {
72
103
  this.components.clear();
104
+ this.options.clear();
73
105
  }
74
106
 
75
107
  /**
@@ -87,5 +119,8 @@ class ComponentRegistry {
87
119
  */
88
120
  export const componentRegistry = new ComponentRegistry();
89
121
 
90
- // Register built-in components
91
- componentRegistry.register("PersonaArtifactCard", PersonaArtifactCard);
122
+ // Register built-in components. The artifact card and inline block carry
123
+ // their own border and surface, so they render bare in the thread to avoid
124
+ // double-boxing.
125
+ componentRegistry.register("PersonaArtifactCard", PersonaArtifactCard, { bubbleChrome: false });
126
+ componentRegistry.register("PersonaArtifactInline", PersonaArtifactInline, { bubbleChrome: false });
@@ -2,6 +2,7 @@ import { createElement } from "../utils/dom";
2
2
  import { AgentWidgetMessage, AgentWidgetConfig } from "../types";
3
3
  import { formatUnknownValue, describeToolTitle, resolveToolHeaderText, computeToolElapsed, parseFormattedTemplate } from "../utils/formatting";
4
4
  import { renderLucideIcon } from "../utils/icons";
5
+ import { appendCharSpans } from "../utils/tool-loading-animation";
5
6
 
6
7
  // Expansion state per widget instance
7
8
  export const toolExpansionState = new Set<string>();
@@ -259,19 +260,6 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
259
260
  const currentTemplate = isActive ? activeTemplate : completeTemplate;
260
261
  const skipCustomElement = customSummary instanceof HTMLElement;
261
262
 
262
- // Helper: append text as individual animated character spans
263
- const appendCharSpans = (container: HTMLElement, text: string, startIndex: number): number => {
264
- let idx = startIndex;
265
- for (const char of text) {
266
- const span = createElement("span", "persona-tool-char");
267
- span.style.setProperty("--char-index", String(idx));
268
- span.textContent = char === " " ? "\u00A0" : char;
269
- container.appendChild(span);
270
- idx++;
271
- }
272
- return idx;
273
- };
274
-
275
263
  /**
276
264
  * Renders a template into the title element, handling:
277
265
  * - Inline formatting markers: **bold**, *italic*, ~dim~
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ /**
6
+ * Bundle guard for the lazy context-mentions chunk (see
7
+ * `docs/context-mentions-plan.md`, Bundle strategy).
8
+ *
9
+ * The mention runtime (controller/manager/menu) must NOT land in ANY core bundle
10
+ * shipped to consumers — neither the CDN IIFE (`dist/index.global.js`) nor the
11
+ * ESM/CJS bundles (`dist/index.js` / `dist/index.cjs`). It ships as the sibling
12
+ * chunk `dist/context-mentions.{js,cjs}`, loaded on demand: the IIFE via a
13
+ * sibling URL, ESM/CJS via the external `@runtypelabs/persona/context-mentions`
14
+ * subpath. A stray static import would pull the runtime back in; this fails then.
15
+ *
16
+ * Skips when `dist/` hasn't been built (e.g. a test-only CI step).
17
+ */
18
+ const dist = (f: string) => resolve(__dirname, "..", "dist", f);
19
+
20
+ const RUNTIME_MARKERS = [
21
+ "persona-mention-menu", // menu component class — chunk-only
22
+ "persona-mention-group-header", // menu component class — chunk-only
23
+ // Slash-command dispatch lives in the controller (chunk-only); this literal
24
+ // is unique to the command path and must not leak into a core bundle.
25
+ "context-mention prompt resolve failed",
26
+ ];
27
+
28
+ const SUBPATH = "@runtypelabs/persona/context-mentions";
29
+
30
+ // Inline (contenteditable) runtime markers — unique to the inline chunk. The
31
+ // composer contenteditable class is set only by the inline adapter/entry (in
32
+ // widget.css it's a stylesheet rule, not a JS-bundle string), so its presence in
33
+ // a JS bundle means the contenteditable engine leaked in.
34
+ const INLINE_RUNTIME_MARKERS = ["persona-composer-contenteditable"];
35
+ const INLINE_SUBPATH = "@runtypelabs/persona/context-mentions-inline";
36
+
37
+ describe("context-mentions bundle split", () => {
38
+ const iifeBuilt =
39
+ existsSync(dist("index.global.js")) && existsSync(dist("context-mentions.js"));
40
+ const esmBuilt = existsSync(dist("index.js")) && existsSync(dist("index.cjs"));
41
+
42
+ it.runIf(iifeBuilt)("keeps the mention runtime OUT of the core IIFE bundle", () => {
43
+ const core = readFileSync(dist("index.global.js"), "utf8");
44
+ for (const marker of RUNTIME_MARKERS) {
45
+ expect(core.includes(marker), `IIFE bundle unexpectedly contains "${marker}"`).toBe(
46
+ false
47
+ );
48
+ }
49
+ // The loader stub (sibling-URL reference) must remain so the chunk can load.
50
+ expect(core).toContain("context-mentions.js");
51
+ });
52
+
53
+ it.runIf(esmBuilt)("keeps the mention runtime OUT of the ESM/CJS bundles", () => {
54
+ for (const file of ["index.js", "index.cjs"]) {
55
+ const core = readFileSync(dist(file), "utf8");
56
+ for (const marker of RUNTIME_MARKERS) {
57
+ expect(core.includes(marker), `${file} unexpectedly contains "${marker}"`).toBe(
58
+ false
59
+ );
60
+ }
61
+ // The runtime is reached via the external subpath, not inlined.
62
+ expect(core.includes(SUBPATH), `${file} must import the external subpath`).toBe(
63
+ true
64
+ );
65
+ }
66
+ });
67
+
68
+ it.runIf(iifeBuilt)("ships the mention + slash-command runtime in the sibling chunk", () => {
69
+ const chunk = readFileSync(dist("context-mentions.js"), "utf8");
70
+ expect(chunk).toContain("persona-mention-menu");
71
+ // Slash-command dispatch ships in the chunk, not the core.
72
+ expect(chunk).toContain("context-mention prompt resolve failed");
73
+ // The menu CSS rides with the chunk (injected on open), not eager widget.css.
74
+ expect(chunk).toContain(".persona-mention-option[data-active");
75
+ // A CJS twin exists for require()-based consumers.
76
+ expect(existsSync(dist("context-mentions.cjs"))).toBe(true);
77
+ });
78
+
79
+ // --- inline (contenteditable) chunk guards --------------------------------
80
+
81
+ const inlineBuilt = existsSync(dist("context-mentions-inline.js"));
82
+
83
+ it.runIf(iifeBuilt)(
84
+ "keeps the inline contenteditable engine OUT of the core IIFE bundle",
85
+ () => {
86
+ const core = readFileSync(dist("index.global.js"), "utf8");
87
+ for (const marker of INLINE_RUNTIME_MARKERS) {
88
+ expect(
89
+ core.includes(marker),
90
+ `IIFE bundle unexpectedly contains inline marker "${marker}"`
91
+ ).toBe(false);
92
+ }
93
+ // The inline loader stub must remain so the chunk can load on mount.
94
+ expect(core).toContain("context-mentions-inline.js");
95
+ }
96
+ );
97
+
98
+ it.runIf(esmBuilt)(
99
+ "keeps the inline contenteditable engine OUT of the ESM/CJS bundles",
100
+ () => {
101
+ for (const file of ["index.js", "index.cjs"]) {
102
+ const core = readFileSync(dist(file), "utf8");
103
+ for (const marker of INLINE_RUNTIME_MARKERS) {
104
+ expect(
105
+ core.includes(marker),
106
+ `${file} unexpectedly contains inline marker "${marker}"`
107
+ ).toBe(false);
108
+ }
109
+ // Reached via the external subpath, not inlined.
110
+ expect(
111
+ core.includes(INLINE_SUBPATH),
112
+ `${file} must import the external inline subpath`
113
+ ).toBe(true);
114
+ }
115
+ }
116
+ );
117
+
118
+ it.runIf(inlineBuilt)(
119
+ "ships the contenteditable engine in the inline chunk (with a CJS twin)",
120
+ () => {
121
+ const chunk = readFileSync(dist("context-mentions-inline.js"), "utf8");
122
+ expect(chunk).toContain("persona-composer-contenteditable");
123
+ expect(existsSync(dist("context-mentions-inline.cjs"))).toBe(true);
124
+ }
125
+ );
126
+
127
+ it.runIf(iifeBuilt)(
128
+ "keeps the contenteditable engine OUT of the chip chunk (capability-only reach)",
129
+ () => {
130
+ // The chip controller reaches token insertion ONLY via the capability's
131
+ // `insertMentionAtTrigger`, so the chip chunk must never pull the document
132
+ // model / contenteditable adapter. This guard is what keeps that true.
133
+ const chip = readFileSync(dist("context-mentions.js"), "utf8");
134
+ for (const marker of INLINE_RUNTIME_MARKERS) {
135
+ expect(
136
+ chip.includes(marker),
137
+ `chip chunk unexpectedly contains inline marker "${marker}"`
138
+ ).toBe(false);
139
+ }
140
+ }
141
+ );
142
+
143
+ it.runIf(existsSync(dist("widget.css")))(
144
+ "keeps menu CSS out of the eager stylesheet but keeps chip CSS",
145
+ () => {
146
+ const css = readFileSync(dist("widget.css"), "utf8");
147
+ expect(css).toContain(".persona-mention-chip"); // eager chip pieces stay
148
+ expect(css.includes(".persona-mention-menu")).toBe(false); // menu moved to chunk
149
+ expect(css.includes(".persona-mention-option")).toBe(false);
150
+ }
151
+ );
152
+
153
+ it.runIf(esmBuilt)("ships no lookbehind regex (Safari < 16.4 parse guard)", () => {
154
+ // A lookbehind literal anywhere in a core bundle is a parse-time SyntaxError
155
+ // on older Safari, breaking the whole widget even with mentions disabled.
156
+ for (const file of ["index.js", "index.cjs", "index.global.js"]) {
157
+ if (!existsSync(dist(file))) continue;
158
+ expect(readFileSync(dist(file), "utf8").includes("(?<="), `${file} has a lookbehind`).toBe(
159
+ false
160
+ );
161
+ }
162
+ });
163
+ });