@runtypelabs/persona 4.8.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 (117) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
  5. package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-IO5VVUKP.js +3 -0
  9. package/dist/chunk-IPVK3KOM.js +1 -0
  10. package/dist/chunk-UPO4GUFC.js +1 -0
  11. package/dist/codegen.cjs +6 -6
  12. package/dist/codegen.js +8 -8
  13. package/dist/context-mentions-7S5KVUTG.js +169 -0
  14. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  15. package/dist/context-mentions-inline.cjs +4 -0
  16. package/dist/context-mentions-inline.d.cts +203 -0
  17. package/dist/context-mentions-inline.d.ts +203 -0
  18. package/dist/context-mentions-inline.js +4 -0
  19. package/dist/context-mentions.cjs +295 -0
  20. package/dist/context-mentions.d.cts +7025 -0
  21. package/dist/context-mentions.d.ts +7025 -0
  22. package/dist/context-mentions.js +295 -0
  23. package/dist/index.cjs +59 -51
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +622 -2
  26. package/dist/index.d.ts +622 -2
  27. package/dist/index.global.js +54 -46
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +59 -51
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js +2 -2
  32. package/dist/launcher.global.js.map +1 -1
  33. package/dist/plugin-kit.cjs +1 -1
  34. package/dist/plugin-kit.d.cts +17 -0
  35. package/dist/plugin-kit.d.ts +17 -0
  36. package/dist/plugin-kit.js +1 -1
  37. package/dist/smart-dom-reader.cjs +17 -16
  38. package/dist/smart-dom-reader.d.cts +507 -1
  39. package/dist/smart-dom-reader.d.ts +507 -1
  40. package/dist/smart-dom-reader.js +17 -16
  41. package/dist/theme-editor-preview.cjs +234 -55
  42. package/dist/theme-editor-preview.d.cts +473 -0
  43. package/dist/theme-editor-preview.d.ts +473 -0
  44. package/dist/theme-editor-preview.js +48 -42
  45. package/dist/theme-editor.cjs +7 -7
  46. package/dist/theme-editor.d.cts +473 -0
  47. package/dist/theme-editor.d.ts +473 -0
  48. package/dist/theme-editor.js +5 -5
  49. package/dist/theme-reference.cjs +1 -1
  50. package/dist/theme-reference.d.cts +2 -0
  51. package/dist/theme-reference.d.ts +2 -0
  52. package/dist/widget.css +1 -1
  53. package/package.json +15 -3
  54. package/src/client.test.ts +69 -0
  55. package/src/client.ts +65 -51
  56. package/src/components/composer-parts.ts +3 -12
  57. package/src/components/context-mention-button.test.ts +70 -0
  58. package/src/components/context-mention-button.ts +82 -0
  59. package/src/components/context-mention-chip.ts +134 -0
  60. package/src/components/context-mention-menu.test.ts +508 -0
  61. package/src/components/context-mention-menu.ts +0 -0
  62. package/src/components/message-bubble.test.ts +175 -0
  63. package/src/components/message-bubble.ts +177 -19
  64. package/src/components/panel.ts +7 -10
  65. package/src/context-mentions-bundle.test.ts +163 -0
  66. package/src/context-mentions-entry.ts +185 -0
  67. package/src/context-mentions-inline-entry.test.ts +136 -0
  68. package/src/context-mentions-inline-entry.ts +226 -0
  69. package/src/context-mentions-inline-loader.test.ts +30 -0
  70. package/src/context-mentions-inline-loader.ts +36 -0
  71. package/src/context-mentions-inline.ts +15 -0
  72. package/src/context-mentions-loader.ts +32 -0
  73. package/src/context-mentions.ts +16 -0
  74. package/src/index-core.ts +26 -0
  75. package/src/index-global.ts +51 -0
  76. package/src/markdown-parsers-loader.ts +35 -44
  77. package/src/plugin-kit.test.ts +40 -0
  78. package/src/plugin-kit.ts +39 -5
  79. package/src/session.mentions.test.ts +175 -0
  80. package/src/session.test.ts +52 -4
  81. package/src/session.ts +121 -5
  82. package/src/smart-dom-reader.test.ts +129 -2
  83. package/src/smart-dom-reader.ts +127 -1
  84. package/src/styles/context-mention-menu-css.ts +176 -0
  85. package/src/styles/widget.css +229 -126
  86. package/src/theme-editor/sections.ts +3 -3
  87. package/src/types/theme.ts +2 -0
  88. package/src/types.ts +525 -0
  89. package/src/ui.mention-submit.test.ts +235 -0
  90. package/src/ui.ts +301 -51
  91. package/src/utils/chunk-loader.test.ts +97 -0
  92. package/src/utils/chunk-loader.ts +88 -0
  93. package/src/utils/composer-contenteditable.test.ts +507 -0
  94. package/src/utils/composer-contenteditable.ts +626 -0
  95. package/src/utils/composer-document.test.ts +280 -0
  96. package/src/utils/composer-document.ts +293 -0
  97. package/src/utils/composer-history.test.ts +35 -7
  98. package/src/utils/composer-history.ts +30 -20
  99. package/src/utils/composer-input.ts +159 -0
  100. package/src/utils/context-mention-controller.test.ts +1215 -0
  101. package/src/utils/context-mention-controller.ts +1186 -0
  102. package/src/utils/context-mention-manager.test.ts +422 -0
  103. package/src/utils/context-mention-manager.ts +410 -0
  104. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  105. package/src/utils/context-mention-orchestrator.ts +348 -0
  106. package/src/utils/live-region.test.ts +108 -0
  107. package/src/utils/live-region.ts +94 -0
  108. package/src/utils/mention-channels.ts +63 -0
  109. package/src/utils/mention-llm-format.test.ts +91 -0
  110. package/src/utils/mention-llm-format.ts +79 -0
  111. package/src/utils/mention-matcher.test.ts +86 -0
  112. package/src/utils/mention-matcher.ts +221 -0
  113. package/src/utils/mention-token.ts +72 -0
  114. package/src/utils/mention-trigger.test.ts +155 -0
  115. package/src/utils/mention-trigger.ts +156 -0
  116. package/src/utils/theme.test.ts +28 -4
  117. package/src/utils/tokens.ts +27 -11
@@ -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,
@@ -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
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Context-mentions runtime entry — the HEAVY module that assembles the menu
3
+ * controller + chip manager. It is NEVER statically imported on the core path:
4
+ * the IIFE/CDN build marks it external (`tsup.global.config.ts`) and loads the
5
+ * standalone `context-mentions.js` chunk from a sibling URL only when
6
+ * `contextMentions.enabled` and the user first interacts. The core orchestrator
7
+ * (`utils/context-mention-orchestrator.ts`) reaches it through
8
+ * `context-mentions-loader.ts`. See `docs/context-mentions-plan.md`.
9
+ */
10
+
11
+ import { ContextMentionManager } from "./utils/context-mention-manager";
12
+ import {
13
+ ContextMentionController,
14
+ type InlineCommandResult,
15
+ } from "./utils/context-mention-controller";
16
+ import {
17
+ createTextareaComposerInput,
18
+ type ComposerInputCapability,
19
+ } from "./utils/composer-input";
20
+ import { createLiveRegion } from "./utils/live-region";
21
+ import type { MentionSubmitBundle } from "./utils/context-mention-manager";
22
+ import type {
23
+ AgentWidgetConfig,
24
+ AgentWidgetContextMentionConfig,
25
+ AgentWidgetContextMentionRef,
26
+ AgentWidgetMessage,
27
+ } from "./types";
28
+
29
+ export interface ContextMentionMountContext {
30
+ mentionConfig: AgentWidgetContextMentionConfig;
31
+ textarea: HTMLTextAreaElement;
32
+ /**
33
+ * Pre-built composer input surface. Provided by the inline chunk in
34
+ * `display: "inline"` mode (a contenteditable adapter that replaced the
35
+ * textarea); omitted in chip mode, where a textarea adapter is built here.
36
+ */
37
+ composerInput?: ComposerInputCapability;
38
+ /** Popover anchor — the composer form/pill (menu opens upward, full width). */
39
+ anchor: HTMLElement;
40
+ /** Chip row created by the core orchestrator. */
41
+ contextRow: HTMLElement;
42
+ getMessages: () => AgentWidgetMessage[];
43
+ getConfig: () => AgentWidgetConfig;
44
+ /**
45
+ * Widget container that hosts the mention live regions (created here, on
46
+ * engine mount, so the helper stays out of the core bundle). Announcements
47
+ * can't fire before the engine exists, so lazy creation loses nothing.
48
+ */
49
+ liveRegionHost: HTMLElement | ShadowRoot;
50
+ popoverContainer?: HTMLElement | ShadowRoot;
51
+ /**
52
+ * Reflect the affordance-button picker's open state onto the button
53
+ * (`aria-expanded`/`aria-controls`). See the controller option of the same name.
54
+ */
55
+ onPickerOpenChange?: (open: boolean, trigger: string, listboxId: string) => void;
56
+ /** Emit a `persona:mention:<event>` analytics DOM event. */
57
+ emit?: (event: string, detail: unknown) => void;
58
+ }
59
+
60
+ export interface ContextMentionEngine {
61
+ /**
62
+ * Open the menu from an affordance button as a picker (no char inserted).
63
+ * Pass the channel's `trigger` to open a specific channel (e.g. `"/"`).
64
+ */
65
+ openMenu(trigger?: string): void;
66
+ isMenuOpen(): boolean;
67
+ /** Re-parse the textarea on input and open/update/close the menu. */
68
+ handleInput(): void;
69
+ /** Returns true when the key was consumed. */
70
+ handleKeydown(event: KeyboardEvent): boolean;
71
+ hasMentions(): boolean;
72
+ removeLastChip(): boolean;
73
+ /**
74
+ * Dispatch a leading inline slash command in the composer `text` at submit
75
+ * (Slack-style). Returns null when `text` isn't an inline command. See
76
+ * {@link InlineCommandResult}.
77
+ */
78
+ dispatchInlineCommand(text: string): Promise<InlineCommandResult | null>;
79
+ collectForSubmit():
80
+ | { refs: AgentWidgetContextMentionRef[]; finalize: () => Promise<MentionSubmitBundle> }
81
+ | null;
82
+ /**
83
+ * INLINE display only. Stop tracking a mention whose token the user deleted from
84
+ * the composer (aborts its in-flight resolve). Wired to the contenteditable
85
+ * adapter's removal detection by the orchestrator.
86
+ */
87
+ untrackMention(id: string): void;
88
+ /**
89
+ * Rebind the engine to a new composer input surface (the inline swap landing
90
+ * while the engine is already live). Only the menu layer (controller) re-mounts
91
+ * on the new element — the manager, with any committed chips and their
92
+ * in-flight resolves, survives, so a mention picked pre-swap still finalizes
93
+ * into the submit payload. Any open menu closes. No-op when already bound.
94
+ */
95
+ rebindComposer(input: ComposerInputCapability): void;
96
+ clear(): void;
97
+ destroy(): void;
98
+ }
99
+
100
+ export function mountContextMentions(
101
+ ctx: ContextMentionMountContext
102
+ ): ContextMentionEngine {
103
+ // Inline mode supplies a pre-built contenteditable capability; chip mode builds
104
+ // a textarea-backed one here. Mutable: `rebindComposer` swaps it when the inline
105
+ // composer replaces the textarea while the engine is already live — closures
106
+ // below read it at call time so they always drive the current surface.
107
+ let composerInput =
108
+ ctx.composerInput ?? createTextareaComposerInput(ctx.textarea);
109
+
110
+ // Polite region for result-count + add/remove announcements; assertive region
111
+ // for resolve failures. Hosted via the shared helper so they land in the light
112
+ // DOM when the widget renders inside a Shadow DOM (see live-region.ts).
113
+ const liveRegion = createLiveRegion("polite", ctx.liveRegionHost);
114
+ const errorRegion = createLiveRegion("assertive", ctx.liveRegionHost);
115
+ const announce = (message: string): void => liveRegion.announce(message);
116
+ const announceError = (message: string): void => errorRegion.announce(message);
117
+
118
+ const manager = new ContextMentionManager({
119
+ mentionConfig: ctx.mentionConfig,
120
+ contextRow: ctx.contextRow,
121
+ getMessages: ctx.getMessages,
122
+ getConfig: ctx.getConfig,
123
+ getComposerText: () => composerInput.getValue(),
124
+ announce,
125
+ announceError,
126
+ emit: ctx.emit,
127
+ });
128
+
129
+ const buildController = (): ContextMentionController =>
130
+ new ContextMentionController({
131
+ mentionConfig: ctx.mentionConfig,
132
+ composerInput,
133
+ anchor: ctx.anchor,
134
+ getMessages: ctx.getMessages,
135
+ getConfig: ctx.getConfig,
136
+ onSelect: (source, item, args) => manager.add(source, item, args),
137
+ // Inline commit: the controller inserts the token, then tracks its resolve
138
+ // keyed by the composer id. `reportStatus` reflects the resolve outcome onto
139
+ // the live token element (error styling — inline tokens carry no chip).
140
+ onInsertMention: (id, source, item, args) =>
141
+ manager.track(id, source, item, args, (status) =>
142
+ composerInput.setMentionStatus?.(id, status)
143
+ ),
144
+ admitMention: (source, item) => manager.admit(source, item),
145
+ announce,
146
+ popoverContainer: ctx.popoverContainer,
147
+ onPickerOpenChange: ctx.onPickerOpenChange,
148
+ emit: ctx.emit,
149
+ });
150
+
151
+ let controller = buildController();
152
+
153
+ return {
154
+ openMenu: (trigger) => controller.openFromButton(trigger),
155
+ isMenuOpen: () => controller.isOpen(),
156
+ handleInput: () => controller.onInput(),
157
+ handleKeydown: (event) => controller.handleKeydown(event),
158
+ hasMentions: () => manager.hasMentions(),
159
+ removeLastChip: () => manager.removeLast(),
160
+ dispatchInlineCommand: (text) => controller.dispatchInlineCommand(text),
161
+ collectForSubmit: () =>
162
+ manager.hasMentions() ? manager.collectForSubmit() : null,
163
+ untrackMention: (id) => manager.remove(id),
164
+ rebindComposer: (input) => {
165
+ if (input === composerInput) return;
166
+ // Re-mount ONLY the menu layer on the new surface. The manager — committed
167
+ // chips and their in-flight resolves — must survive: a mention picked
168
+ // pre-swap stays a valid chip post-swap (tokens can't be retro-inserted
169
+ // into text the user already typed) and still finalizes at submit.
170
+ controller.destroy();
171
+ composerInput = input;
172
+ controller = buildController();
173
+ },
174
+ clear: () => manager.clear(),
175
+ destroy: () => {
176
+ controller.destroy();
177
+ manager.clear();
178
+ liveRegion.destroy();
179
+ errorRegion.destroy();
180
+ },
181
+ };
182
+ }
183
+
184
+ export type { MentionSubmitBundle };
185
+ export type { InlineCommandResult };