@runtypelabs/persona 4.8.0 → 4.10.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 +14 -1
- 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 +72 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +634 -3
- package/dist/index.d.ts +634 -3
- package/dist/index.global.js +59 -51
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +64 -56
- 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 +236 -57
- package/dist/theme-editor-preview.d.cts +485 -1
- package/dist/theme-editor-preview.d.ts +485 -1
- package/dist/theme-editor-preview.js +53 -47
- 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/artifact-pane.test.ts +47 -0
- package/src/components/artifact-pane.ts +25 -2
- 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 +27 -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/runtime/init-update-reset.test.ts +81 -0
- package/src/runtime/init.test.ts +62 -0
- package/src/runtime/init.ts +7 -14
- 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 +243 -126
- package/src/theme-editor/sections.ts +3 -3
- package/src/types/theme.ts +2 -0
- package/src/types.ts +542 -0
- package/src/ui.artifact-pane-gating.test.ts +11 -1
- package/src/ui.attachments-drop.test.ts +90 -0
- package/src/ui.header-update-stability.test.ts +149 -0
- package/src/ui.launcher-update-merge.test.ts +83 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.send-button-stream-update.test.ts +69 -0
- package/src/ui.ts +379 -84
- 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/config-merge.test.ts +131 -0
- package/src/utils/config-merge.ts +61 -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 +54 -4
- package/src/utils/theme.ts +6 -3
- package/src/utils/tokens.ts +27 -11
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { describe, it, expect } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
createStandardBubble,
|
|
5
|
+
createMessageInlineMentions,
|
|
5
6
|
isSafeImageSrc,
|
|
6
7
|
isSafeMediaSrc,
|
|
7
8
|
resolveStopReasonNoticeText,
|
|
@@ -17,6 +18,180 @@ const makeMessage = (overrides: Partial<AgentWidgetMessage> = {}): AgentWidgetMe
|
|
|
17
18
|
...overrides,
|
|
18
19
|
});
|
|
19
20
|
|
|
21
|
+
describe("createMessageInlineMentions", () => {
|
|
22
|
+
it("walks segments into prose text nodes + read-only token spans", () => {
|
|
23
|
+
const frag = createMessageInlineMentions([
|
|
24
|
+
{ kind: "text", text: "Check " },
|
|
25
|
+
{ kind: "mention", ref: { sourceId: "files", itemId: "app", label: "App.tsx" } },
|
|
26
|
+
{ kind: "text", text: " for errors" },
|
|
27
|
+
]);
|
|
28
|
+
const host = document.createElement("div");
|
|
29
|
+
host.appendChild(frag);
|
|
30
|
+
expect(host.textContent).toBe("Check @App.tsx for errors");
|
|
31
|
+
const tokens = host.querySelectorAll(".persona-mention-token");
|
|
32
|
+
expect(tokens).toHaveLength(1);
|
|
33
|
+
expect(tokens[0].classList.contains("persona-mention-token-readonly")).toBe(true);
|
|
34
|
+
// Per-type theming hook: the source id is exposed for CSS/data targeting.
|
|
35
|
+
expect(tokens[0].getAttribute("data-mention-source")).toBe("files");
|
|
36
|
+
expect(tokens[0].querySelector(".persona-mention-token-label")?.textContent).toBe(
|
|
37
|
+
"@App.tsx"
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("applies a per-item color as the token accent and honors renderMentionToken", () => {
|
|
42
|
+
// Custom render hook fully replaces the token DOM.
|
|
43
|
+
const custom = createMessageInlineMentions(
|
|
44
|
+
[{ kind: "mention", ref: { sourceId: "files", itemId: "app", label: "App.tsx" } }],
|
|
45
|
+
({ ref }) => {
|
|
46
|
+
const el = document.createElement("b");
|
|
47
|
+
el.className = "my-token";
|
|
48
|
+
el.textContent = ref.label;
|
|
49
|
+
return el;
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
const host = document.createElement("div");
|
|
53
|
+
host.appendChild(custom);
|
|
54
|
+
expect(host.querySelector(".my-token")?.textContent).toBe("App.tsx");
|
|
55
|
+
|
|
56
|
+
// Per-item color sets the accent custom property inline.
|
|
57
|
+
const colored = createMessageInlineMentions([
|
|
58
|
+
{ kind: "mention", ref: { sourceId: "files", itemId: "app", label: "App.tsx", color: "#e11d48" } },
|
|
59
|
+
]);
|
|
60
|
+
const host2 = document.createElement("div");
|
|
61
|
+
host2.appendChild(colored);
|
|
62
|
+
const token = host2.querySelector<HTMLElement>(".persona-mention-token")!;
|
|
63
|
+
expect(token.style.getPropertyValue("--persona-mention-token-accent")).toBe("#e11d48");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("renders a segmented user bubble with inline tokens and no chip row", () => {
|
|
67
|
+
const bubble = createStandardBubble(
|
|
68
|
+
makeMessage({
|
|
69
|
+
role: "user",
|
|
70
|
+
content: "Check @App.tsx for errors",
|
|
71
|
+
contextMentions: [{ sourceId: "files", itemId: "app", label: "App.tsx" }],
|
|
72
|
+
contentSegments: [
|
|
73
|
+
{ kind: "text", text: "Check " },
|
|
74
|
+
{ kind: "mention", ref: { sourceId: "files", itemId: "app", label: "App.tsx" } },
|
|
75
|
+
{ kind: "text", text: " for errors" },
|
|
76
|
+
],
|
|
77
|
+
}),
|
|
78
|
+
({ text }) => text
|
|
79
|
+
);
|
|
80
|
+
expect(bubble.querySelectorAll(".persona-mention-token")).toHaveLength(1);
|
|
81
|
+
// Inline mode replaces the separate chip row.
|
|
82
|
+
expect(bubble.querySelector("[data-message-mentions]")).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const segmentedMessage = () =>
|
|
86
|
+
makeMessage({
|
|
87
|
+
role: "user",
|
|
88
|
+
content: "Check @App.tsx for errors",
|
|
89
|
+
contentSegments: [
|
|
90
|
+
{ kind: "text", text: "Check " },
|
|
91
|
+
{ kind: "mention", ref: { sourceId: "files", itemId: "app", label: "App.tsx" } },
|
|
92
|
+
{ kind: "text", text: " for errors" },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("runs segmented prose through the transform so markdown-style output renders", () => {
|
|
97
|
+
const bubble = createStandardBubble(
|
|
98
|
+
makeMessage({
|
|
99
|
+
role: "user",
|
|
100
|
+
content: "see **bold** and @App.tsx",
|
|
101
|
+
contentSegments: [
|
|
102
|
+
{ kind: "text", text: "see **bold** and " },
|
|
103
|
+
{ kind: "mention", ref: { sourceId: "files", itemId: "app", label: "App.tsx" } },
|
|
104
|
+
],
|
|
105
|
+
}),
|
|
106
|
+
({ text }) => `<p>${text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")}</p>`
|
|
107
|
+
);
|
|
108
|
+
expect(bubble.querySelector("strong")?.textContent).toBe("bold");
|
|
109
|
+
const token = bubble.querySelector(".persona-mention-token");
|
|
110
|
+
expect(token).not.toBeNull();
|
|
111
|
+
// The token landed inside the transform's block element, in prose order.
|
|
112
|
+
expect(token?.closest("p")).not.toBeNull();
|
|
113
|
+
expect(bubble.querySelector("p")?.textContent).toBe("see bold and @App.tsx");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("escapes segment text when the transform escapes (default pipeline shape)", () => {
|
|
117
|
+
const bubble = createStandardBubble(
|
|
118
|
+
makeMessage({
|
|
119
|
+
role: "user",
|
|
120
|
+
contentSegments: [
|
|
121
|
+
{ kind: "text", text: "<script>alert(1)</script> " },
|
|
122
|
+
{ kind: "mention", ref: { sourceId: "files", itemId: "app", label: "App.tsx" } },
|
|
123
|
+
],
|
|
124
|
+
}),
|
|
125
|
+
({ text }) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
126
|
+
);
|
|
127
|
+
expect(bubble.querySelector("script")).toBeNull();
|
|
128
|
+
expect(bubble.textContent).toContain("<script>alert(1)</script>");
|
|
129
|
+
expect(bubble.querySelectorAll(".persona-mention-token")).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("falls back to verbatim segment rendering when a transform drops a mention slot", () => {
|
|
133
|
+
const bubble = createStandardBubble(segmentedMessage(), () => "rewritten output");
|
|
134
|
+
// The mention survives via the fallback instead of vanishing with the slot.
|
|
135
|
+
expect(bubble.querySelectorAll(".persona-mention-token")).toHaveLength(1);
|
|
136
|
+
expect(bubble.textContent).toContain("Check @App.tsx for errors");
|
|
137
|
+
expect(bubble.textContent).not.toContain("rewritten output");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does not swap user-typed sentinel lookalikes", () => {
|
|
141
|
+
// Real sentinel chars with a wrong nonce: must never match a slot.
|
|
142
|
+
const lookalike = "\uE000abcdef:0\uE001";
|
|
143
|
+
const bubble = createStandardBubble(
|
|
144
|
+
makeMessage({
|
|
145
|
+
role: "user",
|
|
146
|
+
contentSegments: [
|
|
147
|
+
{ kind: "text", text: `pasted ${lookalike} ` },
|
|
148
|
+
{ kind: "mention", ref: { sourceId: "files", itemId: "app", label: "App.tsx" } },
|
|
149
|
+
],
|
|
150
|
+
}),
|
|
151
|
+
({ text }) => text
|
|
152
|
+
);
|
|
153
|
+
// Only the real slot becomes a token; the lookalike stays literal text.
|
|
154
|
+
expect(bubble.querySelectorAll(".persona-mention-token")).toHaveLength(1);
|
|
155
|
+
expect(bubble.textContent).toContain(lookalike);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("keeps mention slots through the real DOMPurify sanitizer", async () => {
|
|
159
|
+
const { createDefaultSanitizer } = await import("../utils/sanitize");
|
|
160
|
+
const sanitize = createDefaultSanitizer();
|
|
161
|
+
const bubble = createStandardBubble(
|
|
162
|
+
segmentedMessage(),
|
|
163
|
+
({ text }) => sanitize(`<p>${text}</p>`)
|
|
164
|
+
);
|
|
165
|
+
expect(bubble.querySelectorAll(".persona-mention-token")).toHaveLength(1);
|
|
166
|
+
expect(bubble.querySelector("p")?.textContent).toBe("Check @App.tsx for errors");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("swaps multiple mentions across transform-created blocks", () => {
|
|
170
|
+
const bubble = createStandardBubble(
|
|
171
|
+
makeMessage({
|
|
172
|
+
role: "user",
|
|
173
|
+
contentSegments: [
|
|
174
|
+
{ kind: "text", text: "compare " },
|
|
175
|
+
{ kind: "mention", ref: { sourceId: "files", itemId: "a", label: "a.ts" } },
|
|
176
|
+
{ kind: "text", text: "\n\nwith " },
|
|
177
|
+
{ kind: "mention", ref: { sourceId: "files", itemId: "b", label: "b.ts" } },
|
|
178
|
+
],
|
|
179
|
+
}),
|
|
180
|
+
({ text }) =>
|
|
181
|
+
text
|
|
182
|
+
.split("\n\n")
|
|
183
|
+
.map((p) => `<p>${p}</p>`)
|
|
184
|
+
.join("")
|
|
185
|
+
);
|
|
186
|
+
const tokens = bubble.querySelectorAll(".persona-mention-token");
|
|
187
|
+
expect(tokens).toHaveLength(2);
|
|
188
|
+
const paragraphs = bubble.querySelectorAll("p");
|
|
189
|
+
expect(paragraphs).toHaveLength(2);
|
|
190
|
+
expect(paragraphs[0]?.textContent).toBe("compare @a.ts");
|
|
191
|
+
expect(paragraphs[1]?.textContent).toBe("with @b.ts");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
20
195
|
describe("isSafeImageSrc", () => {
|
|
21
196
|
it("allows https URLs", () => {
|
|
22
197
|
expect(isSafeImageSrc("https://example.com/img.png")).toBe(true);
|
|
@@ -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
|
+
});
|