@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,538 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { createContextMentionOrchestrator } from "./context-mention-orchestrator";
5
+ import { createStaticMentionSource, createSlashCommandsSource } from "./mention-matcher";
6
+ import * as contextMentionsLoader from "../context-mentions-loader";
7
+ import { loadContextMentions } from "../context-mentions-loader";
8
+ import {
9
+ loadContextMentionsInline,
10
+ setContextMentionsInlineLoader,
11
+ } from "../context-mentions-inline-loader";
12
+ import type { AgentWidgetConfig } from "../types";
13
+
14
+ const pump = async (n = 8) => {
15
+ for (let i = 0; i < n; i++) await new Promise((r) => setTimeout(r, 0));
16
+ };
17
+
18
+ /** Build an inline-display orchestrator over a fresh textarea. */
19
+ function inlineSetup() {
20
+ document.body.innerHTML = "";
21
+ const form = document.createElement("form");
22
+ const textarea = document.createElement("textarea");
23
+ form.appendChild(textarea);
24
+ document.body.appendChild(form);
25
+ const config = {
26
+ contextMentions: {
27
+ enabled: true,
28
+ display: "inline",
29
+ sources: [
30
+ createStaticMentionSource({
31
+ id: "files",
32
+ label: "Files",
33
+ items: [{ id: "app", label: "App.tsx" }],
34
+ resolve: (i: { label: string }) => ({ llmAppend: `body of ${i.label}` }),
35
+ }),
36
+ ],
37
+ },
38
+ } as AgentWidgetConfig;
39
+ const orchestrator = createContextMentionOrchestrator({
40
+ config,
41
+ textarea,
42
+ anchor: form,
43
+ getMessages: () => [],
44
+ liveRegionHost: document.body,
45
+ })!;
46
+ return { orchestrator, textarea, form };
47
+ }
48
+
49
+ // Flush the dynamic-import → mount → handleInput promise chain. Awaiting the
50
+ // module load first makes this deterministic regardless of which test pays the
51
+ // initial (slower) dynamic-import cost.
52
+ const flush = async () => {
53
+ await loadContextMentions().catch(() => {});
54
+ for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
55
+ };
56
+
57
+ /** Also flush the inline chunk (composer swap happens on its load). */
58
+ const flushInline = async () => {
59
+ await loadContextMentionsInline().catch(() => {});
60
+ await loadContextMentions().catch(() => {});
61
+ for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
62
+ };
63
+
64
+ function makeConfig(): AgentWidgetConfig {
65
+ return {
66
+ contextMentions: {
67
+ enabled: true,
68
+ sources: [
69
+ createStaticMentionSource({
70
+ id: "files",
71
+ label: "Files",
72
+ items: [
73
+ { id: "app", label: "App.tsx" },
74
+ { id: "readme", label: "README.md" },
75
+ ],
76
+ resolve: (item) => ({ llmAppend: `body of ${item.label}` }),
77
+ }),
78
+ ],
79
+ },
80
+ } as AgentWidgetConfig;
81
+ }
82
+
83
+ function setup() {
84
+ document.body.innerHTML = "";
85
+ const form = document.createElement("form");
86
+ const textarea = document.createElement("textarea");
87
+ form.appendChild(textarea);
88
+ document.body.appendChild(form);
89
+ const config = makeConfig();
90
+ const orchestrator = createContextMentionOrchestrator({
91
+ config,
92
+ textarea,
93
+ anchor: form,
94
+ getMessages: () => [],
95
+ liveRegionHost: document.body,
96
+ })!;
97
+ // Place the orchestrator DOM like ui.ts does.
98
+ textarea.parentElement!.insertBefore(orchestrator.contextRow, textarea);
99
+ for (const btn of orchestrator.affordanceButtons) form.appendChild(btn);
100
+ return { orchestrator, textarea, form };
101
+ }
102
+
103
+ describe("createContextMentionOrchestrator (lazy-load integration)", () => {
104
+ it("returns null when disabled", () => {
105
+ const o = createContextMentionOrchestrator({
106
+ config: {} as AgentWidgetConfig,
107
+ textarea: document.createElement("textarea"),
108
+ anchor: document.createElement("form"),
109
+ getMessages: () => [],
110
+ liveRegionHost: document.body,
111
+ });
112
+ expect(o).toBeNull();
113
+ });
114
+
115
+ it("exposes an affordance button and an (initially hidden) chip row", () => {
116
+ const { orchestrator } = setup();
117
+ expect(orchestrator.affordanceButtons).toHaveLength(1);
118
+ expect(orchestrator.contextRow.getAttribute("data-persona-mention-context-row")).toBe("");
119
+ });
120
+
121
+ it("lazy-loads the runtime on the first @ and opens the menu", async () => {
122
+ const { orchestrator, textarea } = setup();
123
+ expect(document.querySelector("[data-persona-mention-menu]")).toBeNull();
124
+ textarea.value = "@";
125
+ textarea.setSelectionRange(1, 1);
126
+ orchestrator.handleInput("insertText");
127
+ await flush();
128
+ expect(orchestrator.isMenuOpen()).toBe(true);
129
+ expect(document.querySelector("[data-persona-mention-menu]")).not.toBeNull();
130
+ });
131
+
132
+ it("does not lazy-load on paste of an @", async () => {
133
+ const { orchestrator, textarea } = setup();
134
+ textarea.value = "@";
135
+ textarea.setSelectionRange(1, 1);
136
+ orchestrator.handleInput("insertFromPaste");
137
+ await flush();
138
+ expect(orchestrator.isMenuOpen()).toBe(false);
139
+ });
140
+
141
+ it("selecting an item adds a chip and surfaces it via collectForSubmit", async () => {
142
+ const { orchestrator, textarea } = setup();
143
+ textarea.value = "@app";
144
+ textarea.setSelectionRange(4, 4);
145
+ orchestrator.handleInput("insertText");
146
+ await flush();
147
+
148
+ const enter = new KeyboardEvent("keydown", { key: "Enter" });
149
+ expect(orchestrator.handleKeydown(enter)).toBe(true);
150
+
151
+ expect(orchestrator.hasMentions()).toBe(true);
152
+ expect(textarea.value).toBe(""); // @app stripped on select
153
+ const collected = orchestrator.collectForSubmit();
154
+ expect(collected?.refs.map((r) => r.itemId)).toEqual(["app"]);
155
+ const bundle = await collected!.finalize();
156
+ expect(bundle.blocks[0]).toBe("```App.tsx\nbody of App.tsx\n```");
157
+ });
158
+
159
+ it("Backspace on an empty composer removes the last chip", async () => {
160
+ const { orchestrator, textarea } = setup();
161
+ textarea.value = "@app";
162
+ textarea.setSelectionRange(4, 4);
163
+ orchestrator.handleInput("insertText");
164
+ await flush();
165
+ orchestrator.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
166
+ expect(orchestrator.hasMentions()).toBe(true);
167
+
168
+ // Empty composer + Backspace → removes the chip.
169
+ textarea.value = "";
170
+ textarea.setSelectionRange(0, 0);
171
+ const bs = new KeyboardEvent("keydown", { key: "Backspace" });
172
+ expect(orchestrator.handleKeydown(bs)).toBe(true);
173
+ expect(orchestrator.hasMentions()).toBe(false);
174
+ });
175
+
176
+ it("the affordance button opens the same menu", async () => {
177
+ const { orchestrator } = setup();
178
+ (orchestrator.affordanceButtons[0].querySelector("button") as HTMLButtonElement).click();
179
+ await flush();
180
+ expect(orchestrator.isMenuOpen()).toBe(true);
181
+ });
182
+
183
+ it("reflects the picker open state on the affordance button (aria-expanded + aria-controls)", async () => {
184
+ const { orchestrator } = setup();
185
+ const button = orchestrator.affordanceButtons[0].querySelector(
186
+ "button"
187
+ ) as HTMLButtonElement;
188
+ // Collapsed before any interaction.
189
+ expect(button.getAttribute("aria-expanded")).toBe("false");
190
+ expect(button.hasAttribute("aria-controls")).toBe(false);
191
+
192
+ button.click();
193
+ await flush();
194
+ expect(orchestrator.isMenuOpen()).toBe(true);
195
+ const listbox = document.querySelector<HTMLElement>('[role="listbox"]')!;
196
+ expect(button.getAttribute("aria-expanded")).toBe("true");
197
+ expect(button.getAttribute("aria-controls")).toBe(listbox.id);
198
+
199
+ // Escape closes the picker → button collapses and drops the dangling control.
200
+ orchestrator.handleKeydown(new KeyboardEvent("keydown", { key: "Escape" }));
201
+ expect(button.getAttribute("aria-expanded")).toBe("false");
202
+ expect(button.hasAttribute("aria-controls")).toBe(false);
203
+ });
204
+
205
+ it("does not put aria-expanded on the composer surface (role=textbox keeps aria-haspopup instead)", async () => {
206
+ const { orchestrator, textarea } = setup();
207
+ textarea.value = "@";
208
+ textarea.setSelectionRange(1, 1);
209
+ orchestrator.handleInput("insertText");
210
+ await flush();
211
+ expect(orchestrator.isMenuOpen()).toBe(true);
212
+ // aria-expanded is unsupported on role=textbox; the popup is advertised via
213
+ // aria-haspopup + aria-controls + the result-count live region instead.
214
+ expect(textarea.hasAttribute("aria-expanded")).toBe(false);
215
+ expect(textarea.getAttribute("aria-haspopup")).toBe("listbox");
216
+ expect(textarea.getAttribute("aria-controls")).toBe(
217
+ document.querySelector<HTMLElement>('[role="listbox"]')!.id
218
+ );
219
+ });
220
+
221
+ it("takeInlineCommand: ignores plain text, dispatches a leading server command", async () => {
222
+ document.body.innerHTML = "";
223
+ const form = document.createElement("form");
224
+ const textarea = document.createElement("textarea");
225
+ form.appendChild(textarea);
226
+ document.body.appendChild(form);
227
+ const config = {
228
+ contextMentions: {
229
+ enabled: true,
230
+ sources: [],
231
+ triggers: [
232
+ {
233
+ trigger: "/",
234
+ triggerPosition: "line-start",
235
+ sources: [
236
+ createSlashCommandsSource({
237
+ id: "cmd",
238
+ label: "Commands",
239
+ commands: [
240
+ {
241
+ name: "lookup",
242
+ kind: "server",
243
+ argsPlaceholder: "order id",
244
+ data: (args) => ({ orderId: args }),
245
+ },
246
+ ],
247
+ }),
248
+ ],
249
+ },
250
+ ],
251
+ },
252
+ } as AgentWidgetConfig;
253
+ const orchestrator = createContextMentionOrchestrator({
254
+ config,
255
+ textarea,
256
+ anchor: form,
257
+ getMessages: () => [],
258
+ liveRegionHost: document.body,
259
+ })!;
260
+
261
+ // Plain text never loads the runtime or matches.
262
+ expect(await orchestrator.takeInlineCommand("just a message")).toBeNull();
263
+
264
+ // A leading `/lookup 1042` lazy-loads the runtime and resolves its context.
265
+ const result = await orchestrator.takeInlineCommand("/lookup 1042");
266
+ expect(result?.kind).toBe("server");
267
+ if (result?.kind !== "server") throw new Error("expected a server command");
268
+ expect(result.mentions.refs).toEqual([]);
269
+ const bundle = await result.mentions.finalize();
270
+ expect(bundle.context).toEqual({ cmd: { lookup: { orderId: "1042" } } });
271
+ });
272
+
273
+ it("takeInlineCommand: dispatches a line-start command that leads line 2", async () => {
274
+ document.body.innerHTML = "";
275
+ const form = document.createElement("form");
276
+ const textarea = document.createElement("textarea");
277
+ form.appendChild(textarea);
278
+ document.body.appendChild(form);
279
+ const config = {
280
+ contextMentions: {
281
+ enabled: true,
282
+ sources: [],
283
+ triggers: [
284
+ {
285
+ trigger: "/",
286
+ triggerPosition: "line-start",
287
+ sources: [
288
+ createSlashCommandsSource({
289
+ id: "cmd",
290
+ label: "Commands",
291
+ commands: [
292
+ {
293
+ name: "lookup",
294
+ kind: "server",
295
+ argsPlaceholder: "order id",
296
+ data: (args) => ({ orderId: args }),
297
+ },
298
+ ],
299
+ }),
300
+ ],
301
+ },
302
+ ],
303
+ },
304
+ } as AgentWidgetConfig;
305
+ const orchestrator = createContextMentionOrchestrator({
306
+ config,
307
+ textarea,
308
+ anchor: form,
309
+ getMessages: () => [],
310
+ liveRegionHost: document.body,
311
+ })!;
312
+
313
+ // A line-start command leading line 2 (not just line 1) must dispatch — the
314
+ // eager `looksLikeCommand` gate honors every line start, matching the live parser.
315
+ const result = await orchestrator.takeInlineCommand("hello there\n/lookup 1042");
316
+ expect(result?.kind).toBe("server");
317
+ if (result?.kind !== "server") throw new Error("expected a server command");
318
+ const bundle = await result.mentions.finalize();
319
+ expect(bundle.context).toEqual({ cmd: { lookup: { orderId: "1042" } } });
320
+ });
321
+
322
+ it("a transient runtime-chunk failure does not disable mentions for the session", async () => {
323
+ const { orchestrator, textarea } = setup();
324
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
325
+ // Reject only the FIRST load; later calls fall through to the real loader.
326
+ const spy = vi
327
+ .spyOn(contextMentionsLoader, "loadContextMentions")
328
+ .mockImplementationOnce(() => Promise.reject(new Error("transient chunk failure")));
329
+
330
+ // First trigger: the chunk load rejects, so the menu can't open this time.
331
+ textarea.value = "@";
332
+ textarea.setSelectionRange(1, 1);
333
+ orchestrator.handleInput("insertText");
334
+ await flush();
335
+ expect(orchestrator.isMenuOpen()).toBe(false);
336
+
337
+ // The cached mount promise must have been cleared on failure: a later trigger
338
+ // retries and, now that the load succeeds, mounts the runtime and opens.
339
+ textarea.value = "@app";
340
+ textarea.setSelectionRange(4, 4);
341
+ orchestrator.handleInput("insertText");
342
+ await flush();
343
+ expect(orchestrator.isMenuOpen()).toBe(true);
344
+
345
+ spy.mockRestore();
346
+ warn.mockRestore();
347
+ });
348
+
349
+ it("degraded inline (chunk load fails): Backspace still removes the last chip", async () => {
350
+ // Force the inline chunk to fail so the widget degrades to chip behavior while
351
+ // config still says `display: "inline"`. Gating on the config (not the runtime
352
+ // swap) lost this affordance exactly in the degraded state.
353
+ setContextMentionsInlineLoader(() =>
354
+ Promise.reject(new Error("no inline chunk"))
355
+ );
356
+ const { orchestrator, textarea } = inlineSetup();
357
+
358
+ await flushInline().catch(() => {});
359
+ expect(textarea.isConnected).toBe(true); // swap never happened → still textarea
360
+
361
+ // Select a mention → the degraded path uses the chip row.
362
+ textarea.value = "@app";
363
+ textarea.setSelectionRange(4, 4);
364
+ orchestrator.handleInput("insertText");
365
+ await flush();
366
+ orchestrator.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
367
+ expect(orchestrator.hasMentions()).toBe(true);
368
+
369
+ // Empty composer + Backspace removes the last chip (runtime chip mode).
370
+ textarea.value = "";
371
+ textarea.setSelectionRange(0, 0);
372
+ const bs = new KeyboardEvent("keydown", { key: "Backspace" });
373
+ expect(orchestrator.handleKeydown(bs)).toBe(true);
374
+ expect(orchestrator.hasMentions()).toBe(false);
375
+ });
376
+
377
+ it("rebinds the menu engine to the live composer when the menu opened before the swap", async () => {
378
+ // Gate the inline chunk so the menu engine mounts (bound to the textarea)
379
+ // BEFORE the swap — the race that left the menu dead for the session.
380
+ let releaseInline!: () => void;
381
+ const gate = new Promise<void>((r) => (releaseInline = r));
382
+ const realInline = await import("../context-mentions-inline");
383
+ setContextMentionsInlineLoader(() => gate.then(() => realInline));
384
+
385
+ const { orchestrator, textarea, form } = inlineSetup();
386
+
387
+ // Open the menu on the still-live textarea (inline swap gated).
388
+ textarea.value = "@app";
389
+ textarea.setSelectionRange(4, 4);
390
+ orchestrator.handleInput("insertText");
391
+ await flush();
392
+ expect(orchestrator.isMenuOpen()).toBe(true);
393
+ expect(textarea.isConnected).toBe(true); // not swapped yet
394
+
395
+ // Release the inline chunk → swap + engine re-mount onto the contenteditable.
396
+ releaseInline();
397
+ await pump();
398
+ const editable = form.querySelector<HTMLElement>(
399
+ '[contenteditable="true"][data-persona-composer-input]'
400
+ );
401
+ expect(editable).not.toBeNull();
402
+ expect(textarea.isConnected).toBe(false);
403
+
404
+ // A post-swap `@` drives a WORKING menu on the contenteditable and selecting
405
+ // inserts a token there (proving the engine tracks the live element, not the
406
+ // detached textarea).
407
+ const el = editable as unknown as HTMLTextAreaElement;
408
+ el.value = "@app";
409
+ orchestrator.handleInput("insertText");
410
+ await flush();
411
+ expect(orchestrator.isMenuOpen()).toBe(true);
412
+ orchestrator.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
413
+ expect(editable!.querySelectorAll(".persona-mention-token")).toHaveLength(1);
414
+ expect(orchestrator.hasMentions()).toBe(true);
415
+ });
416
+
417
+ it("a mention committed pre-swap (chip path) survives the engine rebind and still finalizes", async () => {
418
+ // Gate the inline chunk: the user opens the menu and COMMITS a mention while
419
+ // the textarea is still live (chip path, resolve in flight), then the chunk
420
+ // lands. The rebind must not discard the manager — the committed chip and its
421
+ // resolve must survive into the submit payload.
422
+ let releaseInline!: () => void;
423
+ const gate = new Promise<void>((r) => (releaseInline = r));
424
+ const realInline = await import("../context-mentions-inline");
425
+ setContextMentionsInlineLoader(() => gate.then(() => realInline));
426
+
427
+ const { orchestrator, textarea, form } = inlineSetup();
428
+
429
+ textarea.value = "@app";
430
+ textarea.setSelectionRange(4, 4);
431
+ orchestrator.handleInput("insertText");
432
+ await flush();
433
+ orchestrator.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
434
+ expect(orchestrator.hasMentions()).toBe(true);
435
+ expect(
436
+ orchestrator.contextRow.querySelectorAll("[data-persona-mention-chip]")
437
+ ).toHaveLength(1); // committed as a chip pre-swap
438
+
439
+ // Inline chunk lands → swap + engine rebind.
440
+ releaseInline();
441
+ await pump();
442
+ expect(
443
+ form.querySelector('[contenteditable="true"][data-persona-composer-input]')
444
+ ).not.toBeNull();
445
+ expect(textarea.isConnected).toBe(false);
446
+
447
+ // The pre-swap mention was NOT discarded: chip intact, still collectable.
448
+ expect(orchestrator.hasMentions()).toBe(true);
449
+ expect(
450
+ orchestrator.contextRow.querySelectorAll("[data-persona-mention-chip]")
451
+ ).toHaveLength(1);
452
+ const collected = orchestrator.collectForSubmit();
453
+ expect(collected?.refs.map((r) => r.itemId)).toEqual(["app"]);
454
+ const bundle = await collected!.finalize();
455
+ expect(bundle.blocks[0]).toBe("```App.tsx\nbody of App.tsx\n```");
456
+ });
457
+
458
+ it("inline display: swaps the textarea for a contenteditable and inserts a token on select", async () => {
459
+ document.body.innerHTML = "";
460
+ const form = document.createElement("form");
461
+ const textarea = document.createElement("textarea");
462
+ form.appendChild(textarea);
463
+ document.body.appendChild(form);
464
+ const config = {
465
+ contextMentions: {
466
+ enabled: true,
467
+ display: "inline",
468
+ sources: [
469
+ createStaticMentionSource({
470
+ id: "files",
471
+ label: "Files",
472
+ items: [{ id: "app", label: "App.tsx" }],
473
+ resolve: (item) => ({ llmAppend: `body of ${item.label}` }),
474
+ }),
475
+ ],
476
+ },
477
+ } as AgentWidgetConfig;
478
+
479
+ const swaps: Array<{ next: HTMLElement; prev: HTMLElement }> = [];
480
+ const orchestrator = createContextMentionOrchestrator({
481
+ config,
482
+ textarea,
483
+ anchor: form,
484
+ getMessages: () => [],
485
+ liveRegionHost: document.body,
486
+ })!;
487
+ orchestrator.onComposerSwap((next, prev) => swaps.push({ next, prev }));
488
+
489
+ // The inline chunk loads on mount and swaps the textarea for a contenteditable.
490
+ await flushInline();
491
+ const editable = form.querySelector<HTMLElement>(
492
+ '[contenteditable="true"][data-persona-composer-input]'
493
+ );
494
+ expect(editable).not.toBeNull();
495
+ expect(textarea.isConnected).toBe(false); // replaced
496
+ expect(swaps).toHaveLength(1);
497
+ expect(swaps[0].next).toBe(editable);
498
+
499
+ // Type "@app" into the swapped surface (via the textarea-compat shim) and open.
500
+ const el = editable as unknown as HTMLTextAreaElement;
501
+ el.value = "@app";
502
+ orchestrator.handleInput("insertText");
503
+ await flush();
504
+ expect(orchestrator.isMenuOpen()).toBe(true);
505
+
506
+ // Enter selects → an atomic token lands in the prose (no chip row), and the
507
+ // mention is tracked for submit.
508
+ orchestrator.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
509
+ expect(editable!.querySelectorAll(".persona-mention-token")).toHaveLength(1);
510
+ expect(orchestrator.contextRow.children).toHaveLength(0); // no chip row
511
+ expect(orchestrator.hasMentions()).toBe(true);
512
+
513
+ const collected = orchestrator.collectForSubmit();
514
+ expect(collected?.refs.map((r) => r.itemId)).toEqual(["app"]);
515
+ const bundle = await collected!.finalize();
516
+ expect(bundle.blocks[0]).toBe("```App.tsx\nbody of App.tsx\n```");
517
+ });
518
+
519
+ it("emits persona:mention:* analytics events", async () => {
520
+ const { orchestrator, textarea } = setup();
521
+ const opened = vi.fn();
522
+ const selected = vi.fn();
523
+ window.addEventListener("persona:mention:opened", opened);
524
+ window.addEventListener("persona:mention:selected", selected);
525
+
526
+ textarea.value = "@app";
527
+ textarea.setSelectionRange(4, 4);
528
+ orchestrator.handleInput("insertText");
529
+ await flush();
530
+ expect(opened).toHaveBeenCalled();
531
+
532
+ orchestrator.handleKeydown(new KeyboardEvent("keydown", { key: "Enter" }));
533
+ expect(selected).toHaveBeenCalled();
534
+
535
+ window.removeEventListener("persona:mention:opened", opened);
536
+ window.removeEventListener("persona:mention:selected", selected);
537
+ });
538
+ });