@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.
- package/README.md +1 -0
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
- package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +6 -6
- package/dist/codegen.js +8 -8
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +59 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +622 -2
- package/dist/index.d.ts +622 -2
- package/dist/index.global.js +54 -46
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +59 -51
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.cjs +17 -16
- package/dist/smart-dom-reader.d.cts +507 -1
- package/dist/smart-dom-reader.d.ts +507 -1
- package/dist/smart-dom-reader.js +17 -16
- package/dist/theme-editor-preview.cjs +234 -55
- package/dist/theme-editor-preview.d.cts +473 -0
- package/dist/theme-editor-preview.d.ts +473 -0
- package/dist/theme-editor-preview.js +48 -42
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +473 -0
- package/dist/theme-editor.d.ts +473 -0
- package/dist/theme-editor.js +5 -5
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/widget.css +1 -1
- package/package.json +15 -3
- package/src/client.test.ts +69 -0
- package/src/client.ts +65 -51
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/index-core.ts +26 -0
- package/src/index-global.ts +51 -0
- package/src/markdown-parsers-loader.ts +35 -44
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +121 -5
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +229 -126
- package/src/theme-editor/sections.ts +3 -3
- package/src/types/theme.ts +2 -0
- package/src/types.ts +525 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.ts +301 -51
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/theme.test.ts +28 -4
- 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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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 =
|
|
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 =
|
|
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
|
package/src/components/panel.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
420
|
-
//
|
|
421
|
-
// when
|
|
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,
|
|
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 };
|