@runtypelabs/persona 4.7.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/animations/glyph-cycle.cjs +2 -2
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -2
- package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
- package/dist/animations/wipe.cjs +2 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-5EIIHQLQ.js +1 -0
- 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 +13 -13
- package/dist/codegen.js +14 -14
- 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 +99 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1507 -182
- package/dist/index.d.ts +1507 -182
- package/dist/index.global.js +88 -56
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +99 -68
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +24 -24
- 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/runtype-tts-entry-UJAEF7NZ.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-reconnect-JKIJBHS5.js +1 -0
- package/dist/smart-dom-reader.cjs +18 -17
- package/dist/smart-dom-reader.d.cts +1260 -16
- package/dist/smart-dom-reader.d.ts +1260 -16
- package/dist/smart-dom-reader.js +18 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +260 -58
- package/dist/theme-editor-preview.d.cts +1234 -15
- package/dist/theme-editor-preview.d.ts +1234 -15
- package/dist/theme-editor-preview.js +87 -58
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +1226 -15
- package/dist/theme-editor.d.ts +1226 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +76 -0
- package/dist/theme-reference.d.ts +76 -0
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +2 -2
- package/dist/voice-worklet-player.js +2 -2
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +16 -5
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +255 -1
- package/src/client.ts +157 -60
- package/src/components/artifact-card.test.ts +333 -0
- package/src/components/artifact-card.ts +75 -28
- package/src/components/artifact-inline.test.ts +1328 -0
- package/src/components/artifact-inline.ts +920 -0
- package/src/components/artifact-pane.test.ts +1042 -0
- package/src/components/artifact-pane.ts +440 -131
- package/src/components/artifact-preview.test.ts +1155 -0
- package/src/components/artifact-preview.ts +994 -0
- 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/components/pill-composer-builder.test.ts +6 -2
- package/src/components/pill-composer-builder.ts +6 -6
- package/src/components/reasoning-bubble.ts +1 -13
- package/src/components/registry.ts +38 -3
- package/src/components/tool-bubble.ts +1 -13
- 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/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +46 -1
- package/src/index-global.ts +51 -0
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +83 -27
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/host-layout.test.ts +163 -0
- package/src/runtime/host-layout.ts +110 -7
- package/src/runtime/init.ts +18 -61
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +197 -27
- 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 +1002 -152
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +13 -5
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +79 -0
- package/src/types.ts +1041 -17
- package/src/ui.artifact-pane-gating.test.ts +636 -0
- package/src/ui.component-directive.test.ts +104 -0
- package/src/ui.composer-bar.test.ts +60 -2
- package/src/ui.detached-panel.test.ts +1049 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +998 -159
- package/src/utils/artifact-custom-actions.ts +128 -0
- package/src/utils/artifact-display.test.ts +42 -0
- package/src/utils/artifact-display.ts +84 -0
- package/src/utils/artifact-file.test.ts +116 -0
- package/src/utils/artifact-file.ts +117 -0
- package/src/utils/artifact-gate.test.ts +112 -5
- package/src/utils/artifact-gate.ts +39 -14
- package/src/utils/artifact-loading-status.ts +55 -0
- package/src/utils/artifact-status-label.ts +190 -0
- package/src/utils/buttons.ts +7 -1
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -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/icons.ts +2 -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/roving-tablist.test.ts +152 -0
- package/src/utils/roving-tablist.ts +111 -0
- package/src/utils/spinner.ts +45 -0
- package/src/utils/theme.test.ts +76 -4
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +118 -11
- package/src/utils/tool-loading-animation.test.ts +32 -0
- package/src/utils/tool-loading-animation.ts +24 -0
- package/dist/chunk-DFBSCFYN.js +0 -1
- package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
- package/dist/session-reconnect-U77QFUR7.js +0 -1
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
createMentionMenu,
|
|
6
|
+
type MentionMenuViewModel,
|
|
7
|
+
} from "./context-mention-menu";
|
|
8
|
+
import type {
|
|
9
|
+
AgentWidgetContextMentionConfig,
|
|
10
|
+
AgentWidgetContextMentionItem,
|
|
11
|
+
AgentWidgetContextMentionSource,
|
|
12
|
+
} from "../types";
|
|
13
|
+
|
|
14
|
+
const source: AgentWidgetContextMentionSource = {
|
|
15
|
+
id: "files",
|
|
16
|
+
label: "Files",
|
|
17
|
+
search: () => [],
|
|
18
|
+
resolve: async () => ({}),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const items: AgentWidgetContextMentionItem[] = [
|
|
22
|
+
{ id: "a", label: "App.tsx", description: "entry" },
|
|
23
|
+
{ id: "b", label: "Readme" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function vm(activeIndex = 0): MentionMenuViewModel {
|
|
27
|
+
return {
|
|
28
|
+
query: "ap",
|
|
29
|
+
groups: [{ source, items, status: "ready", truncated: false }],
|
|
30
|
+
activeIndex,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeConfig(
|
|
35
|
+
overrides: Partial<AgentWidgetContextMentionConfig> = {}
|
|
36
|
+
): AgentWidgetContextMentionConfig {
|
|
37
|
+
return { enabled: true, sources: [source], ...overrides };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("createMentionMenu", () => {
|
|
41
|
+
it("renders the built-in row (icon + label + description) by default", () => {
|
|
42
|
+
const menu = createMentionMenu({
|
|
43
|
+
config: makeConfig(),
|
|
44
|
+
listboxId: "lb",
|
|
45
|
+
onSelectIndex: vi.fn(),
|
|
46
|
+
onHoverIndex: vi.fn(),
|
|
47
|
+
});
|
|
48
|
+
menu.render(vm());
|
|
49
|
+
|
|
50
|
+
const rows = menu.el.querySelectorAll(".persona-mention-option");
|
|
51
|
+
expect(rows).toHaveLength(2);
|
|
52
|
+
expect(menu.el.querySelector(".persona-mention-option-label")?.textContent).toBe(
|
|
53
|
+
"App.tsx"
|
|
54
|
+
);
|
|
55
|
+
expect(menu.el.querySelector(".persona-mention-option-desc")?.textContent).toBe(
|
|
56
|
+
"entry"
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("renders one empty state when all groups are empty (no per-group dump)", () => {
|
|
61
|
+
const menu = createMentionMenu({
|
|
62
|
+
config: makeConfig(),
|
|
63
|
+
listboxId: "lb",
|
|
64
|
+
onSelectIndex: vi.fn(),
|
|
65
|
+
onHoverIndex: vi.fn(),
|
|
66
|
+
});
|
|
67
|
+
menu.render({
|
|
68
|
+
query: "zzz",
|
|
69
|
+
groups: [
|
|
70
|
+
{ source, items: [], status: "empty", truncated: false },
|
|
71
|
+
{
|
|
72
|
+
source: { ...source, id: "s2", label: "S2" },
|
|
73
|
+
items: [],
|
|
74
|
+
status: "empty",
|
|
75
|
+
truncated: false,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
activeIndex: 0,
|
|
79
|
+
});
|
|
80
|
+
// Exactly one "No matches", and no empty group headers.
|
|
81
|
+
expect(menu.el.querySelectorAll(".persona-mention-empty")).toHaveLength(1);
|
|
82
|
+
expect(menu.el.querySelectorAll(".persona-mention-group")).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("uses renderMentionItem for inner content while keeping the option wrapper + wiring", () => {
|
|
86
|
+
const renderMentionItem = vi.fn(
|
|
87
|
+
(ctx: {
|
|
88
|
+
item: AgentWidgetContextMentionItem;
|
|
89
|
+
source: AgentWidgetContextMentionSource;
|
|
90
|
+
query: string;
|
|
91
|
+
active: boolean;
|
|
92
|
+
index: number;
|
|
93
|
+
}) => {
|
|
94
|
+
const el = document.createElement("span");
|
|
95
|
+
el.className = "my-row";
|
|
96
|
+
el.dataset.id = ctx.item.id;
|
|
97
|
+
el.dataset.sourceId = ctx.source.id;
|
|
98
|
+
el.dataset.active = String(ctx.active);
|
|
99
|
+
el.dataset.query = ctx.query;
|
|
100
|
+
el.textContent = ctx.item.label;
|
|
101
|
+
return el;
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
const onSelectIndex = vi.fn();
|
|
105
|
+
const onHoverIndex = vi.fn();
|
|
106
|
+
const menu = createMentionMenu({
|
|
107
|
+
config: makeConfig({ renderMentionItem }),
|
|
108
|
+
listboxId: "lb",
|
|
109
|
+
onSelectIndex,
|
|
110
|
+
onHoverIndex,
|
|
111
|
+
});
|
|
112
|
+
menu.render(vm(1));
|
|
113
|
+
|
|
114
|
+
const rows = menu.el.querySelectorAll<HTMLElement>(".persona-mention-option");
|
|
115
|
+
expect(rows).toHaveLength(2);
|
|
116
|
+
// Wrapper keeps a11y semantics.
|
|
117
|
+
expect(rows[0].getAttribute("role")).toBe("option");
|
|
118
|
+
// Host inner content is used; the built-in label/icon is NOT rendered.
|
|
119
|
+
expect(menu.el.querySelectorAll(".my-row")).toHaveLength(2);
|
|
120
|
+
expect(menu.el.querySelector(".persona-mention-option-label")).toBeNull();
|
|
121
|
+
// Context carries query, source, index, and active state.
|
|
122
|
+
expect(renderMentionItem).toHaveBeenCalledWith(
|
|
123
|
+
expect.objectContaining({ query: "ap", index: 0, active: false })
|
|
124
|
+
);
|
|
125
|
+
expect(renderMentionItem).toHaveBeenCalledWith(
|
|
126
|
+
expect.objectContaining({ index: 1, active: true })
|
|
127
|
+
);
|
|
128
|
+
expect((menu.el.querySelector(".my-row") as HTMLElement).dataset.sourceId).toBe(
|
|
129
|
+
"files"
|
|
130
|
+
);
|
|
131
|
+
// Click/hover stay wired on the widget-owned wrapper.
|
|
132
|
+
rows[0].dispatchEvent(
|
|
133
|
+
new MouseEvent("mousedown", { bubbles: true, cancelable: true })
|
|
134
|
+
);
|
|
135
|
+
expect(onSelectIndex).toHaveBeenCalledWith(0);
|
|
136
|
+
rows[1].dispatchEvent(new MouseEvent("mouseenter"));
|
|
137
|
+
expect(onHoverIndex).toHaveBeenCalledWith(1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps every listbox child to option/group/presentation and labels each group by an existing header", () => {
|
|
141
|
+
const menu = createMentionMenu({
|
|
142
|
+
config: makeConfig(),
|
|
143
|
+
listboxId: "lb",
|
|
144
|
+
onSelectIndex: vi.fn(),
|
|
145
|
+
onHoverIndex: vi.fn(),
|
|
146
|
+
});
|
|
147
|
+
const docs: AgentWidgetContextMentionSource = { ...source, id: "docs", label: "Docs" };
|
|
148
|
+
menu.render({
|
|
149
|
+
query: "x",
|
|
150
|
+
groups: [
|
|
151
|
+
{ source, items, status: "ready", truncated: true },
|
|
152
|
+
{ source: docs, items: [{ id: "g", label: "Guide" }], status: "ready", truncated: false },
|
|
153
|
+
{
|
|
154
|
+
source: { ...source, id: "net", label: "Net" },
|
|
155
|
+
items: [],
|
|
156
|
+
status: "loading",
|
|
157
|
+
truncated: false,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
activeIndex: 0,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const listbox = menu.el.querySelector<HTMLElement>('[role="listbox"]')!;
|
|
164
|
+
for (const child of Array.from(listbox.children)) {
|
|
165
|
+
expect(["option", "group", "presentation"]).toContain(child.getAttribute("role"));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Each group carries role=group + aria-labelledby → an existing header id.
|
|
169
|
+
const groups = listbox.querySelectorAll<HTMLElement>('[role="group"]');
|
|
170
|
+
expect(groups).toHaveLength(3);
|
|
171
|
+
groups.forEach((g) => {
|
|
172
|
+
const headerId = g.getAttribute("aria-labelledby")!;
|
|
173
|
+
expect(headerId).toBeTruthy();
|
|
174
|
+
const header = menu.el.querySelector(`#${headerId}`);
|
|
175
|
+
expect(header?.classList.contains("persona-mention-group-header")).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Loading + truncation-hint rows are presentational (announced via live region).
|
|
179
|
+
expect(
|
|
180
|
+
menu.el.querySelector(".persona-mention-loading")?.getAttribute("role")
|
|
181
|
+
).toBe("presentation");
|
|
182
|
+
expect(
|
|
183
|
+
menu.el.querySelector(".persona-mention-hint")?.getAttribute("role")
|
|
184
|
+
).toBe("presentation");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("marks the empty-state row presentational (spoken via the live region)", () => {
|
|
188
|
+
const menu = createMentionMenu({
|
|
189
|
+
config: makeConfig(),
|
|
190
|
+
listboxId: "lb",
|
|
191
|
+
onSelectIndex: vi.fn(),
|
|
192
|
+
onHoverIndex: vi.fn(),
|
|
193
|
+
});
|
|
194
|
+
menu.render({
|
|
195
|
+
query: "zzz",
|
|
196
|
+
groups: [{ source, items: [], status: "empty", truncated: false }],
|
|
197
|
+
activeIndex: 0,
|
|
198
|
+
});
|
|
199
|
+
const empty = menu.el.querySelector(".persona-mention-empty")!;
|
|
200
|
+
expect(empty.getAttribute("role")).toBe("presentation");
|
|
201
|
+
const listbox = menu.el.querySelector<HTMLElement>('[role="listbox"]')!;
|
|
202
|
+
// The empty row is a direct listbox child; it must be presentational.
|
|
203
|
+
expect(Array.from(listbox.children)).toContain(empty);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("gives options a flat aria-setsize/aria-posinset across groups", () => {
|
|
207
|
+
const menu = createMentionMenu({
|
|
208
|
+
config: makeConfig(),
|
|
209
|
+
listboxId: "lb",
|
|
210
|
+
onSelectIndex: vi.fn(),
|
|
211
|
+
onHoverIndex: vi.fn(),
|
|
212
|
+
});
|
|
213
|
+
const docs: AgentWidgetContextMentionSource = { ...source, id: "docs", label: "Docs" };
|
|
214
|
+
menu.render({
|
|
215
|
+
query: "x",
|
|
216
|
+
groups: [
|
|
217
|
+
{ source, items, status: "ready", truncated: false }, // 2 options
|
|
218
|
+
{ source: docs, items: [{ id: "g", label: "Guide" }], status: "ready", truncated: false }, // 1 option
|
|
219
|
+
],
|
|
220
|
+
activeIndex: 0,
|
|
221
|
+
});
|
|
222
|
+
const options = menu.el.querySelectorAll<HTMLElement>('[role="option"]');
|
|
223
|
+
expect(options).toHaveLength(3);
|
|
224
|
+
// Flat total on every option; 1-based flat position across group boundaries.
|
|
225
|
+
options.forEach((opt) => expect(opt.getAttribute("aria-setsize")).toBe("3"));
|
|
226
|
+
expect(options[0].getAttribute("aria-posinset")).toBe("1");
|
|
227
|
+
expect(options[1].getAttribute("aria-posinset")).toBe("2");
|
|
228
|
+
expect(options[2].getAttribute("aria-posinset")).toBe("3");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("renders item.group overrides under their own header while ungrouped items stay under the source label", () => {
|
|
232
|
+
const menu = createMentionMenu({
|
|
233
|
+
config: makeConfig(),
|
|
234
|
+
listboxId: "lb",
|
|
235
|
+
onSelectIndex: vi.fn(),
|
|
236
|
+
onHoverIndex: vi.fn(),
|
|
237
|
+
});
|
|
238
|
+
menu.render({
|
|
239
|
+
query: "x",
|
|
240
|
+
groups: [
|
|
241
|
+
{
|
|
242
|
+
source,
|
|
243
|
+
items: [
|
|
244
|
+
{ id: "a", label: "App.tsx" },
|
|
245
|
+
{ id: "b", label: "Recent doc", group: "Recent" },
|
|
246
|
+
],
|
|
247
|
+
status: "ready",
|
|
248
|
+
truncated: false,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
activeIndex: 0,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const headers = Array.from(
|
|
255
|
+
menu.el.querySelectorAll<HTMLElement>(".persona-mention-group-header")
|
|
256
|
+
).map((h) => h.textContent);
|
|
257
|
+
// Source label first (ungrouped item), then the override header.
|
|
258
|
+
expect(headers).toEqual(["Files", "Recent"]);
|
|
259
|
+
|
|
260
|
+
const sections = menu.el.querySelectorAll<HTMLElement>(".persona-mention-group");
|
|
261
|
+
expect(sections).toHaveLength(2);
|
|
262
|
+
expect(sections[0].querySelector(".persona-mention-option-label")?.textContent).toBe(
|
|
263
|
+
"App.tsx"
|
|
264
|
+
);
|
|
265
|
+
expect(sections[1].querySelector(".persona-mention-option-label")?.textContent).toBe(
|
|
266
|
+
"Recent doc"
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("shares one header for items with the same group override", () => {
|
|
271
|
+
const menu = createMentionMenu({
|
|
272
|
+
config: makeConfig(),
|
|
273
|
+
listboxId: "lb",
|
|
274
|
+
onSelectIndex: vi.fn(),
|
|
275
|
+
onHoverIndex: vi.fn(),
|
|
276
|
+
});
|
|
277
|
+
menu.render({
|
|
278
|
+
query: "x",
|
|
279
|
+
groups: [
|
|
280
|
+
{
|
|
281
|
+
source,
|
|
282
|
+
items: [
|
|
283
|
+
{ id: "a", label: "One", group: "Pinned" },
|
|
284
|
+
{ id: "b", label: "Two", group: "Pinned" },
|
|
285
|
+
],
|
|
286
|
+
status: "ready",
|
|
287
|
+
truncated: false,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
activeIndex: 0,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const headers = menu.el.querySelectorAll<HTMLElement>(".persona-mention-group-header");
|
|
294
|
+
expect(headers).toHaveLength(1);
|
|
295
|
+
expect(headers[0].textContent).toBe("Pinned");
|
|
296
|
+
// Both options live under the single shared header.
|
|
297
|
+
const section = menu.el.querySelector<HTMLElement>(".persona-mention-group")!;
|
|
298
|
+
expect(section.querySelectorAll(".persona-mention-option")).toHaveLength(2);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("keeps the flat keyboard order matching visual order across partitions", () => {
|
|
302
|
+
const menu = createMentionMenu({
|
|
303
|
+
config: makeConfig(),
|
|
304
|
+
listboxId: "lb",
|
|
305
|
+
onSelectIndex: vi.fn(),
|
|
306
|
+
onHoverIndex: vi.fn(),
|
|
307
|
+
});
|
|
308
|
+
document.body.appendChild(menu.el);
|
|
309
|
+
menu.render({
|
|
310
|
+
query: "x",
|
|
311
|
+
groups: [
|
|
312
|
+
{
|
|
313
|
+
source,
|
|
314
|
+
items: [
|
|
315
|
+
{ id: "a", label: "Alpha" },
|
|
316
|
+
{ id: "b", label: "Bravo", group: "Recent" },
|
|
317
|
+
{ id: "c", label: "Charlie" },
|
|
318
|
+
],
|
|
319
|
+
status: "ready",
|
|
320
|
+
truncated: false,
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
activeIndex: 0,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Partitioning preserves first-appearance order: [Files: Alpha, Charlie],
|
|
327
|
+
// [Recent: Bravo]. Flat option indices follow that visual order.
|
|
328
|
+
const options = menu.el.querySelectorAll<HTMLElement>('[role="option"]');
|
|
329
|
+
expect(Array.from(options).map((o) => o.textContent)).toEqual([
|
|
330
|
+
"Alpha",
|
|
331
|
+
"Charlie",
|
|
332
|
+
"Bravo",
|
|
333
|
+
]);
|
|
334
|
+
// aria-posinset is 1-based flat and monotonic with visual order.
|
|
335
|
+
expect(Array.from(options).map((o) => o.getAttribute("aria-posinset"))).toEqual([
|
|
336
|
+
"1",
|
|
337
|
+
"2",
|
|
338
|
+
"3",
|
|
339
|
+
]);
|
|
340
|
+
// The listbox's active descendant is the first flat option (visual order).
|
|
341
|
+
const listbox = menu.el.querySelector<HTMLElement>('[role="listbox"]')!;
|
|
342
|
+
expect(listbox.getAttribute("aria-activedescendant")).toBe(options[0].id);
|
|
343
|
+
// Advancing the highlight lands on the second visual option (Charlie).
|
|
344
|
+
menu.setActiveIndex(1);
|
|
345
|
+
expect(listbox.getAttribute("aria-activedescendant")).toBe(options[1].id);
|
|
346
|
+
expect(options[1].textContent).toBe("Charlie");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("toggles the search field's aria-expanded with its visibility", () => {
|
|
350
|
+
const menu = createMentionMenu({
|
|
351
|
+
config: makeConfig(),
|
|
352
|
+
listboxId: "lb",
|
|
353
|
+
onSelectIndex: vi.fn(),
|
|
354
|
+
onHoverIndex: vi.fn(),
|
|
355
|
+
});
|
|
356
|
+
document.body.appendChild(menu.el);
|
|
357
|
+
const input = menu.el.querySelector<HTMLInputElement>(".persona-mention-search-input")!;
|
|
358
|
+
// Hidden combobox never advertises an expanded popup.
|
|
359
|
+
expect(input.getAttribute("aria-expanded")).toBe("false");
|
|
360
|
+
menu.showSearch?.("");
|
|
361
|
+
expect(input.getAttribute("aria-expanded")).toBe("true");
|
|
362
|
+
menu.hideSearch?.();
|
|
363
|
+
expect(input.getAttribute("aria-expanded")).toBe("false");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("reuses the same row elements when re-rendering an identical result set", () => {
|
|
367
|
+
const menu = createMentionMenu({
|
|
368
|
+
config: makeConfig(),
|
|
369
|
+
listboxId: "lb",
|
|
370
|
+
onSelectIndex: vi.fn(),
|
|
371
|
+
onHoverIndex: vi.fn(),
|
|
372
|
+
});
|
|
373
|
+
menu.render(vm());
|
|
374
|
+
const first = Array.from(
|
|
375
|
+
menu.el.querySelectorAll<HTMLElement>(".persona-mention-option")
|
|
376
|
+
);
|
|
377
|
+
menu.render(vm());
|
|
378
|
+
const second = Array.from(
|
|
379
|
+
menu.el.querySelectorAll<HTMLElement>(".persona-mention-option")
|
|
380
|
+
);
|
|
381
|
+
// Same DOM nodes across renders — no rebuild churn.
|
|
382
|
+
expect(second[0]).toBe(first[0]);
|
|
383
|
+
expect(second[1]).toBe(first[1]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("keeps surviving rows' identity and updates aria-setsize when an item is removed", () => {
|
|
387
|
+
const menu = createMentionMenu({
|
|
388
|
+
config: makeConfig(),
|
|
389
|
+
listboxId: "lb",
|
|
390
|
+
onSelectIndex: vi.fn(),
|
|
391
|
+
onHoverIndex: vi.fn(),
|
|
392
|
+
});
|
|
393
|
+
menu.render(vm());
|
|
394
|
+
const firstRow = menu.el.querySelector<HTMLElement>(".persona-mention-option")!;
|
|
395
|
+
expect(firstRow.getAttribute("aria-setsize")).toBe("2");
|
|
396
|
+
|
|
397
|
+
menu.render({
|
|
398
|
+
query: "ap",
|
|
399
|
+
groups: [{ source, items: [items[0]], status: "ready", truncated: false }],
|
|
400
|
+
activeIndex: 0,
|
|
401
|
+
});
|
|
402
|
+
const rows = menu.el.querySelectorAll<HTMLElement>(".persona-mention-option");
|
|
403
|
+
expect(rows).toHaveLength(1);
|
|
404
|
+
// Surviving row is the very same element, with refreshed setsize.
|
|
405
|
+
expect(rows[0]).toBe(firstRow);
|
|
406
|
+
expect(rows[0].getAttribute("aria-setsize")).toBe("1");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("dispatches the correct item after a reorder (no stale flat index in the click handler)", () => {
|
|
410
|
+
const onSelectIndex = vi.fn();
|
|
411
|
+
const menu = createMentionMenu({
|
|
412
|
+
config: makeConfig(),
|
|
413
|
+
listboxId: "lb",
|
|
414
|
+
onSelectIndex,
|
|
415
|
+
onHoverIndex: vi.fn(),
|
|
416
|
+
});
|
|
417
|
+
menu.render(vm());
|
|
418
|
+
const appRow = Array.from(
|
|
419
|
+
menu.el.querySelectorAll<HTMLElement>(".persona-mention-option")
|
|
420
|
+
).find((r) => r.textContent?.includes("App.tsx"))!;
|
|
421
|
+
|
|
422
|
+
// Reverse the order: App.tsx (same element) is now the second flat option.
|
|
423
|
+
menu.render({
|
|
424
|
+
query: "ap",
|
|
425
|
+
groups: [{ source, items: [items[1], items[0]], status: "ready", truncated: false }],
|
|
426
|
+
activeIndex: 0,
|
|
427
|
+
});
|
|
428
|
+
// It's the same reused node, now at visual index 1.
|
|
429
|
+
const rows = Array.from(
|
|
430
|
+
menu.el.querySelectorAll<HTMLElement>(".persona-mention-option")
|
|
431
|
+
);
|
|
432
|
+
expect(rows[1]).toBe(appRow);
|
|
433
|
+
appRow.dispatchEvent(
|
|
434
|
+
new MouseEvent("mousedown", { bubbles: true, cancelable: true })
|
|
435
|
+
);
|
|
436
|
+
// Handler reads the live index, so it dispatches 1, not the stale 0.
|
|
437
|
+
expect(onSelectIndex).toHaveBeenCalledWith(1);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("clones cached icons so rows sharing an icon get distinct SVG elements", () => {
|
|
441
|
+
const menu = createMentionMenu({
|
|
442
|
+
config: makeConfig(),
|
|
443
|
+
listboxId: "lb",
|
|
444
|
+
onSelectIndex: vi.fn(),
|
|
445
|
+
onHoverIndex: vi.fn(),
|
|
446
|
+
});
|
|
447
|
+
// Both items fall back to the same default icon ("at-sign").
|
|
448
|
+
menu.render({
|
|
449
|
+
query: "x",
|
|
450
|
+
groups: [
|
|
451
|
+
{
|
|
452
|
+
source,
|
|
453
|
+
items: [
|
|
454
|
+
{ id: "a", label: "One" },
|
|
455
|
+
{ id: "b", label: "Two" },
|
|
456
|
+
],
|
|
457
|
+
status: "ready",
|
|
458
|
+
truncated: false,
|
|
459
|
+
},
|
|
460
|
+
],
|
|
461
|
+
activeIndex: 0,
|
|
462
|
+
});
|
|
463
|
+
const svgs = menu.el.querySelectorAll(".persona-mention-option-icon svg");
|
|
464
|
+
expect(svgs).toHaveLength(2);
|
|
465
|
+
// Distinct nodes (clones), not the same cached instance shared into two rows.
|
|
466
|
+
expect(svgs[0]).not.toBe(svgs[1]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("hides the picker search field until showSearch, then wires input/keydown", () => {
|
|
470
|
+
const onSearchInput = vi.fn();
|
|
471
|
+
const onSearchKeydown = vi.fn();
|
|
472
|
+
const menu = createMentionMenu({
|
|
473
|
+
config: makeConfig({ searchPlaceholder: "Find a file…" }),
|
|
474
|
+
listboxId: "lb",
|
|
475
|
+
onSelectIndex: vi.fn(),
|
|
476
|
+
onHoverIndex: vi.fn(),
|
|
477
|
+
onSearchInput,
|
|
478
|
+
onSearchKeydown,
|
|
479
|
+
});
|
|
480
|
+
document.body.appendChild(menu.el);
|
|
481
|
+
|
|
482
|
+
const wrap = menu.el.querySelector<HTMLElement>(".persona-mention-search")!;
|
|
483
|
+
const input = menu.el.querySelector<HTMLInputElement>(".persona-mention-search-input")!;
|
|
484
|
+
// Hidden by default; placeholder is configurable.
|
|
485
|
+
expect(wrap.style.display).toBe("none");
|
|
486
|
+
expect(input.placeholder).toBe("Find a file…");
|
|
487
|
+
// Options render into the inner listbox, not the root, so the search field
|
|
488
|
+
// survives a re-render.
|
|
489
|
+
menu.render(vm());
|
|
490
|
+
expect(wrap.isConnected).toBe(true);
|
|
491
|
+
expect(menu.el.querySelectorAll(".persona-mention-option")).toHaveLength(2);
|
|
492
|
+
|
|
493
|
+
menu.showSearch?.("");
|
|
494
|
+
expect(wrap.style.display).not.toBe("none");
|
|
495
|
+
expect(document.activeElement).toBe(input);
|
|
496
|
+
|
|
497
|
+
input.value = "app";
|
|
498
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
499
|
+
expect(onSearchInput).toHaveBeenCalledWith("app");
|
|
500
|
+
|
|
501
|
+
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" }));
|
|
502
|
+
expect(onSearchKeydown).toHaveBeenCalled();
|
|
503
|
+
|
|
504
|
+
menu.hideSearch?.();
|
|
505
|
+
expect(wrap.style.display).toBe("none");
|
|
506
|
+
expect(input.value).toBe("");
|
|
507
|
+
});
|
|
508
|
+
});
|
|
Binary file
|
|
@@ -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);
|