@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.
Files changed (130) hide show
  1. package/README.md +14 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
  5. package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-IO5VVUKP.js +3 -0
  9. package/dist/chunk-IPVK3KOM.js +1 -0
  10. package/dist/chunk-UPO4GUFC.js +1 -0
  11. package/dist/codegen.cjs +6 -6
  12. package/dist/codegen.js +8 -8
  13. package/dist/context-mentions-7S5KVUTG.js +169 -0
  14. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  15. package/dist/context-mentions-inline.cjs +4 -0
  16. package/dist/context-mentions-inline.d.cts +203 -0
  17. package/dist/context-mentions-inline.d.ts +203 -0
  18. package/dist/context-mentions-inline.js +4 -0
  19. package/dist/context-mentions.cjs +295 -0
  20. package/dist/context-mentions.d.cts +7025 -0
  21. package/dist/context-mentions.d.ts +7025 -0
  22. package/dist/context-mentions.js +295 -0
  23. package/dist/index.cjs +72 -64
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +634 -3
  26. package/dist/index.d.ts +634 -3
  27. package/dist/index.global.js +59 -51
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +64 -56
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js +2 -2
  32. package/dist/launcher.global.js.map +1 -1
  33. package/dist/plugin-kit.cjs +1 -1
  34. package/dist/plugin-kit.d.cts +17 -0
  35. package/dist/plugin-kit.d.ts +17 -0
  36. package/dist/plugin-kit.js +1 -1
  37. package/dist/smart-dom-reader.cjs +17 -16
  38. package/dist/smart-dom-reader.d.cts +507 -1
  39. package/dist/smart-dom-reader.d.ts +507 -1
  40. package/dist/smart-dom-reader.js +17 -16
  41. package/dist/theme-editor-preview.cjs +236 -57
  42. package/dist/theme-editor-preview.d.cts +485 -1
  43. package/dist/theme-editor-preview.d.ts +485 -1
  44. package/dist/theme-editor-preview.js +53 -47
  45. package/dist/theme-editor.cjs +7 -7
  46. package/dist/theme-editor.d.cts +473 -0
  47. package/dist/theme-editor.d.ts +473 -0
  48. package/dist/theme-editor.js +5 -5
  49. package/dist/theme-reference.cjs +1 -1
  50. package/dist/theme-reference.d.cts +2 -0
  51. package/dist/theme-reference.d.ts +2 -0
  52. package/dist/widget.css +1 -1
  53. package/package.json +15 -3
  54. package/src/client.test.ts +69 -0
  55. package/src/client.ts +65 -51
  56. package/src/components/artifact-pane.test.ts +47 -0
  57. package/src/components/artifact-pane.ts +25 -2
  58. package/src/components/composer-parts.ts +3 -12
  59. package/src/components/context-mention-button.test.ts +70 -0
  60. package/src/components/context-mention-button.ts +82 -0
  61. package/src/components/context-mention-chip.ts +134 -0
  62. package/src/components/context-mention-menu.test.ts +508 -0
  63. package/src/components/context-mention-menu.ts +0 -0
  64. package/src/components/message-bubble.test.ts +175 -0
  65. package/src/components/message-bubble.ts +177 -19
  66. package/src/components/panel.ts +7 -10
  67. package/src/context-mentions-bundle.test.ts +163 -0
  68. package/src/context-mentions-entry.ts +185 -0
  69. package/src/context-mentions-inline-entry.test.ts +136 -0
  70. package/src/context-mentions-inline-entry.ts +226 -0
  71. package/src/context-mentions-inline-loader.test.ts +30 -0
  72. package/src/context-mentions-inline-loader.ts +36 -0
  73. package/src/context-mentions-inline.ts +15 -0
  74. package/src/context-mentions-loader.ts +32 -0
  75. package/src/context-mentions.ts +16 -0
  76. package/src/index-core.ts +27 -0
  77. package/src/index-global.ts +51 -0
  78. package/src/markdown-parsers-loader.ts +35 -44
  79. package/src/plugin-kit.test.ts +40 -0
  80. package/src/plugin-kit.ts +39 -5
  81. package/src/runtime/init-update-reset.test.ts +81 -0
  82. package/src/runtime/init.test.ts +62 -0
  83. package/src/runtime/init.ts +7 -14
  84. package/src/session.mentions.test.ts +175 -0
  85. package/src/session.test.ts +52 -4
  86. package/src/session.ts +121 -5
  87. package/src/smart-dom-reader.test.ts +129 -2
  88. package/src/smart-dom-reader.ts +127 -1
  89. package/src/styles/context-mention-menu-css.ts +176 -0
  90. package/src/styles/widget.css +243 -126
  91. package/src/theme-editor/sections.ts +3 -3
  92. package/src/types/theme.ts +2 -0
  93. package/src/types.ts +542 -0
  94. package/src/ui.artifact-pane-gating.test.ts +11 -1
  95. package/src/ui.attachments-drop.test.ts +90 -0
  96. package/src/ui.header-update-stability.test.ts +149 -0
  97. package/src/ui.launcher-update-merge.test.ts +83 -0
  98. package/src/ui.mention-submit.test.ts +235 -0
  99. package/src/ui.send-button-stream-update.test.ts +69 -0
  100. package/src/ui.ts +379 -84
  101. package/src/utils/chunk-loader.test.ts +97 -0
  102. package/src/utils/chunk-loader.ts +88 -0
  103. package/src/utils/composer-contenteditable.test.ts +507 -0
  104. package/src/utils/composer-contenteditable.ts +626 -0
  105. package/src/utils/composer-document.test.ts +280 -0
  106. package/src/utils/composer-document.ts +293 -0
  107. package/src/utils/composer-history.test.ts +35 -7
  108. package/src/utils/composer-history.ts +30 -20
  109. package/src/utils/composer-input.ts +159 -0
  110. package/src/utils/config-merge.test.ts +131 -0
  111. package/src/utils/config-merge.ts +61 -0
  112. package/src/utils/context-mention-controller.test.ts +1215 -0
  113. package/src/utils/context-mention-controller.ts +1186 -0
  114. package/src/utils/context-mention-manager.test.ts +422 -0
  115. package/src/utils/context-mention-manager.ts +410 -0
  116. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  117. package/src/utils/context-mention-orchestrator.ts +348 -0
  118. package/src/utils/live-region.test.ts +108 -0
  119. package/src/utils/live-region.ts +94 -0
  120. package/src/utils/mention-channels.ts +63 -0
  121. package/src/utils/mention-llm-format.test.ts +91 -0
  122. package/src/utils/mention-llm-format.ts +79 -0
  123. package/src/utils/mention-matcher.test.ts +86 -0
  124. package/src/utils/mention-matcher.ts +221 -0
  125. package/src/utils/mention-token.ts +72 -0
  126. package/src/utils/mention-trigger.test.ts +155 -0
  127. package/src/utils/mention-trigger.ts +156 -0
  128. package/src/utils/theme.test.ts +54 -4
  129. package/src/utils/theme.ts +6 -3
  130. package/src/utils/tokens.ts +27 -11
@@ -0,0 +1,134 @@
1
+ import { createElement, createNode } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import type {
4
+ AgentWidgetContextMentionConfig,
5
+ AgentWidgetContextMentionPayload,
6
+ AgentWidgetContextMentionRef,
7
+ } from "../types";
8
+
9
+ export type MentionChipStatus = "resolving" | "ready" | "error";
10
+
11
+ export interface MentionChipParts {
12
+ el: HTMLElement;
13
+ /** Update lifecycle state; `payload` is forwarded to `renderMentionChip` once resolved. */
14
+ setStatus: (
15
+ status: MentionChipStatus,
16
+ payload?: AgentWidgetContextMentionPayload
17
+ ) => void;
18
+ }
19
+
20
+ /**
21
+ * A single compact pill chip (icon + label + ×, ~26px) for the composer context
22
+ * row. Mirrors the attachment chip's remove affordance but is a text pill, not a
23
+ * 48×48 thumbnail. Honors `config.renderMentionChip` when provided.
24
+ */
25
+ export function createMentionChip(opts: {
26
+ ref: AgentWidgetContextMentionRef;
27
+ config: AgentWidgetContextMentionConfig;
28
+ onRemove: () => void;
29
+ }): MentionChipParts {
30
+ const { ref, config, onRemove } = opts;
31
+
32
+ if (config.renderMentionChip) {
33
+ // Mid-level override: the host owns the markup. Status (and the resolved
34
+ // payload, once available) is reflected by re-invoking the renderer.
35
+ let status: MentionChipStatus = "resolving";
36
+ let payload: AgentWidgetContextMentionPayload | undefined;
37
+ let el = config.renderMentionChip({ ref, status, payload, remove: onRemove });
38
+ const setStatus = (
39
+ next: MentionChipStatus,
40
+ nextPayload?: AgentWidgetContextMentionPayload
41
+ ) => {
42
+ if (next === status && nextPayload === payload) return;
43
+ status = next;
44
+ payload = nextPayload;
45
+ const replacement = config.renderMentionChip!({
46
+ ref,
47
+ status,
48
+ payload,
49
+ remove: onRemove,
50
+ });
51
+ el.replaceWith(replacement);
52
+ el = replacement;
53
+ };
54
+ // Getter, not a snapshot: setStatus swaps the node, and the manager's
55
+ // remove()/clear() must target the live element, not the detached original.
56
+ return {
57
+ get el() {
58
+ return el;
59
+ },
60
+ setStatus,
61
+ };
62
+ }
63
+
64
+ const iconName = ref.iconName ?? config.chipIconName ?? "at-sign";
65
+
66
+ const chip = createNode("div", {
67
+ className: "persona-mention-chip",
68
+ attrs: {
69
+ "data-persona-mention-chip": "",
70
+ "data-status": "resolving",
71
+ // Full label on hover, since the pill truncates long labels with ellipsis.
72
+ title: ref.label,
73
+ },
74
+ });
75
+
76
+ const iconHost = createElement("span", "persona-mention-chip-icon");
77
+ const setIcon = (name: string) => {
78
+ iconHost.replaceChildren();
79
+ const svg = renderLucideIcon(name, 13, "currentColor", 2);
80
+ if (svg) iconHost.appendChild(svg);
81
+ };
82
+ setIcon(iconName);
83
+
84
+ // The spinner shown while the mention resolves; swapped for the icon on ready.
85
+ const spinner = createElement("span", "persona-mention-chip-spinner");
86
+
87
+ const label = createNode("span", {
88
+ className: "persona-mention-chip-label",
89
+ text: ref.label,
90
+ });
91
+
92
+ const removeBtn = createNode("button", {
93
+ className: "persona-mention-chip-remove",
94
+ attrs: {
95
+ type: "button",
96
+ "aria-label": `Remove ${ref.label} context`,
97
+ },
98
+ }) as HTMLButtonElement;
99
+ const x = renderLucideIcon("x", 11, "currentColor", 2.5);
100
+ if (x) removeBtn.appendChild(x);
101
+ else removeBtn.textContent = "×";
102
+ removeBtn.addEventListener("click", (e) => {
103
+ e.preventDefault();
104
+ e.stopPropagation();
105
+ onRemove();
106
+ });
107
+
108
+ chip.appendChild(spinner);
109
+ chip.appendChild(label);
110
+ chip.appendChild(removeBtn);
111
+
112
+ const setStatus = (status: MentionChipStatus) => {
113
+ chip.setAttribute("data-status", status);
114
+ if (status === "resolving") {
115
+ if (spinner.parentNode !== chip) chip.insertBefore(spinner, label);
116
+ if (iconHost.parentNode === chip) iconHost.remove();
117
+ chip.setAttribute("title", ref.label);
118
+ } else {
119
+ if (spinner.parentNode === chip) spinner.remove();
120
+ if (iconHost.parentNode !== chip) chip.insertBefore(iconHost, label);
121
+ if (status === "error") {
122
+ // Error is not color-only: swap to an alert icon and explain in the
123
+ // hover title (and the aria-label reflects it via the title too).
124
+ setIcon("triangle-alert");
125
+ chip.setAttribute("title", `Couldn't add ${ref.label} to context`);
126
+ } else {
127
+ setIcon(iconName);
128
+ chip.setAttribute("title", ref.label);
129
+ }
130
+ }
131
+ };
132
+
133
+ return { el: chip, setStatus };
134
+ }
@@ -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
+ });