@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,507 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi, beforeEach } from "vitest";
4
+ import {
5
+ createContentEditableComposerInput,
6
+ domPositionToLogical,
7
+ logicalToDomPosition,
8
+ MENTION_TOKEN_CLASS
9
+ } from "./composer-contenteditable";
10
+ import type { AgentWidgetContextMentionRef } from "../types";
11
+
12
+ const appRef: AgentWidgetContextMentionRef = {
13
+ sourceId: "files",
14
+ itemId: "app",
15
+ label: "App.tsx"
16
+ };
17
+
18
+ let idSeq = 0;
19
+ const generateId = () => `mid-${++idSeq}`;
20
+
21
+ /** Stand-in for the core orchestrator's pill factory (now a required option).
22
+ * Mirrors createMentionTokenElement's accessible attributes so the
23
+ * setMentionStatus stash/restore behaves as it does on real default tokens. */
24
+ function defaultRenderToken(ref: AgentWidgetContextMentionRef): HTMLElement {
25
+ const el = document.createElement("span");
26
+ el.className = MENTION_TOKEN_CLASS;
27
+ el.setAttribute("title", ref.label);
28
+ el.setAttribute("role", "img");
29
+ el.setAttribute("aria-label", `${ref.label} mention`);
30
+ const label = document.createElement("span");
31
+ label.className = "persona-mention-token-label";
32
+ label.textContent = `@${ref.label}`;
33
+ el.appendChild(label);
34
+ return el;
35
+ }
36
+
37
+ function make(overrides = {}) {
38
+ const onMentionInserted = vi.fn();
39
+ const onMentionRemoved = vi.fn();
40
+ const input = createContentEditableComposerInput({
41
+ generateId,
42
+ renderToken: defaultRenderToken,
43
+ onMentionInserted,
44
+ onMentionRemoved,
45
+ ...overrides
46
+ });
47
+ document.body.appendChild(input.element);
48
+ return { input, onMentionInserted, onMentionRemoved };
49
+ }
50
+
51
+ function tokenSpans(root: HTMLElement): HTMLElement[] {
52
+ return Array.from(
53
+ root.querySelectorAll<HTMLElement>(`.${MENTION_TOKEN_CLASS}`)
54
+ );
55
+ }
56
+
57
+ /** Seed one mention token via the product insertion path: `before` text, the
58
+ * token, then `after` text. Places a real DOM caret first (jsdom won't focus a
59
+ * contenteditable) so the token splices at that offset. Returns the mention id. */
60
+ function seedToken(
61
+ input: ReturnType<typeof make>["input"],
62
+ before: string,
63
+ after: string,
64
+ ref: AgentWidgetContextMentionRef = appRef
65
+ ): string {
66
+ input.setValueWithCaret(before + after, before.length);
67
+ const sel = window.getSelection()!;
68
+ const pos = logicalToDomPosition(input.element, before.length);
69
+ const range = document.createRange();
70
+ range.setStart(pos.node, pos.offset);
71
+ range.collapse(true);
72
+ sel.removeAllRanges();
73
+ sel.addRange(range);
74
+ return input.insertMentionAtSelection!(ref);
75
+ }
76
+
77
+ describe("composer-contenteditable caret mapping", () => {
78
+ it("maps between DOM position and logical offset around a token", () => {
79
+ // "Check " + [token] + " end" → logical "Check  end"
80
+ const root = document.createElement("div");
81
+ const t1 = document.createTextNode("Check ");
82
+ const span = document.createElement("span");
83
+ span.setAttribute("data-mention-id", "m1");
84
+ span.setAttribute("contenteditable", "false");
85
+ span.textContent = "@App.tsx";
86
+ const t2 = document.createTextNode(" end");
87
+ root.append(t1, span, t2);
88
+
89
+ // caret in the leading text at offset 3 → logical 3
90
+ expect(domPositionToLogical(root, t1, 3)).toBe(3);
91
+ // caret right after the token (root offset 2) → logical 7 (6 + )
92
+ expect(domPositionToLogical(root, root, 2)).toBe(7);
93
+ // caret in the trailing text at offset 2 → logical 9 (6 + 1 + 2)
94
+ expect(domPositionToLogical(root, t2, 2)).toBe(9);
95
+
96
+ // reverse: logical 3 → inside the first text node
97
+ expect(logicalToDomPosition(root, 3)).toEqual({ node: t1, offset: 3 });
98
+ // logical 6 (token boundary) resolves to end of the text node — the caret
99
+ // spot equivalent to "just before the token".
100
+ expect(logicalToDomPosition(root, 6)).toEqual({ node: t1, offset: 6 });
101
+ // logical 7 (just after the token) → root child slot 2
102
+ expect(logicalToDomPosition(root, 7)).toEqual({ node: root, offset: 2 });
103
+ });
104
+
105
+ it("treats a <br> as one newline in caret math (both directions)", () => {
106
+ // "a" + <br> + "b" → logical "a\nb": a=0, \n=1 (the <br>), b=2.
107
+ const root = document.createElement("div");
108
+ const t1 = document.createTextNode("a");
109
+ const br = document.createElement("br");
110
+ const t2 = document.createTextNode("b");
111
+ root.append(t1, br, t2);
112
+
113
+ // caret after "a"
114
+ expect(domPositionToLogical(root, t1, 1)).toBe(1);
115
+ // caret after the <br> (root child slot 2) → logical 2
116
+ expect(domPositionToLogical(root, root, 2)).toBe(2);
117
+ // caret after "b"
118
+ expect(domPositionToLogical(root, t2, 1)).toBe(3);
119
+
120
+ // reverse: logical 3 lands inside the trailing text node
121
+ expect(logicalToDomPosition(root, 3)).toEqual({ node: t2, offset: 1 });
122
+ // logical 2 (just after the break) → root child slot 2
123
+ expect(logicalToDomPosition(root, 2)).toEqual({ node: root, offset: 2 });
124
+ });
125
+ });
126
+
127
+ describe("createContentEditableComposerInput", () => {
128
+ beforeEach(() => {
129
+ document.body.innerHTML = "";
130
+ idSeq = 0;
131
+ });
132
+
133
+ it("starts empty and contenteditable", () => {
134
+ const { input } = make();
135
+ expect(input.element.getAttribute("contenteditable")).toBe("true");
136
+ expect(input.element.getAttribute("data-persona-composer-input")).toBe("");
137
+ expect(input.element.childNodes).toHaveLength(0); // :empty for placeholder
138
+ expect(input.getValue()).toBe("");
139
+ expect(input.getLogicalText()).toBe("");
140
+ });
141
+
142
+ it("renders a document with an atomic token and projects text", () => {
143
+ const { input } = make();
144
+ const id = seedToken(input, "Check ", " now");
145
+ const spans = tokenSpans(input.element);
146
+ expect(spans).toHaveLength(1);
147
+ expect(spans[0].getAttribute("contenteditable")).toBe("false");
148
+ expect(spans[0].getAttribute("data-mention-id")).toBe(id);
149
+ expect(input.getValue()).toBe("Check @App.tsx now");
150
+ expect(input.getLogicalText()).toBe("Check  now");
151
+ });
152
+
153
+ it("re-parses the DOM live after a native text edit", () => {
154
+ const { input } = make();
155
+ input.setValueWithCaret("hello", 5);
156
+ // Simulate the browser mutating the text node (native typing).
157
+ (input.element.firstChild as Text).data = "hello world";
158
+ expect(input.getValue()).toBe("hello world");
159
+ expect(input.getDocument!().blocks).toEqual([
160
+ { kind: "text", value: "hello world" }
161
+ ]);
162
+ });
163
+
164
+ it("insertMentionAtTrigger replaces the @query with a token and tracks it", () => {
165
+ const { input, onMentionInserted } = make();
166
+ input.setValueWithCaret("Check @App", 10);
167
+ const id = input.insertMentionAtTrigger!(appRef, {
168
+ triggerIndex: 6,
169
+ query: "App"
170
+ });
171
+ expect(id).toBe("mid-1");
172
+ // Completion appends the separating space (Slack-style).
173
+ expect(input.getValue()).toBe("Check @App.tsx ");
174
+ expect(input.getLogicalText()).toBe("Check  ");
175
+ expect(tokenSpans(input.element)).toHaveLength(1);
176
+ expect(onMentionInserted).toHaveBeenCalledWith("mid-1", appRef);
177
+ });
178
+
179
+ it("insertMentionAtTrigger returns null on a stale trigger range", () => {
180
+ const { input, onMentionInserted } = make();
181
+ input.setValueWithCaret("Check @App", 10);
182
+ // Query no longer matches what's in the document.
183
+ const id = input.insertMentionAtTrigger!(appRef, {
184
+ triggerIndex: 6,
185
+ query: "Xyz"
186
+ });
187
+ expect(id).toBeNull();
188
+ expect(tokenSpans(input.element)).toHaveLength(0);
189
+ expect(onMentionInserted).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it("allows duplicate refs as distinct tokens with distinct ids", () => {
193
+ const { input } = make();
194
+ input.setValueWithCaret("@App", 4);
195
+ const id1 = input.insertMentionAtTrigger!(appRef, {
196
+ triggerIndex: 0,
197
+ query: "App"
198
+ });
199
+ // Type another trigger after the token and insert again.
200
+ const logical = input.getLogicalText(); // ""
201
+ input.setValueWithCaret(logical + " @App", logical.length + 5);
202
+ // Re-inserting requires the token to be present; simplest: assert two ids
203
+ // differ when inserted on fresh text.
204
+ input.setValueWithCaret("@App", 4);
205
+ const id2 = input.insertMentionAtTrigger!(appRef, {
206
+ triggerIndex: 0,
207
+ query: "App"
208
+ });
209
+ expect(id1).not.toBe(id2);
210
+ });
211
+
212
+ it("fires onMentionRemoved when a token disappears from the DOM (input)", () => {
213
+ const { input, onMentionRemoved } = make();
214
+ const id = seedToken(input, "a ", " b");
215
+ // Simulate the user deleting the token node, then the browser firing input.
216
+ tokenSpans(input.element)[0].remove();
217
+ input.element.dispatchEvent(new Event("input", { bubbles: true }));
218
+ expect(onMentionRemoved).toHaveBeenCalledWith(id);
219
+ });
220
+
221
+ it("does not reconcile removals mid-IME-composition", () => {
222
+ const { input, onMentionRemoved } = make();
223
+ const id = seedToken(input, "", "");
224
+ input.element.dispatchEvent(new Event("compositionstart"));
225
+ tokenSpans(input.element)[0].remove();
226
+ input.element.dispatchEvent(new Event("input", { bubbles: true }));
227
+ expect(onMentionRemoved).not.toHaveBeenCalled();
228
+ // On composition end the reconcile runs.
229
+ input.element.dispatchEvent(new Event("compositionend"));
230
+ expect(onMentionRemoved).toHaveBeenCalledWith(id);
231
+ });
232
+
233
+ it("coerces paste to plain text (splice fallback)", () => {
234
+ const { input } = make();
235
+ input.setValueWithCaret("ab", 2);
236
+ // Force the fallback path (jsdom has no working execCommand insert).
237
+ const original = document.execCommand;
238
+ // @ts-expect-error override for the test
239
+ document.execCommand = undefined;
240
+ try {
241
+ // jsdom lacks DataTransfer/ClipboardEvent.clipboardData — fake it.
242
+ const evt = new Event("paste", { bubbles: true, cancelable: true });
243
+ Object.defineProperty(evt, "clipboardData", {
244
+ value: { getData: (type: string) => (type === "text/plain" ? "XY" : "") }
245
+ });
246
+ // Caret at end (offset 2) via a real range.
247
+ const sel = window.getSelection()!;
248
+ const range = document.createRange();
249
+ range.setStart(input.element.firstChild!, 2);
250
+ range.collapse(true);
251
+ sel.removeAllRanges();
252
+ sel.addRange(range);
253
+ input.element.dispatchEvent(evt);
254
+ expect(evt.defaultPrevented).toBe(true);
255
+ expect(input.getValue()).toBe("abXY");
256
+ } finally {
257
+ document.execCommand = original;
258
+ }
259
+ });
260
+
261
+ it("reads a native <br> back as a newline (getValue / getLogicalText)", () => {
262
+ const { input } = make();
263
+ input.setValueWithCaret("a", 1);
264
+ // Simulate Shift+Enter: the browser inserts a <br> then a text node.
265
+ input.element.append(
266
+ document.createElement("br"),
267
+ document.createTextNode("b")
268
+ );
269
+ expect(input.getValue()).toBe("a\nb");
270
+ expect(input.getLogicalText()).toBe("a\nb");
271
+ expect(input.getDocument!().blocks).toEqual([
272
+ { kind: "text", value: "a" },
273
+ { kind: "text", value: "\n" },
274
+ { kind: "text", value: "b" }
275
+ ]);
276
+ });
277
+
278
+ it("round-trips a newline through the document model (getValue keeps \\n)", () => {
279
+ const { input } = make();
280
+ input.setValueWithCaret("a\nb", 3);
281
+ expect(input.getValue()).toBe("a\nb");
282
+ expect(input.getLogicalText()).toBe("a\nb");
283
+ });
284
+
285
+ it("inserts a mention after a line break", () => {
286
+ const { input } = make();
287
+ input.setValueWithCaret("a", 1);
288
+ input.element.append(
289
+ document.createElement("br"),
290
+ document.createTextNode("@App")
291
+ );
292
+ // logical is now "a\n@App"; caret after "@App" (offset 6), trigger at index 2.
293
+ const id = input.insertMentionAtTrigger!(appRef, {
294
+ triggerIndex: 2,
295
+ query: "App"
296
+ });
297
+ expect(id).not.toBeNull();
298
+ expect(input.getValue()).toBe("a\n@App.tsx ");
299
+ expect(input.getLogicalText()).toBe("a\n ");
300
+ expect(tokenSpans(input.element)).toHaveLength(1);
301
+ });
302
+
303
+ it("normalizes insertParagraph to a newline (no <div>/<p> wrapping)", () => {
304
+ const { input } = make();
305
+ input.setValueWithCaret("ab", 2);
306
+ // Caret between "a" and "b".
307
+ const sel = window.getSelection()!;
308
+ const range = document.createRange();
309
+ range.setStart(input.element.firstChild!, 1);
310
+ range.collapse(true);
311
+ sel.removeAllRanges();
312
+ sel.addRange(range);
313
+ const evt = new InputEvent("beforeinput", {
314
+ inputType: "insertParagraph",
315
+ bubbles: true,
316
+ cancelable: true
317
+ });
318
+ input.element.dispatchEvent(evt);
319
+ expect(evt.defaultPrevented).toBe(true);
320
+ expect(input.getValue()).toBe("a\nb");
321
+ });
322
+
323
+ it("setValueWithCaret does not clobber a selection outside the composer (unfocused)", () => {
324
+ const { input } = make();
325
+ const outside = document.createElement("div");
326
+ outside.setAttribute("contenteditable", "true");
327
+ outside.textContent = "hello world";
328
+ document.body.appendChild(outside);
329
+ // Select "llo" in the outside element; focus is NOT in the composer.
330
+ const sel = window.getSelection()!;
331
+ const range = document.createRange();
332
+ range.setStart(outside.firstChild!, 2);
333
+ range.setEnd(outside.firstChild!, 5);
334
+ sel.removeAllRanges();
335
+ sel.addRange(range);
336
+
337
+ input.setValueWithCaret("new text", 3);
338
+
339
+ const after = window.getSelection()!.getRangeAt(0);
340
+ expect(after.startContainer).toBe(outside.firstChild);
341
+ expect(after.startOffset).toBe(2);
342
+ expect(after.endOffset).toBe(5);
343
+ });
344
+
345
+ it("setValue (intentional) focuses and places the caret at the end", () => {
346
+ const { input } = make();
347
+ input.setValue("hello");
348
+ expect(input.getValue()).toBe("hello");
349
+ expect(input.getSelection()).toEqual({ start: 5, end: 5 });
350
+ });
351
+
352
+ it("paste fallback preserves an existing token (splices at the document level)", () => {
353
+ const { input, onMentionRemoved } = make();
354
+ seedToken(input, "a ", "");
355
+ const original = document.execCommand;
356
+ // @ts-expect-error force the fallback path
357
+ document.execCommand = undefined;
358
+ try {
359
+ // Caret at the end of the document (after the token).
360
+ const sel = window.getSelection()!;
361
+ const range = document.createRange();
362
+ range.selectNodeContents(input.element);
363
+ range.collapse(false);
364
+ sel.removeAllRanges();
365
+ sel.addRange(range);
366
+ const evt = new Event("paste", { bubbles: true, cancelable: true });
367
+ Object.defineProperty(evt, "clipboardData", {
368
+ value: { getData: (t: string) => (t === "text/plain" ? "x" : "") }
369
+ });
370
+ input.element.dispatchEvent(evt);
371
+ // Seed's auto-space already separates; the paste lands after it.
372
+ expect(input.getValue()).toBe("a @App.tsx x");
373
+ expect(tokenSpans(input.element)).toHaveLength(1);
374
+ expect(onMentionRemoved).not.toHaveBeenCalled();
375
+ } finally {
376
+ document.execCommand = original;
377
+ }
378
+ });
379
+
380
+ it("replaceLogicalRange rewrites a span while preserving tokens outside it", () => {
381
+ const { input } = make();
382
+ seedToken(input, "hi ", " /look");
383
+ // logical "hi  /look": token at 3, "/look" spans [5, 10).
384
+ input.replaceLogicalRange!(5, 10, "/lookup ");
385
+ expect(tokenSpans(input.element)).toHaveLength(1); // token survived
386
+ expect(input.getValue()).toBe("hi @App.tsx /lookup ");
387
+ expect(input.getLogicalText()).toBe("hi  /lookup ");
388
+ // Caret lands just after the inserted text (logical 5 + "/lookup ".length).
389
+ expect(input.getSelection()).toEqual({ start: 13, end: 13 });
390
+ });
391
+
392
+ it("setMentionStatus surfaces an accessible title + aria-label on error and restores them", () => {
393
+ const { input } = make();
394
+ const id = seedToken(input, "", "");
395
+ const span = tokenSpans(input.element)[0];
396
+ input.setMentionStatus!(id, "error");
397
+ expect(span.getAttribute("title")).toBe("App.tsx: failed to attach context");
398
+ // The red styling is visual-only; the failure must reach AT via aria-label.
399
+ expect(span.getAttribute("aria-label")).toContain("failed to attach");
400
+ input.setMentionStatus!(id, "resolved");
401
+ expect(span.getAttribute("title")).toBe("App.tsx");
402
+ // Non-error restores the normal "{label} mention" name.
403
+ expect(span.getAttribute("aria-label")).toBe("App.tsx mention");
404
+ });
405
+
406
+ it("setMentionStatus never rewrites attributes on host-rendered custom tokens outside an error", () => {
407
+ const { input } = make({
408
+ renderToken: (ref: AgentWidgetContextMentionRef) => {
409
+ const el = document.createElement("span");
410
+ el.className = MENTION_TOKEN_CLASS;
411
+ el.setAttribute("aria-label", `custom ${ref.label}`);
412
+ // Deliberately no title and no .persona-mention-token-label child.
413
+ return el;
414
+ }
415
+ });
416
+ const id = seedToken(input, "", "");
417
+ const span = tokenSpans(input.element)[0];
418
+ // resolveOn:"select" success path fires "resolved" without a prior error:
419
+ // the host's accessible name must be left untouched.
420
+ input.setMentionStatus!(id, "resolved");
421
+ expect(span.getAttribute("aria-label")).toBe("custom App.tsx");
422
+ expect(span.hasAttribute("title")).toBe(false);
423
+ // Error overrides with the ref label (not scraped builder markup)...
424
+ input.setMentionStatus!(id, "error");
425
+ expect(span.getAttribute("aria-label")).toBe(
426
+ "App.tsx, failed to attach context"
427
+ );
428
+ expect(span.getAttribute("title")).toBe("App.tsx: failed to attach context");
429
+ // ...and recovery restores exactly the pre-error state, absent title included.
430
+ input.setMentionStatus!(id, "resolved");
431
+ expect(span.getAttribute("aria-label")).toBe("custom App.tsx");
432
+ expect(span.hasAttribute("title")).toBe(false);
433
+ });
434
+
435
+ it("setMentionStatus toggles the error class and is a no-op for unknown ids", () => {
436
+ const { input } = make();
437
+ const id = seedToken(input, "", "");
438
+ const span = tokenSpans(input.element)[0];
439
+ input.setMentionStatus!(id, "error");
440
+ expect(span.classList.contains("persona-mention-token-error")).toBe(true);
441
+ input.setMentionStatus!(id, "resolved");
442
+ expect(span.classList.contains("persona-mention-token-error")).toBe(false);
443
+ input.setMentionStatus!(id, "pending");
444
+ expect(span.classList.contains("persona-mention-token-error")).toBe(false);
445
+ // Unknown id: silent no-op.
446
+ expect(() => input.setMentionStatus!("nope", "error")).not.toThrow();
447
+ });
448
+
449
+ it("destroy() detaches listeners", () => {
450
+ const { input, onMentionRemoved } = make();
451
+ seedToken(input, "", "");
452
+ input.destroy();
453
+ tokenSpans(input.element)[0].remove();
454
+ input.element.dispatchEvent(new Event("input", { bubbles: true }));
455
+ expect(onMentionRemoved).not.toHaveBeenCalled();
456
+ });
457
+
458
+ describe("getLogicalRangeRect", () => {
459
+ it("builds a non-collapsed range around the glyph and returns its rect", () => {
460
+ const { input } = make();
461
+ input.setValueWithCaret("hi @a", 5); // single text node "hi @a"
462
+ const textNode = input.element.firstChild!;
463
+ const setStart = vi.fn();
464
+ const setEnd = vi.fn();
465
+ const fakeRect = {
466
+ left: 40,
467
+ right: 48,
468
+ top: 10,
469
+ bottom: 24,
470
+ width: 8,
471
+ height: 14
472
+ } as DOMRect;
473
+ const createRange = vi
474
+ .spyOn(document, "createRange")
475
+ .mockReturnValue({
476
+ setStart,
477
+ setEnd,
478
+ getBoundingClientRect: () => fakeRect
479
+ } as unknown as Range);
480
+
481
+ // The "@" sits at logical index 3; measure the single glyph [3, 4).
482
+ const rect = input.getLogicalRangeRect!(3, 4);
483
+
484
+ expect(rect).toBe(fakeRect);
485
+ // Endpoints are the logical→DOM mapping of both ends (non-collapsed), which
486
+ // for a single text node is (textNode, 3) and (textNode, 4).
487
+ expect(setStart).toHaveBeenCalledWith(textNode, 3);
488
+ expect(setEnd).toHaveBeenCalledWith(textNode, 4);
489
+ createRange.mockRestore();
490
+ });
491
+
492
+ it("returns null for invalid ranges", () => {
493
+ const { input } = make();
494
+ input.setValueWithCaret("hi @a", 5);
495
+ expect(input.getLogicalRangeRect!(3, 3)).toBeNull(); // empty
496
+ expect(input.getLogicalRangeRect!(4, 2)).toBeNull(); // reversed
497
+ expect(input.getLogicalRangeRect!(Number.NaN, 4)).toBeNull();
498
+ });
499
+
500
+ it("returns null for a degenerate (zero-size) rect", () => {
501
+ const { input } = make();
502
+ input.setValueWithCaret("hi @a", 5);
503
+ // jsdom's Range.getBoundingClientRect is all-zeros → treated as unmeasurable.
504
+ expect(input.getLogicalRangeRect!(3, 4)).toBeNull();
505
+ });
506
+ });
507
+ });