@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.
Files changed (185) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -2
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -2
  6. package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
  7. package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
  8. package/dist/animations/wipe.cjs +2 -2
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/chunk-5EIIHQLQ.js +1 -0
  12. package/dist/chunk-IO5VVUKP.js +3 -0
  13. package/dist/chunk-IPVK3KOM.js +1 -0
  14. package/dist/chunk-UPO4GUFC.js +1 -0
  15. package/dist/codegen.cjs +13 -13
  16. package/dist/codegen.js +14 -14
  17. package/dist/context-mentions-7S5KVUTG.js +169 -0
  18. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  19. package/dist/context-mentions-inline.cjs +4 -0
  20. package/dist/context-mentions-inline.d.cts +203 -0
  21. package/dist/context-mentions-inline.d.ts +203 -0
  22. package/dist/context-mentions-inline.js +4 -0
  23. package/dist/context-mentions.cjs +295 -0
  24. package/dist/context-mentions.d.cts +7025 -0
  25. package/dist/context-mentions.d.ts +7025 -0
  26. package/dist/context-mentions.js +295 -0
  27. package/dist/index.cjs +99 -68
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1507 -182
  30. package/dist/index.d.ts +1507 -182
  31. package/dist/index.global.js +88 -56
  32. package/dist/index.global.js.map +1 -1
  33. package/dist/index.js +99 -68
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.global.js +1 -1
  36. package/dist/install.global.js.map +1 -1
  37. package/dist/launcher.global.js +3 -2
  38. package/dist/launcher.global.js.map +1 -1
  39. package/dist/markdown-parsers.js +24 -24
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +17 -0
  42. package/dist/plugin-kit.d.ts +17 -0
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  45. package/dist/runtype-tts.js +1 -1
  46. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  47. package/dist/smart-dom-reader.cjs +18 -17
  48. package/dist/smart-dom-reader.d.cts +1260 -16
  49. package/dist/smart-dom-reader.d.ts +1260 -16
  50. package/dist/smart-dom-reader.js +18 -17
  51. package/dist/testing.cjs +3 -3
  52. package/dist/testing.js +3 -3
  53. package/dist/theme-editor-preview.cjs +260 -58
  54. package/dist/theme-editor-preview.d.cts +1234 -15
  55. package/dist/theme-editor-preview.d.ts +1234 -15
  56. package/dist/theme-editor-preview.js +87 -58
  57. package/dist/theme-editor.cjs +10 -10
  58. package/dist/theme-editor.d.cts +1226 -15
  59. package/dist/theme-editor.d.ts +1226 -15
  60. package/dist/theme-editor.js +10 -10
  61. package/dist/theme-reference.cjs +1 -1
  62. package/dist/theme-reference.d.cts +76 -0
  63. package/dist/theme-reference.d.ts +76 -0
  64. package/dist/theme-reference.js +1 -1
  65. package/dist/voice-worklet-player.cjs +2 -2
  66. package/dist/voice-worklet-player.js +2 -2
  67. package/dist/webmcp-polyfill.js +2 -2
  68. package/dist/widget.css +1 -1
  69. package/package.json +16 -5
  70. package/src/artifacts-session.test.ts +178 -0
  71. package/src/client.test.ts +255 -1
  72. package/src/client.ts +157 -60
  73. package/src/components/artifact-card.test.ts +333 -0
  74. package/src/components/artifact-card.ts +75 -28
  75. package/src/components/artifact-inline.test.ts +1328 -0
  76. package/src/components/artifact-inline.ts +920 -0
  77. package/src/components/artifact-pane.test.ts +1042 -0
  78. package/src/components/artifact-pane.ts +440 -131
  79. package/src/components/artifact-preview.test.ts +1155 -0
  80. package/src/components/artifact-preview.ts +994 -0
  81. package/src/components/composer-parts.ts +3 -12
  82. package/src/components/context-mention-button.test.ts +70 -0
  83. package/src/components/context-mention-button.ts +82 -0
  84. package/src/components/context-mention-chip.ts +134 -0
  85. package/src/components/context-mention-menu.test.ts +508 -0
  86. package/src/components/context-mention-menu.ts +0 -0
  87. package/src/components/message-bubble.test.ts +175 -0
  88. package/src/components/message-bubble.ts +177 -19
  89. package/src/components/panel.ts +7 -10
  90. package/src/components/pill-composer-builder.test.ts +6 -2
  91. package/src/components/pill-composer-builder.ts +6 -6
  92. package/src/components/reasoning-bubble.ts +1 -13
  93. package/src/components/registry.ts +38 -3
  94. package/src/components/tool-bubble.ts +1 -13
  95. package/src/context-mentions-bundle.test.ts +163 -0
  96. package/src/context-mentions-entry.ts +185 -0
  97. package/src/context-mentions-inline-entry.test.ts +136 -0
  98. package/src/context-mentions-inline-entry.ts +226 -0
  99. package/src/context-mentions-inline-loader.test.ts +30 -0
  100. package/src/context-mentions-inline-loader.ts +36 -0
  101. package/src/context-mentions-inline.ts +15 -0
  102. package/src/context-mentions-loader.ts +32 -0
  103. package/src/context-mentions.ts +16 -0
  104. package/src/defaults.ts +1 -0
  105. package/src/generated/runtype-openapi-contract.ts +55 -3
  106. package/src/index-core.ts +46 -1
  107. package/src/index-global.ts +51 -0
  108. package/src/index.ts +8 -0
  109. package/src/markdown-parsers-loader.test.ts +158 -0
  110. package/src/markdown-parsers-loader.ts +83 -27
  111. package/src/plugin-kit.test.ts +40 -0
  112. package/src/plugin-kit.ts +39 -5
  113. package/src/runtime/host-layout.test.ts +163 -0
  114. package/src/runtime/host-layout.ts +110 -7
  115. package/src/runtime/init.ts +18 -61
  116. package/src/session.mentions.test.ts +175 -0
  117. package/src/session.test.ts +52 -4
  118. package/src/session.ts +197 -27
  119. package/src/smart-dom-reader.test.ts +129 -2
  120. package/src/smart-dom-reader.ts +127 -1
  121. package/src/styles/context-mention-menu-css.ts +176 -0
  122. package/src/styles/widget.css +1002 -152
  123. package/src/theme-editor/preview.ts +2 -0
  124. package/src/theme-editor/sections.test.ts +26 -1
  125. package/src/theme-editor/sections.ts +13 -5
  126. package/src/theme-reference.ts +2 -2
  127. package/src/tool-call-display-defaults.test.ts +1 -0
  128. package/src/types/theme.ts +79 -0
  129. package/src/types.ts +1041 -17
  130. package/src/ui.artifact-pane-gating.test.ts +636 -0
  131. package/src/ui.component-directive.test.ts +104 -0
  132. package/src/ui.composer-bar.test.ts +60 -2
  133. package/src/ui.detached-panel.test.ts +1049 -0
  134. package/src/ui.mention-submit.test.ts +235 -0
  135. package/src/ui.tool-display.test.ts +51 -0
  136. package/src/ui.ts +998 -159
  137. package/src/utils/artifact-custom-actions.ts +128 -0
  138. package/src/utils/artifact-display.test.ts +42 -0
  139. package/src/utils/artifact-display.ts +84 -0
  140. package/src/utils/artifact-file.test.ts +116 -0
  141. package/src/utils/artifact-file.ts +117 -0
  142. package/src/utils/artifact-gate.test.ts +112 -5
  143. package/src/utils/artifact-gate.ts +39 -14
  144. package/src/utils/artifact-loading-status.ts +55 -0
  145. package/src/utils/artifact-status-label.ts +190 -0
  146. package/src/utils/buttons.ts +7 -1
  147. package/src/utils/chunk-loader.test.ts +97 -0
  148. package/src/utils/chunk-loader.ts +88 -0
  149. package/src/utils/code-highlight.test.ts +186 -0
  150. package/src/utils/code-highlight.ts +400 -0
  151. package/src/utils/composer-contenteditable.test.ts +507 -0
  152. package/src/utils/composer-contenteditable.ts +626 -0
  153. package/src/utils/composer-document.test.ts +280 -0
  154. package/src/utils/composer-document.ts +293 -0
  155. package/src/utils/composer-history.test.ts +35 -7
  156. package/src/utils/composer-history.ts +30 -20
  157. package/src/utils/composer-input.ts +159 -0
  158. package/src/utils/context-mention-controller.test.ts +1215 -0
  159. package/src/utils/context-mention-controller.ts +1186 -0
  160. package/src/utils/context-mention-manager.test.ts +422 -0
  161. package/src/utils/context-mention-manager.ts +410 -0
  162. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  163. package/src/utils/context-mention-orchestrator.ts +348 -0
  164. package/src/utils/icons.ts +2 -0
  165. package/src/utils/live-region.test.ts +108 -0
  166. package/src/utils/live-region.ts +94 -0
  167. package/src/utils/mention-channels.ts +63 -0
  168. package/src/utils/mention-llm-format.test.ts +91 -0
  169. package/src/utils/mention-llm-format.ts +79 -0
  170. package/src/utils/mention-matcher.test.ts +86 -0
  171. package/src/utils/mention-matcher.ts +221 -0
  172. package/src/utils/mention-token.ts +72 -0
  173. package/src/utils/mention-trigger.test.ts +155 -0
  174. package/src/utils/mention-trigger.ts +156 -0
  175. package/src/utils/roving-tablist.test.ts +152 -0
  176. package/src/utils/roving-tablist.ts +111 -0
  177. package/src/utils/spinner.ts +45 -0
  178. package/src/utils/theme.test.ts +76 -4
  179. package/src/utils/theme.ts +7 -0
  180. package/src/utils/tokens.ts +118 -11
  181. package/src/utils/tool-loading-animation.test.ts +32 -0
  182. package/src/utils/tool-loading-animation.ts +24 -0
  183. package/dist/chunk-DFBSCFYN.js +0 -1
  184. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  185. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -0,0 +1,1215 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
+ import { ContextMentionController } from "./context-mention-controller";
5
+ import { createTextareaComposerInput } from "./composer-input";
6
+ import { createContentEditableComposerInput } from "./composer-contenteditable";
7
+ import { createSlashCommandsSource } from "./mention-matcher";
8
+ import type {
9
+ AgentWidgetConfig,
10
+ AgentWidgetContextMentionConfig,
11
+ AgentWidgetContextMentionItem,
12
+ AgentWidgetContextMentionRef,
13
+ AgentWidgetContextMentionSource,
14
+ } from "../types";
15
+
16
+ const tick = () => new Promise((r) => setTimeout(r, 0));
17
+ const item = (id: string, label = id): AgentWidgetContextMentionItem => ({ id, label });
18
+
19
+ function setup(
20
+ sources: AgentWidgetContextMentionSource[],
21
+ cfg: Partial<AgentWidgetContextMentionConfig> = {}
22
+ ) {
23
+ const form = document.createElement("form");
24
+ const textarea = document.createElement("textarea");
25
+ form.appendChild(textarea);
26
+ document.body.appendChild(form);
27
+ // The textarea adapter derives submit from `textarea.form.requestSubmit`;
28
+ // spy on it so prompt-macro `submitOnSelect` is observable.
29
+ const submit = vi.fn();
30
+ form.requestSubmit = submit;
31
+
32
+ const onSelect = vi.fn(() => true);
33
+ const announce = vi.fn();
34
+ const controller = new ContextMentionController({
35
+ mentionConfig: { enabled: true, sources, ...cfg },
36
+ composerInput: createTextareaComposerInput(textarea),
37
+ anchor: form,
38
+ getMessages: () => [],
39
+ getConfig: () => ({}) as AgentWidgetConfig,
40
+ onSelect,
41
+ announce,
42
+ });
43
+ return { controller, textarea, onSelect, form, announce, submit };
44
+ }
45
+
46
+ /** An async source whose `search` promise resolves only when `release` is called. */
47
+ function makeDeferredSource(id: string): {
48
+ source: AgentWidgetContextMentionSource;
49
+ release: (items: AgentWidgetContextMentionItem[]) => void;
50
+ } {
51
+ let release!: (items: AgentWidgetContextMentionItem[]) => void;
52
+ const source: AgentWidgetContextMentionSource = {
53
+ id,
54
+ label: id,
55
+ search: () => new Promise((r) => (release = r)),
56
+ resolve: async () => ({ llmAppend: "x" }),
57
+ };
58
+ return { source, release: (items) => release(items) };
59
+ }
60
+
61
+ const syncSource = (
62
+ id: string,
63
+ items: AgentWidgetContextMentionItem[]
64
+ ): AgentWidgetContextMentionSource => ({
65
+ id,
66
+ label: id,
67
+ search: (q) => items.filter((i) => i.label.toLowerCase().includes(q.toLowerCase())),
68
+ resolve: async () => ({ llmAppend: "x" }),
69
+ });
70
+
71
+ describe("ContextMentionController", () => {
72
+ beforeEach(() => {
73
+ document.body.innerHTML = "";
74
+ });
75
+
76
+ it("opens instantly on a typed trigger and lists sync results", () => {
77
+ const { controller, textarea } = setup([
78
+ syncSource("files", [item("App.tsx"), item("index.ts")]),
79
+ ]);
80
+ textarea.value = "@App";
81
+ textarea.setSelectionRange(4, 4);
82
+ controller.onInput();
83
+ expect(controller.isOpen()).toBe(true);
84
+ const menu = document.querySelector("[data-persona-mention-menu]")!;
85
+ const options = menu.querySelectorAll(".persona-mention-option");
86
+ expect(options).toHaveLength(1);
87
+ expect(options[0].textContent).toContain("App.tsx");
88
+ });
89
+
90
+ it("navigates with arrows and selects with Enter, stripping the @query", () => {
91
+ const { controller, textarea, onSelect } = setup([
92
+ syncSource("files", [item("App.tsx"), item("api.ts")]),
93
+ ]);
94
+ textarea.value = "check @a";
95
+ textarea.setSelectionRange(8, 8);
96
+ controller.onInput();
97
+
98
+ // Two matches ("App.tsx" via subsequence, "api.ts"); move to the 2nd.
99
+ const down = new KeyboardEvent("keydown", { key: "ArrowDown" });
100
+ expect(controller.handleKeydown(down)).toBe(true);
101
+
102
+ const enter = new KeyboardEvent("keydown", { key: "Enter" });
103
+ expect(controller.handleKeydown(enter)).toBe(true);
104
+ expect(onSelect).toHaveBeenCalledTimes(1);
105
+
106
+ // The "@a" span is stripped from the textarea, leaving clean prose.
107
+ expect(textarea.value).toBe("check ");
108
+ expect(controller.isOpen()).toBe(false);
109
+ });
110
+
111
+ it("Escape closes the menu and keeps the literal trigger", () => {
112
+ const { controller, textarea } = setup([syncSource("files", [item("App.tsx")])]);
113
+ textarea.value = "@";
114
+ textarea.setSelectionRange(1, 1);
115
+ controller.onInput();
116
+ expect(controller.isOpen()).toBe(true);
117
+ const esc = new KeyboardEvent("keydown", { key: "Escape" });
118
+ expect(controller.handleKeydown(esc)).toBe(true);
119
+ expect(controller.isOpen()).toBe(false);
120
+ expect(textarea.value).toBe("@"); // literal kept
121
+ });
122
+
123
+ it("closes when the query is no longer a valid trigger", () => {
124
+ const { controller, textarea } = setup([syncSource("files", [item("App.tsx")])]);
125
+ textarea.value = "@App";
126
+ textarea.setSelectionRange(4, 4);
127
+ controller.onInput();
128
+ expect(controller.isOpen()).toBe(true);
129
+ // User types a space → mention ends.
130
+ textarea.value = "@App ";
131
+ textarea.setSelectionRange(5, 5);
132
+ controller.onInput();
133
+ expect(controller.isOpen()).toBe(false);
134
+ });
135
+
136
+ it("caps items per group and flags truncation", () => {
137
+ const many = Array.from({ length: 10 }, (_, i) => item(`f${i}`, `file${i}`));
138
+ const { controller, textarea } = setup([syncSource("files", many)], {
139
+ maxItemsPerGroup: 3,
140
+ });
141
+ textarea.value = "@file";
142
+ textarea.setSelectionRange(5, 5);
143
+ controller.onInput();
144
+ const options = document.querySelectorAll(".persona-mention-option");
145
+ expect(options).toHaveLength(3);
146
+ expect(document.querySelector(".persona-mention-hint")).not.toBeNull();
147
+ });
148
+
149
+ it("opens from the button as a picker: no trigger char, search field focused, all items listed", () => {
150
+ const { controller, textarea } = setup([
151
+ syncSource("files", [item("App.tsx"), item("index.ts")]),
152
+ ]);
153
+ controller.openFromButton();
154
+
155
+ expect(controller.isOpen()).toBe(true);
156
+ // Crucially: nothing is inserted into the composer.
157
+ expect(textarea.value).toBe("");
158
+
159
+ const input = document.querySelector<HTMLInputElement>(
160
+ ".persona-mention-search-input"
161
+ )!;
162
+ const wrap = document.querySelector<HTMLElement>(".persona-mention-search")!;
163
+ expect(wrap.style.display).not.toBe("none");
164
+ expect(document.activeElement).toBe(input);
165
+ // Empty query → browse all.
166
+ expect(document.querySelectorAll(".persona-mention-option")).toHaveLength(2);
167
+ });
168
+
169
+ it("openFromButton adopts a live typed trigger so selection strips the @query", () => {
170
+ const { controller, textarea, onSelect } = setup([
171
+ syncSource("files", [item("App.tsx")]),
172
+ ]);
173
+ // User typed "@ap" then clicked the affordance button.
174
+ textarea.value = "hey @ap";
175
+ textarea.setSelectionRange(7, 7);
176
+ controller.openFromButton();
177
+
178
+ // Adopted as a typed trigger (not picker mode): no search field, and the
179
+ // menu is already filtered by "ap".
180
+ expect(controller.isOpen()).toBe(true);
181
+ const wrap = document.querySelector<HTMLElement>(".persona-mention-search")!;
182
+ expect(wrap.style.display).toBe("none");
183
+ expect(document.querySelectorAll(".persona-mention-option")).toHaveLength(1);
184
+
185
+ controller.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
186
+ expect(onSelect).toHaveBeenCalledTimes(1);
187
+ // The "@ap" span is stripped — not left stranded in the composer.
188
+ expect(textarea.value).toBe("hey ");
189
+ });
190
+
191
+ it("mirrors aria-activedescendant onto the textarea and toggles aria-selected", () => {
192
+ const { controller, textarea } = setup([
193
+ syncSource("files", [item("App.tsx"), item("api.ts")]),
194
+ ]);
195
+ textarea.value = "@a";
196
+ textarea.setSelectionRange(2, 2);
197
+ controller.onInput();
198
+
199
+ const options = document.querySelectorAll<HTMLElement>(".persona-mention-option");
200
+ expect(options).toHaveLength(2);
201
+ // First option active: aria-selected on it, aria-activedescendant on textarea.
202
+ expect(options[0].getAttribute("aria-selected")).toBe("true");
203
+ expect(options[1].getAttribute("aria-selected")).toBe("false");
204
+ expect(textarea.getAttribute("aria-activedescendant")).toBe(options[0].id);
205
+
206
+ controller.handleKeydown(new KeyboardEvent("keydown", { key: "ArrowDown" }));
207
+ expect(options[1].getAttribute("aria-selected")).toBe("true");
208
+ expect(options[0].getAttribute("aria-selected")).toBe("false");
209
+ expect(textarea.getAttribute("aria-activedescendant")).toBe(options[1].id);
210
+
211
+ controller.close();
212
+ expect(textarea.hasAttribute("aria-activedescendant")).toBe(false);
213
+ });
214
+
215
+ it("shows a Retry on a failed source and re-runs it on click", () => {
216
+ let calls = 0;
217
+ const flaky: AgentWidgetContextMentionSource = {
218
+ id: "flaky",
219
+ label: "Flaky",
220
+ search: () => {
221
+ calls++;
222
+ if (calls === 1) throw new Error("boom");
223
+ return [item("ok")];
224
+ },
225
+ resolve: async () => ({ llmAppend: "x" }),
226
+ };
227
+ const { controller, textarea } = setup([flaky]);
228
+ textarea.value = "@o";
229
+ textarea.setSelectionRange(2, 2);
230
+ controller.onInput();
231
+
232
+ // First search threw → error group with a Retry button, no options.
233
+ const retry = document.querySelector<HTMLButtonElement>(".persona-mention-retry");
234
+ expect(retry).not.toBeNull();
235
+ expect(document.querySelectorAll(".persona-mention-option")).toHaveLength(0);
236
+
237
+ retry!.click();
238
+ // Second search succeeded → the option renders.
239
+ expect(calls).toBe(2);
240
+ expect(document.querySelectorAll(".persona-mention-option")).toHaveLength(1);
241
+ });
242
+
243
+ it("keeps the highlight on the same item when async results reorder the list", async () => {
244
+ const { source: cmds, release } = makeDeferredSource("cmds");
245
+ // cmds declared FIRST so its results sort ahead of files when they arrive.
246
+ const { controller, textarea } = setup(
247
+ [cmds, syncSource("files", [item("App.tsx"), item("azely")])],
248
+ { searchDebounceMs: 10_000 } // keep the debounce from re-invoking mid-test
249
+ );
250
+ textarea.value = "@a";
251
+ textarea.setSelectionRange(2, 2);
252
+ controller.onInput();
253
+
254
+ // Sync files rendered [App.tsx, azely]; move the highlight to azely.
255
+ controller.handleKeydown(new KeyboardEvent("keydown", { key: "ArrowDown" }));
256
+ expect(
257
+ document.querySelector(".persona-mention-option[data-active]")?.textContent
258
+ ).toContain("azely");
259
+
260
+ // Async commands arrive and sort above files, pushing azely down a slot.
261
+ release([item("alpha")]);
262
+ await tick();
263
+
264
+ // The highlight followed azely rather than staying on a stale index.
265
+ expect(
266
+ document.querySelector(".persona-mention-option[data-active]")?.textContent
267
+ ).toContain("azely");
268
+ });
269
+
270
+ it("close() drops in-flight async results (no late render or announce)", async () => {
271
+ const { source: remote, release } = makeDeferredSource("remote");
272
+ const { controller, textarea, announce } = setup([remote]);
273
+ textarea.value = "@x";
274
+ textarea.setSelectionRange(2, 2);
275
+ controller.onInput();
276
+ expect(controller.isOpen()).toBe(true);
277
+ announce.mockClear();
278
+
279
+ controller.close();
280
+ release([item("late")]);
281
+ await tick();
282
+
283
+ // The stale search result must not render into or announce for a closed menu.
284
+ expect(controller.isOpen()).toBe(false);
285
+ expect(document.querySelectorAll(".persona-mention-option")).toHaveLength(0);
286
+ expect(announce).not.toHaveBeenCalled();
287
+ });
288
+
289
+ it("filters from the picker search field and selects without touching the textarea", () => {
290
+ const { controller, textarea, onSelect } = setup([
291
+ syncSource("files", [item("App.tsx"), item("index.ts")]),
292
+ ]);
293
+ controller.openFromButton();
294
+
295
+ const input = document.querySelector<HTMLInputElement>(
296
+ ".persona-mention-search-input"
297
+ )!;
298
+ input.value = "app";
299
+ input.dispatchEvent(new Event("input", { bubbles: true }));
300
+ const options = document.querySelectorAll(".persona-mention-option");
301
+ expect(options).toHaveLength(1);
302
+ expect(options[0].textContent).toContain("App.tsx");
303
+
304
+ // Enter from the search field selects the active row.
305
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
306
+ expect(onSelect).toHaveBeenCalledTimes(1);
307
+ // The picker never wrote to the composer, so there is nothing to strip.
308
+ expect(textarea.value).toBe("");
309
+ expect(controller.isOpen()).toBe(false);
310
+ });
311
+
312
+ it("dismissing the picker with Escape leaves the composer untouched (no stray trigger)", () => {
313
+ const { controller, textarea } = setup([syncSource("files", [item("App.tsx")])]);
314
+ controller.openFromButton();
315
+ expect(controller.isOpen()).toBe(true);
316
+
317
+ const input = document.querySelector<HTMLInputElement>(
318
+ ".persona-mention-search-input"
319
+ )!;
320
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
321
+ expect(controller.isOpen()).toBe(false);
322
+ expect(textarea.value).toBe("");
323
+ // Picker torn down: focus returns to the composer so the user keeps typing.
324
+ expect(document.activeElement).toBe(textarea);
325
+ });
326
+
327
+ it("debounces async sources and aborts the prior search on the next keystroke", async () => {
328
+ const calls: { query: string; aborted: () => boolean }[] = [];
329
+ const asyncSource: AgentWidgetContextMentionSource = {
330
+ id: "remote",
331
+ label: "Remote",
332
+ search: (q, ctx) => {
333
+ calls.push({ query: q, aborted: () => ctx.signal.aborted });
334
+ return new Promise((resolve) =>
335
+ setTimeout(() => resolve([item(q, `r-${q}`)]), 5)
336
+ );
337
+ },
338
+ resolve: async () => ({ llmAppend: "x" }),
339
+ };
340
+ const { controller, textarea } = setup([asyncSource], { searchDebounceMs: 10 });
341
+
342
+ textarea.value = "@a";
343
+ textarea.setSelectionRange(2, 2);
344
+ controller.onInput(); // first invocation classifies it async + fires once
345
+ textarea.value = "@ab";
346
+ textarea.setSelectionRange(3, 3);
347
+ controller.onInput(); // next keystroke aborts the prior controller
348
+
349
+ await tick();
350
+ // The first in-flight search was aborted by the second keystroke.
351
+ expect(calls[0].aborted()).toBe(true);
352
+ });
353
+
354
+ it("a first-time async source fires exactly one request per token (no debounce double-fetch)", async () => {
355
+ vi.useFakeTimers();
356
+ try {
357
+ let calls = 0;
358
+ const asyncSource: AgentWidgetContextMentionSource = {
359
+ id: "remote",
360
+ label: "Remote",
361
+ search: (q) => {
362
+ calls++;
363
+ return Promise.resolve([item(q, `r-${q}`)]);
364
+ },
365
+ resolve: async () => ({ llmAppend: "x" }),
366
+ };
367
+ const { controller, textarea } = setup([asyncSource], { searchDebounceMs: 50 });
368
+
369
+ textarea.value = "@a";
370
+ textarea.setSelectionRange(2, 2);
371
+ controller.onInput(); // immediate pass invokes the first-time source once
372
+ expect(calls).toBe(1);
373
+
374
+ // Debounce fires for the SAME token: the immediate pass already invoked the
375
+ // now-known-async source, so it must not fire a second identical request.
376
+ await vi.advanceTimersByTimeAsync(60);
377
+ expect(calls).toBe(1);
378
+
379
+ // A genuinely changed query re-invokes on the debounced pass.
380
+ textarea.value = "@ab";
381
+ textarea.setSelectionRange(3, 3);
382
+ controller.onInput(); // known-async: immediate shows loading, no invoke yet
383
+ expect(calls).toBe(1);
384
+ await vi.advanceTimersByTimeAsync(60);
385
+ expect(calls).toBe(2);
386
+ } finally {
387
+ vi.useRealTimers();
388
+ }
389
+ });
390
+
391
+ it("switching pickers emits (false, oldTrigger) before (true, newTrigger)", () => {
392
+ const form = document.createElement("form");
393
+ const textarea = document.createElement("textarea");
394
+ form.appendChild(textarea);
395
+ document.body.appendChild(form);
396
+ const events: Array<[boolean, string]> = [];
397
+ const controller = new ContextMentionController({
398
+ mentionConfig: {
399
+ enabled: true,
400
+ sources: [syncSource("files", [item("App.tsx")])],
401
+ triggers: [
402
+ {
403
+ trigger: "/",
404
+ triggerPosition: "line-start",
405
+ sources: [
406
+ createSlashCommandsSource({
407
+ id: "cmd",
408
+ label: "Commands",
409
+ commands: [{ name: "clear", kind: "action", action: vi.fn() }],
410
+ }),
411
+ ],
412
+ },
413
+ ],
414
+ },
415
+ composerInput: createTextareaComposerInput(textarea),
416
+ anchor: form,
417
+ getMessages: () => [],
418
+ getConfig: () => ({}) as AgentWidgetConfig,
419
+ onSelect: vi.fn(() => true),
420
+ announce: vi.fn(),
421
+ onPickerOpenChange: (open, trigger) => events.push([open, trigger]),
422
+ });
423
+
424
+ controller.openFromButton("@");
425
+ controller.openFromButton("/");
426
+
427
+ // Channel B's picker must close A's button first; otherwise A stays expanded.
428
+ expect(events).toEqual([
429
+ [true, "@"],
430
+ [false, "@"],
431
+ [true, "/"],
432
+ ]);
433
+ });
434
+ });
435
+
436
+ describe("ContextMentionController — slash-commands", () => {
437
+ beforeEach(() => {
438
+ document.body.innerHTML = "";
439
+ });
440
+
441
+ /** A `/` channel driven by createSlashCommandsSource, at line-start. */
442
+ function slashSetup(
443
+ commands: Parameters<typeof createSlashCommandsSource>[0]["commands"]
444
+ ) {
445
+ return setup([], {
446
+ triggers: [
447
+ {
448
+ trigger: "/",
449
+ triggerPosition: "line-start",
450
+ allowSpaces: true,
451
+ sources: [
452
+ createSlashCommandsSource({ id: "cmd", label: "Commands", commands }),
453
+ ],
454
+ },
455
+ ],
456
+ });
457
+ }
458
+
459
+ const pressEnter = (controller: ContextMentionController) =>
460
+ controller.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
461
+
462
+ it("opens the / channel only at line-start", () => {
463
+ const { controller, textarea } = slashSetup([
464
+ { name: "summarize", kind: "prompt", prompt: "Please summarize." },
465
+ ]);
466
+ // Mid-line `/` must NOT open.
467
+ textarea.value = "hi /sum";
468
+ textarea.setSelectionRange(7, 7);
469
+ controller.onInput();
470
+ expect(controller.isOpen()).toBe(false);
471
+ // Line-start `/` opens and lists the command.
472
+ textarea.value = "/sum";
473
+ textarea.setSelectionRange(4, 4);
474
+ controller.onInput();
475
+ expect(controller.isOpen()).toBe(true);
476
+ const options = document.querySelectorAll(".persona-mention-option");
477
+ expect(options).toHaveLength(1);
478
+ expect(options[0].textContent).toContain("summarize");
479
+ });
480
+
481
+ it("prompt macro: writes resolved text into the composer and submits", async () => {
482
+ const { controller, textarea, submit, onSelect } = slashSetup([
483
+ {
484
+ name: "summarize",
485
+ kind: "prompt",
486
+ prompt: "Please summarize the conversation.",
487
+ submitOnSelect: true,
488
+ },
489
+ ]);
490
+ textarea.value = "/summarize";
491
+ textarea.setSelectionRange(10, 10);
492
+ controller.onInput();
493
+ pressEnter(controller);
494
+ await tick(); // runPromptMacro awaits resolve()
495
+
496
+ // The resolved text is written straight into the composer surface.
497
+ expect(textarea.value).toBe("Please summarize the conversation.");
498
+ expect(submit).toHaveBeenCalledTimes(1);
499
+ // No chip path for prompt macros.
500
+ expect(onSelect).not.toHaveBeenCalled();
501
+ });
502
+
503
+ it("client action: runs with parsed args, adds no chip, clears the token", () => {
504
+ const action = vi.fn();
505
+ const { controller, textarea, onSelect } = slashSetup([
506
+ { name: "deploy", kind: "action", action },
507
+ ]);
508
+ textarea.value = "/deploy staging";
509
+ textarea.setSelectionRange(15, 15);
510
+ controller.onInput();
511
+ pressEnter(controller);
512
+
513
+ expect(action).toHaveBeenCalledTimes(1);
514
+ expect(action).toHaveBeenCalledWith(
515
+ expect.objectContaining({ args: "staging" })
516
+ );
517
+ // Action short-circuits: no manager chip, token stripped from the composer.
518
+ expect(onSelect).not.toHaveBeenCalled();
519
+ expect(textarea.value).toBe("");
520
+ });
521
+
522
+ it("server command: selecting inline-completes `/name ` with no chip", () => {
523
+ const { controller, textarea, onSelect } = slashSetup([
524
+ { name: "lookup", kind: "server", data: (args) => ({ query: args }) },
525
+ ]);
526
+ textarea.value = "/lookup";
527
+ textarea.setSelectionRange(7, 7);
528
+ controller.onInput();
529
+ expect(controller.isOpen()).toBe(true);
530
+ pressEnter(controller);
531
+
532
+ // No chip path (Slack-style inline completion): the composer holds editable
533
+ // `/lookup ` text for the argument, the menu closes, and the manager (chip)
534
+ // is never touched.
535
+ expect(onSelect).not.toHaveBeenCalled();
536
+ expect(textarea.value).toBe("/lookup ");
537
+ expect(controller.isOpen()).toBe(false);
538
+ });
539
+
540
+ it("server command: typing past the name closes the menu (arg entry)", () => {
541
+ const { controller, textarea } = slashSetup([
542
+ { name: "lookup", kind: "server", data: (args) => ({ query: args }) },
543
+ ]);
544
+ // Name only → menu open (still choosing the command).
545
+ textarea.value = "/lookup";
546
+ textarea.setSelectionRange(7, 7);
547
+ controller.onInput();
548
+ expect(controller.isOpen()).toBe(true);
549
+ // A space (into the argument) → menu closes; there's nothing left to pick.
550
+ textarea.value = "/lookup 1042";
551
+ textarea.setSelectionRange(12, 12);
552
+ controller.onInput();
553
+ expect(controller.isOpen()).toBe(false);
554
+ });
555
+
556
+ it("server command: dispatchInlineCommand resolves context from typed args", async () => {
557
+ const { controller } = slashSetup([
558
+ { name: "lookup", kind: "server", data: (args) => ({ query: args }) },
559
+ ]);
560
+ const result = await controller.dispatchInlineCommand("/lookup order 42");
561
+ expect(result?.kind).toBe("server");
562
+ if (result?.kind !== "server") throw new Error("expected server");
563
+ expect(result.mentions.refs).toEqual([]);
564
+ const bundle = await result.mentions.finalize();
565
+ // Namespaced `{ [sourceId]: { [commandName]: context } }`.
566
+ expect(bundle.context).toEqual({ cmd: { lookup: { query: "order 42" } } });
567
+ });
568
+
569
+ it("arg-bearing prompt command dispatches at submit with the typed args", async () => {
570
+ const { controller } = slashSetup([
571
+ {
572
+ name: "greet",
573
+ kind: "prompt",
574
+ prompt: (args) => `Write a greeting for ${args}.`,
575
+ argsPlaceholder: "name",
576
+ },
577
+ ]);
578
+ const result = await controller.dispatchInlineCommand("/greet Ada");
579
+ expect(result).toEqual({ kind: "prompt", sendText: "Write a greeting for Ada." });
580
+ });
581
+
582
+ it("arg-bearing action command runs its handler at submit (nothing sent)", async () => {
583
+ const action = vi.fn();
584
+ const { controller } = slashSetup([
585
+ { name: "deploy", kind: "action", action, argsPlaceholder: "environment" },
586
+ ]);
587
+ const result = await controller.dispatchInlineCommand("/deploy staging");
588
+ expect(result).toEqual({ kind: "action" });
589
+ expect(action).toHaveBeenCalledWith(expect.objectContaining({ args: "staging" }));
590
+ });
591
+
592
+ it("dispatches a line-start command that leads line 2, not just line 1", async () => {
593
+ const action = vi.fn();
594
+ const { controller } = slashSetup([
595
+ { name: "deploy", kind: "action", action, argsPlaceholder: "environment" },
596
+ ]);
597
+ // The live parser arms a line-start command at any line start; submit-time
598
+ // parsing must agree, so a command leading line 2 still dispatches.
599
+ const result = await controller.dispatchInlineCommand("hello\n/deploy staging");
600
+ expect(result).toEqual({ kind: "action" });
601
+ expect(action).toHaveBeenCalledWith(expect.objectContaining({ args: "staging" }));
602
+ });
603
+
604
+ it("dispatchInlineCommand ignores plain text and non-inline commands", async () => {
605
+ const { controller } = slashSetup([
606
+ // A zero-arg action (no placeholder) is select-time only, not inline.
607
+ { name: "clear", kind: "action", action: vi.fn() },
608
+ ]);
609
+ expect(await controller.dispatchInlineCommand("hello world")).toBeNull();
610
+ expect(await controller.dispatchInlineCommand("/clear now")).toBeNull();
611
+ });
612
+
613
+ it("keeps @ mentions working alongside the / channel", () => {
614
+ const { controller, textarea } = setup(
615
+ [
616
+ {
617
+ id: "files",
618
+ label: "Files",
619
+ search: () => [item("App.tsx")],
620
+ resolve: async () => ({ llmAppend: "x" }),
621
+ },
622
+ ],
623
+ {
624
+ triggers: [
625
+ {
626
+ trigger: "/",
627
+ triggerPosition: "line-start",
628
+ sources: [
629
+ createSlashCommandsSource({
630
+ id: "cmd",
631
+ label: "Commands",
632
+ commands: [{ name: "clear", kind: "action", action: vi.fn() }],
633
+ }),
634
+ ],
635
+ },
636
+ ],
637
+ }
638
+ );
639
+ // `@` opens the mentions (files) channel.
640
+ textarea.value = "@App";
641
+ textarea.setSelectionRange(4, 4);
642
+ controller.onInput();
643
+ expect(
644
+ document.querySelector(".persona-mention-option")?.textContent
645
+ ).toContain("App.tsx");
646
+ controller.close();
647
+ // `/` at line-start opens the commands channel instead.
648
+ textarea.value = "/clear";
649
+ textarea.setSelectionRange(6, 6);
650
+ controller.onInput();
651
+ expect(
652
+ document.querySelector(".persona-mention-option")?.textContent
653
+ ).toContain("clear");
654
+ });
655
+ });
656
+
657
+ describe("ContextMentionController — inline coordinate space", () => {
658
+ beforeEach(() => {
659
+ document.body.innerHTML = "";
660
+ });
661
+
662
+ const appRef: AgentWidgetContextMentionRef = {
663
+ sourceId: "files",
664
+ itemId: "App.tsx",
665
+ label: "App.tsx",
666
+ };
667
+
668
+ function renderToken(ref: AgentWidgetContextMentionRef): HTMLElement {
669
+ const el = document.createElement("span");
670
+ el.className = "persona-mention-token";
671
+ const label = document.createElement("span");
672
+ label.className = "persona-mention-token-label";
673
+ label.textContent = `@${ref.label}`;
674
+ el.appendChild(label);
675
+ return el;
676
+ }
677
+
678
+ function inlineSetup(
679
+ sources: AgentWidgetContextMentionSource[],
680
+ cfg: Partial<AgentWidgetContextMentionConfig> = {}
681
+ ) {
682
+ const form = document.createElement("form");
683
+ document.body.appendChild(form);
684
+ let idSeq = 0;
685
+ const input = createContentEditableComposerInput({
686
+ generateId: () => `mid-${++idSeq}`,
687
+ renderToken,
688
+ });
689
+ form.appendChild(input.element);
690
+ const onInsertMention = vi.fn();
691
+ const onMentionRejected = vi.fn();
692
+ const controller = new ContextMentionController({
693
+ mentionConfig: {
694
+ enabled: true,
695
+ display: "inline",
696
+ sources,
697
+ onMentionRejected,
698
+ ...cfg,
699
+ },
700
+ composerInput: input,
701
+ anchor: form,
702
+ getMessages: () => [],
703
+ getConfig: () => ({}) as AgentWidgetConfig,
704
+ onSelect: vi.fn(() => true),
705
+ onInsertMention,
706
+ admitMention: () => true,
707
+ announce: vi.fn(),
708
+ });
709
+ return { controller, input, onInsertMention, onMentionRejected };
710
+ }
711
+
712
+ const slashCfg = (
713
+ commands: Parameters<typeof createSlashCommandsSource>[0]["commands"]
714
+ ): Partial<AgentWidgetContextMentionConfig> => ({
715
+ triggers: [
716
+ {
717
+ trigger: "/",
718
+ triggerPosition: "line-start",
719
+ allowSpaces: true,
720
+ sources: [createSlashCommandsSource({ id: "cmd", label: "Commands", commands })],
721
+ },
722
+ ],
723
+ });
724
+
725
+ const tokenCount = (input: { element: HTMLElement }) =>
726
+ input.element.querySelectorAll(".persona-mention-token").length;
727
+
728
+ /** Seed a leading mention token via the product insertion path, followed by
729
+ * `after` text. Places a real DOM caret at the start first (jsdom won't focus
730
+ * a contenteditable) so the token splices at logical 0. */
731
+ function seedLeadingToken(
732
+ input: ReturnType<typeof inlineSetup>["input"],
733
+ after: string,
734
+ ref = appRef
735
+ ): void {
736
+ input.setValueWithCaret(after, 0);
737
+ const sel = window.getSelection()!;
738
+ const range = document.createRange();
739
+ range.selectNodeContents(input.element);
740
+ range.collapse(true);
741
+ sel.removeAllRanges();
742
+ sel.addRange(range);
743
+ input.insertMentionAtSelection!(ref);
744
+ // Leave the caret at the end so the controller parses the trailing trigger.
745
+ const endRange = document.createRange();
746
+ endRange.selectNodeContents(input.element);
747
+ endRange.collapse(false);
748
+ sel.removeAllRanges();
749
+ sel.addRange(endRange);
750
+ }
751
+
752
+ it("slash-command completion preserves an earlier mention token (logical splice)", () => {
753
+ const { controller, input } = inlineSetup(
754
+ [],
755
+ slashCfg([{ name: "lookup", kind: "server", data: (a: string) => ({ q: a }) }])
756
+ );
757
+ // A token on line 1, "/look" being typed on line 2. In DISPLAY text the token
758
+ // is "@App.tsx" (8 chars); in LOGICAL text it is one `` — the pre-fix code
759
+ // sliced DISPLAY text with LOGICAL indices and destroyed the token.
760
+ seedLeadingToken(input, "\n/look");
761
+ controller.onInput();
762
+ expect(controller.isOpen()).toBe(true);
763
+ controller.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
764
+
765
+ expect(tokenCount(input)).toBe(1); // token survived
766
+ expect(input.getValue()).toBe("@App.tsx\n/lookup ");
767
+ expect(controller.isOpen()).toBe(false);
768
+ });
769
+
770
+ it("action-command strip preserves an earlier mention token (logical splice)", () => {
771
+ const action = vi.fn();
772
+ const { controller, input } = inlineSetup(
773
+ [],
774
+ slashCfg([{ name: "deploy", kind: "action", action }])
775
+ );
776
+ seedLeadingToken(input, "\n/deploy");
777
+ controller.onInput();
778
+ expect(controller.isOpen()).toBe(true);
779
+ controller.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
780
+
781
+ expect(action).toHaveBeenCalledTimes(1);
782
+ expect(tokenCount(input)).toBe(1); // token survived the /deploy strip
783
+ expect(input.getValue()).toBe("@App.tsx\n");
784
+ });
785
+
786
+ it("fires the rejection path when an inline insert is stale (no silent no-op)", () => {
787
+ const { controller, input, onInsertMention, onMentionRejected } = inlineSetup([
788
+ syncSource("files", [item("App.tsx")]),
789
+ ]);
790
+ input.setValueWithCaret("@App", 4);
791
+ controller.onInput();
792
+ expect(controller.isOpen()).toBe(true);
793
+ // Staleness: the composer text changes between parse and commit, so the
794
+ // adapter's guard refuses the insert (returns null).
795
+ (input.element.firstChild as Text).data = "@Xyz";
796
+ controller.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
797
+
798
+ expect(onInsertMention).not.toHaveBeenCalled();
799
+ expect(tokenCount(input)).toBe(0);
800
+ expect(onMentionRejected).toHaveBeenCalledWith(
801
+ expect.objectContaining({ id: "App.tsx" }),
802
+ "stale"
803
+ );
804
+ });
805
+ });
806
+
807
+ describe("ContextMentionController — trigger-anchored menu positioning", () => {
808
+ beforeEach(() => {
809
+ document.body.innerHTML = "";
810
+ });
811
+
812
+ const domRect = (r: Partial<DOMRect>): DOMRect =>
813
+ ({
814
+ left: 0,
815
+ right: 0,
816
+ top: 0,
817
+ bottom: 0,
818
+ width: 0,
819
+ height: 0,
820
+ x: 0,
821
+ y: 0,
822
+ toJSON: () => ({}),
823
+ ...r,
824
+ }) as DOMRect;
825
+
826
+ // Composer occupies x:[100, 460] (width 360) in viewport coords.
827
+ const ANCHOR_RECT = domRect({
828
+ left: 100,
829
+ right: 460,
830
+ width: 360,
831
+ top: 200,
832
+ bottom: 240,
833
+ });
834
+ /** Trigger on line 1 — same top as the composer box. */
835
+ const TRIGGER_LINE1 = (left: number) =>
836
+ domRect({ left, top: ANCHOR_RECT.top, width: 8, height: 20 });
837
+
838
+ function renderToken(ref: AgentWidgetContextMentionRef): HTMLElement {
839
+ const el = document.createElement("span");
840
+ el.className = "persona-mention-token";
841
+ el.textContent = `@${ref.label}`;
842
+ return el;
843
+ }
844
+
845
+ function posSetup(
846
+ triggerRect: DOMRect | null,
847
+ opts: { rtl?: boolean; textarea?: boolean } = {}
848
+ ) {
849
+ const form = document.createElement("form");
850
+ document.body.appendChild(form);
851
+ form.getBoundingClientRect = () => ANCHOR_RECT;
852
+
853
+ let input: ReturnType<typeof createTextareaComposerInput>;
854
+ if (opts.textarea) {
855
+ const ta = document.createElement("textarea");
856
+ form.appendChild(ta);
857
+ input = createTextareaComposerInput(ta);
858
+ } else {
859
+ let idSeq = 0;
860
+ input = createContentEditableComposerInput({
861
+ generateId: () => `mid-${++idSeq}`,
862
+ renderToken,
863
+ });
864
+ form.appendChild(input.element);
865
+ if (opts.rtl) input.element.style.direction = "rtl";
866
+ }
867
+
868
+ const measure = vi.fn(() => triggerRect);
869
+ // Chip/textarea mode omits the capability entirely (graceful degradation);
870
+ // inline mode exposes it (stubbed — jsdom Range rects are all-zeros).
871
+ if (!opts.textarea) input.getLogicalRangeRect = measure;
872
+
873
+ const controller = new ContextMentionController({
874
+ mentionConfig: {
875
+ enabled: true,
876
+ display: "inline",
877
+ sources: [syncSource("files", [item("App.tsx")])],
878
+ },
879
+ composerInput: input,
880
+ anchor: form,
881
+ getMessages: () => [],
882
+ getConfig: () => ({}) as AgentWidgetConfig,
883
+ onSelect: vi.fn(() => true),
884
+ onInsertMention: vi.fn(),
885
+ admitMention: () => true,
886
+ announce: vi.fn(),
887
+ });
888
+ return { controller, input, form, measure };
889
+ }
890
+
891
+ /** Open on a typed trigger, then mock the menu's own width and reposition. */
892
+ function openAndMeasureMenu(
893
+ controller: ContextMentionController,
894
+ input: ReturnType<typeof createTextareaComposerInput>,
895
+ text: string,
896
+ caret: number,
897
+ menuWidth = 200
898
+ ): HTMLElement {
899
+ input.setValueWithCaret(text, caret);
900
+ controller.onInput();
901
+ const menu = document.querySelector(".persona-mention-menu") as HTMLElement;
902
+ menu.getBoundingClientRect = () => domRect({ width: menuWidth, height: 100 });
903
+ // Force a reposition now that the menu has a measurable width (the popover
904
+ // subscribes to window resize).
905
+ window.dispatchEvent(new Event("resize"));
906
+ return menu;
907
+ }
908
+
909
+ it("anchors the menu's left edge to the trigger glyph when a rect is available", () => {
910
+ // Trigger glyph at viewport x=140 → 40px from the composer's left edge (100).
911
+ const { controller, input } = posSetup(TRIGGER_LINE1(140));
912
+ const menu = openAndMeasureMenu(controller, input, "@a", 2);
913
+ expect(menu.style.left).toBe("140px"); // 100 + 40
914
+ });
915
+
916
+ it("anchors the menu above the trigger line when the glyph is below line 1", () => {
917
+ // Composer top=200, bottom=260 (3 lines). Trigger on line 3: top=240 → y=40.
918
+ // Menu height=100, offset=6 → top = 200 + 40 - 6 - 100 = 134.
919
+ const formRect = domRect({
920
+ left: 100,
921
+ right: 460,
922
+ width: 360,
923
+ top: 200,
924
+ bottom: 260,
925
+ });
926
+ const form = document.createElement("form");
927
+ document.body.appendChild(form);
928
+ form.getBoundingClientRect = () => formRect;
929
+ let idSeq = 0;
930
+ const input = createContentEditableComposerInput({
931
+ generateId: () => `mid-${++idSeq}`,
932
+ renderToken: (ref) => {
933
+ const el = document.createElement("span");
934
+ el.textContent = `@${ref.label}`;
935
+ return el;
936
+ },
937
+ });
938
+ form.appendChild(input.element);
939
+ input.getLogicalRangeRect = vi.fn(() => domRect({ left: 140, top: 240, width: 8, height: 20 }));
940
+ const controller = new ContextMentionController({
941
+ mentionConfig: {
942
+ enabled: true,
943
+ display: "inline",
944
+ sources: [syncSource("files", [item("App.tsx")])],
945
+ },
946
+ composerInput: input,
947
+ anchor: form,
948
+ getMessages: () => [],
949
+ getConfig: () => ({}) as AgentWidgetConfig,
950
+ onSelect: vi.fn(() => true),
951
+ onInsertMention: vi.fn(),
952
+ admitMention: () => true,
953
+ announce: vi.fn(),
954
+ });
955
+ input.setValueWithCaret("@a", 2);
956
+ controller.onInput();
957
+ const menu = document.querySelector(".persona-mention-menu") as HTMLElement;
958
+ menu.getBoundingClientRect = () => domRect({ width: 200, height: 100 });
959
+ window.dispatchEvent(new Event("resize"));
960
+ expect(menu.style.top).toBe("134px");
961
+ });
962
+
963
+ it("uses composer top when the trigger is on line 1 (zero vertical offset)", () => {
964
+ const { controller, input } = posSetup(TRIGGER_LINE1(140));
965
+ const menu = openAndMeasureMenu(controller, input, "@a", 2);
966
+ // top = 200 + 0 - 6 - 100 = 94
967
+ expect(menu.style.top).toBe("94px");
968
+ });
969
+
970
+ it("clamps a near-right trigger left so the menu fits (Slack-style)", () => {
971
+ // Trigger near the right edge (x=440 → offset 340). A 200px menu placed at 440
972
+ // would overflow past 460, so it shifts left to right-align at 460 → left 260.
973
+ const { controller, input } = posSetup(TRIGGER_LINE1(440));
974
+ const menu = openAndMeasureMenu(controller, input, "@a", 2, 200);
975
+ expect(menu.style.left).toBe("260px"); // 460 - 200
976
+ });
977
+
978
+ it("falls back to composer anchoring when the rect is null", () => {
979
+ const { controller, input } = posSetup(null);
980
+ const menu = openAndMeasureMenu(controller, input, "@a", 2);
981
+ expect(menu.style.left).toBe("100px"); // anchor left
982
+ });
983
+
984
+ it("falls back to composer anchoring in RTL", () => {
985
+ // A rect IS available, but RTL takes the horizontal fallback path; vertical
986
+ // line anchoring still applies (trigger on line 1 → same top as composer).
987
+ const { controller, input } = posSetup(TRIGGER_LINE1(440), {
988
+ rtl: true,
989
+ });
990
+ const menu = openAndMeasureMenu(controller, input, "@a", 2);
991
+ expect(menu.style.left).toBe("100px");
992
+ expect(menu.style.top).toBe("94px");
993
+ });
994
+
995
+ it("line-anchors vertically in RTL when the trigger is below line 1", () => {
996
+ const { controller, input } = posSetup(domRect({ left: 440, top: 240, width: 8, height: 20 }), {
997
+ rtl: true,
998
+ });
999
+ const menu = openAndMeasureMenu(controller, input, "@a", 2);
1000
+ expect(menu.style.left).toBe("100px"); // horizontal fallback
1001
+ expect(menu.style.top).toBe("134px"); // vertical line anchor
1002
+ });
1003
+
1004
+ it("falls back to composer anchoring when the composer has no capability", () => {
1005
+ const { controller, input, measure } = posSetup(TRIGGER_LINE1(140), {
1006
+ textarea: true,
1007
+ });
1008
+ const menu = openAndMeasureMenu(controller, input, "@a", 2);
1009
+ expect(menu.style.left).toBe("100px");
1010
+ expect(measure).not.toHaveBeenCalled();
1011
+ });
1012
+
1013
+ it("measures once per session — no re-measure while typing the query", () => {
1014
+ const { controller, input, measure } = posSetup(TRIGGER_LINE1(140));
1015
+ input.setValueWithCaret("@a", 2);
1016
+ controller.onInput(); // opens, measures once
1017
+ input.setValueWithCaret("@ab", 3);
1018
+ controller.onInput(); // same trigger index → no re-measure
1019
+ input.setValueWithCaret("@abc", 4);
1020
+ controller.onInput();
1021
+ expect(measure).toHaveBeenCalledTimes(1);
1022
+ });
1023
+
1024
+ it("re-measures when the trigger index changes (new session)", () => {
1025
+ const { controller, input, measure } = posSetup(TRIGGER_LINE1(140));
1026
+ input.setValueWithCaret("@a", 2);
1027
+ controller.onInput(); // trigger at index 0, measure #1
1028
+ input.setValueWithCaret("hey @a", 6);
1029
+ controller.onInput(); // trigger moved to index 4 → measure #2
1030
+ expect(measure).toHaveBeenCalledTimes(2);
1031
+ });
1032
+ });
1033
+
1034
+ describe("ContextMentionController — follows composer auto-grow (ResizeObserver)", () => {
1035
+ // jsdom has no ResizeObserver, so install a controllable stub whose callback we
1036
+ // fire by hand. Each instance records the observed target and observe/disconnect
1037
+ // calls, and `trigger()` invokes the box-changed callback (a wrap/auto-grow).
1038
+ class ResizeObserverStub {
1039
+ static instances: ResizeObserverStub[] = [];
1040
+ observe = vi.fn();
1041
+ disconnect = vi.fn();
1042
+ private readonly cb: () => void;
1043
+ constructor(cb: () => void) {
1044
+ this.cb = cb;
1045
+ ResizeObserverStub.instances.push(this);
1046
+ }
1047
+ trigger(): void {
1048
+ this.cb();
1049
+ }
1050
+ }
1051
+
1052
+ let savedRO: typeof ResizeObserver | undefined;
1053
+
1054
+ beforeEach(() => {
1055
+ document.body.innerHTML = "";
1056
+ ResizeObserverStub.instances = [];
1057
+ savedRO = globalThis.ResizeObserver;
1058
+ globalThis.ResizeObserver =
1059
+ ResizeObserverStub as unknown as typeof ResizeObserver;
1060
+ });
1061
+
1062
+ afterEach(() => {
1063
+ if (savedRO) globalThis.ResizeObserver = savedRO;
1064
+ else delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
1065
+ });
1066
+
1067
+ const domRect = (r: Partial<DOMRect>): DOMRect =>
1068
+ ({
1069
+ left: 0,
1070
+ right: 0,
1071
+ top: 0,
1072
+ bottom: 0,
1073
+ width: 0,
1074
+ height: 0,
1075
+ x: 0,
1076
+ y: 0,
1077
+ toJSON: () => ({}),
1078
+ ...r,
1079
+ }) as DOMRect;
1080
+
1081
+ // Composer occupies x:[100, 460] (width 360) in viewport coords.
1082
+ const ANCHOR_RECT = domRect({
1083
+ left: 100,
1084
+ right: 460,
1085
+ width: 360,
1086
+ top: 200,
1087
+ bottom: 240,
1088
+ });
1089
+
1090
+ function renderToken(ref: AgentWidgetContextMentionRef): HTMLElement {
1091
+ const el = document.createElement("span");
1092
+ el.className = "persona-mention-token";
1093
+ el.textContent = `@${ref.label}`;
1094
+ return el;
1095
+ }
1096
+
1097
+ function inlinePosSetup(triggerRect: DOMRect | null) {
1098
+ const form = document.createElement("form");
1099
+ document.body.appendChild(form);
1100
+ form.getBoundingClientRect = () => ANCHOR_RECT;
1101
+ let idSeq = 0;
1102
+ const input = createContentEditableComposerInput({
1103
+ generateId: () => `mid-${++idSeq}`,
1104
+ renderToken,
1105
+ });
1106
+ form.appendChild(input.element);
1107
+ const measure = vi.fn(() => triggerRect);
1108
+ input.getLogicalRangeRect = measure;
1109
+ const controller = new ContextMentionController({
1110
+ mentionConfig: {
1111
+ enabled: true,
1112
+ display: "inline",
1113
+ sources: [syncSource("files", [item("App.tsx")])],
1114
+ },
1115
+ composerInput: input,
1116
+ anchor: form,
1117
+ getMessages: () => [],
1118
+ getConfig: () => ({}) as AgentWidgetConfig,
1119
+ onSelect: vi.fn(() => true),
1120
+ onInsertMention: vi.fn(),
1121
+ admitMention: () => true,
1122
+ announce: vi.fn(),
1123
+ });
1124
+ return { controller, input, form, measure };
1125
+ }
1126
+
1127
+ const TRIGGER_LINE1_RESIZE = domRect({ left: 140, top: ANCHOR_RECT.top, width: 8, height: 20 });
1128
+
1129
+ /** Open on a typed trigger and give the menu a measurable width for reposition. */
1130
+ function openMenu(
1131
+ controller: ContextMentionController,
1132
+ input: ReturnType<typeof createContentEditableComposerInput>,
1133
+ menuWidth = 200
1134
+ ): HTMLElement {
1135
+ input.setValueWithCaret("@a", 2);
1136
+ controller.onInput();
1137
+ const menu = document.querySelector(".persona-mention-menu") as HTMLElement;
1138
+ menu.getBoundingClientRect = () => domRect({ width: menuWidth, height: 100 });
1139
+ window.dispatchEvent(new Event("resize")); // force a reposition with the width
1140
+ return menu;
1141
+ }
1142
+
1143
+ it("observes the composer on open and disconnects on close", () => {
1144
+ const { controller, input, form } = inlinePosSetup(TRIGGER_LINE1_RESIZE);
1145
+ openMenu(controller, input);
1146
+ expect(ResizeObserverStub.instances).toHaveLength(1);
1147
+ const obs = ResizeObserverStub.instances[0];
1148
+ expect(obs.observe).toHaveBeenCalledWith(form);
1149
+
1150
+ controller.close();
1151
+ expect(obs.disconnect).toHaveBeenCalledTimes(1);
1152
+ });
1153
+
1154
+ it("disconnects the observer on destroy", () => {
1155
+ const { controller, input } = inlinePosSetup(TRIGGER_LINE1_RESIZE);
1156
+ openMenu(controller, input);
1157
+ const obs = ResizeObserverStub.instances[0];
1158
+
1159
+ controller.destroy();
1160
+ expect(obs.disconnect).toHaveBeenCalledTimes(1);
1161
+ });
1162
+
1163
+ it("re-measures the trigger anchor and repositions when the composer resizes", () => {
1164
+ // @ glyph starts at x=140 → 40px from the composer's left edge (100) → left 140.
1165
+ const { controller, input, measure } = inlinePosSetup(TRIGGER_LINE1_RESIZE);
1166
+ const menu = openMenu(controller, input);
1167
+ expect(menu.style.left).toBe("140px");
1168
+ expect(measure).toHaveBeenCalledTimes(1); // measured once on open
1169
+
1170
+ // A wrap moved the glyph to x=240 (offset 140). Fire the observer: it must
1171
+ // re-measure and reposition so the menu tracks the glyph's new x.
1172
+ const remeasure = vi.fn(() => domRect({ left: 240, top: ANCHOR_RECT.top, width: 8, height: 20 }));
1173
+ input.getLogicalRangeRect = remeasure;
1174
+ ResizeObserverStub.instances[0].trigger();
1175
+
1176
+ expect(menu.style.left).toBe("240px"); // 100 + 140, reposition ran
1177
+ expect(remeasure).toHaveBeenCalledTimes(1); // re-measured on resize
1178
+ });
1179
+
1180
+ it("repositions vertically when a wrap moves the trigger to a new line", () => {
1181
+ const { controller, input } = inlinePosSetup(TRIGGER_LINE1_RESIZE);
1182
+ const menu = openMenu(controller, input);
1183
+ expect(menu.style.top).toBe("94px"); // line 1
1184
+
1185
+ input.getLogicalRangeRect = vi.fn(() =>
1186
+ domRect({ left: 140, top: 240, width: 8, height: 20 })
1187
+ );
1188
+ ResizeObserverStub.instances[0].trigger();
1189
+
1190
+ expect(menu.style.top).toBe("134px"); // line 3
1191
+ });
1192
+
1193
+ it("does not re-measure the anchor on plain query typing (once-per-session)", () => {
1194
+ const { controller, input, measure } = inlinePosSetup(TRIGGER_LINE1_RESIZE);
1195
+ openMenu(controller, input);
1196
+ input.setValueWithCaret("@ab", 3);
1197
+ controller.onInput(); // same trigger index → no re-measure
1198
+ input.setValueWithCaret("@abc", 4);
1199
+ controller.onInput();
1200
+ expect(measure).toHaveBeenCalledTimes(1);
1201
+ });
1202
+
1203
+ it("creates no observer and does not crash when ResizeObserver is undefined", () => {
1204
+ delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
1205
+ const { controller, input } = inlinePosSetup(TRIGGER_LINE1_RESIZE);
1206
+ let menu!: HTMLElement;
1207
+ expect(() => {
1208
+ menu = openMenu(controller, input);
1209
+ }).not.toThrow();
1210
+ expect(ResizeObserverStub.instances).toHaveLength(0);
1211
+ // Positioning still works via the scroll/window-resize path (degrades cleanly).
1212
+ expect(menu.style.left).toBe("140px");
1213
+ expect(() => controller.close()).not.toThrow();
1214
+ });
1215
+ });