@runtypelabs/persona 4.7.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/animations/glyph-cycle.cjs +2 -2
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -2
- package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
- package/dist/animations/wipe.cjs +2 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-5EIIHQLQ.js +1 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +13 -13
- package/dist/codegen.js +14 -14
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +99 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1507 -182
- package/dist/index.d.ts +1507 -182
- package/dist/index.global.js +88 -56
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +99 -68
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +24 -24
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-reconnect-JKIJBHS5.js +1 -0
- package/dist/smart-dom-reader.cjs +18 -17
- package/dist/smart-dom-reader.d.cts +1260 -16
- package/dist/smart-dom-reader.d.ts +1260 -16
- package/dist/smart-dom-reader.js +18 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +260 -58
- package/dist/theme-editor-preview.d.cts +1234 -15
- package/dist/theme-editor-preview.d.ts +1234 -15
- package/dist/theme-editor-preview.js +87 -58
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +1226 -15
- package/dist/theme-editor.d.ts +1226 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +76 -0
- package/dist/theme-reference.d.ts +76 -0
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +2 -2
- package/dist/voice-worklet-player.js +2 -2
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +16 -5
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +255 -1
- package/src/client.ts +157 -60
- package/src/components/artifact-card.test.ts +333 -0
- package/src/components/artifact-card.ts +75 -28
- package/src/components/artifact-inline.test.ts +1328 -0
- package/src/components/artifact-inline.ts +920 -0
- package/src/components/artifact-pane.test.ts +1042 -0
- package/src/components/artifact-pane.ts +440 -131
- package/src/components/artifact-preview.test.ts +1155 -0
- package/src/components/artifact-preview.ts +994 -0
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/components/pill-composer-builder.test.ts +6 -2
- package/src/components/pill-composer-builder.ts +6 -6
- package/src/components/reasoning-bubble.ts +1 -13
- package/src/components/registry.ts +38 -3
- package/src/components/tool-bubble.ts +1 -13
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +46 -1
- package/src/index-global.ts +51 -0
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +83 -27
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/host-layout.test.ts +163 -0
- package/src/runtime/host-layout.ts +110 -7
- package/src/runtime/init.ts +18 -61
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +197 -27
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +1002 -152
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +13 -5
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +79 -0
- package/src/types.ts +1041 -17
- package/src/ui.artifact-pane-gating.test.ts +636 -0
- package/src/ui.component-directive.test.ts +104 -0
- package/src/ui.composer-bar.test.ts +60 -2
- package/src/ui.detached-panel.test.ts +1049 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +998 -159
- package/src/utils/artifact-custom-actions.ts +128 -0
- package/src/utils/artifact-display.test.ts +42 -0
- package/src/utils/artifact-display.ts +84 -0
- package/src/utils/artifact-file.test.ts +116 -0
- package/src/utils/artifact-file.ts +117 -0
- package/src/utils/artifact-gate.test.ts +112 -5
- package/src/utils/artifact-gate.ts +39 -14
- package/src/utils/artifact-loading-status.ts +55 -0
- package/src/utils/artifact-status-label.ts +190 -0
- package/src/utils/buttons.ts +7 -1
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/icons.ts +2 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/roving-tablist.test.ts +152 -0
- package/src/utils/roving-tablist.ts +111 -0
- package/src/utils/spinner.ts +45 -0
- package/src/utils/theme.test.ts +76 -4
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +118 -11
- package/src/utils/tool-loading-animation.test.ts +32 -0
- package/src/utils/tool-loading-animation.ts +24 -0
- package/dist/chunk-DFBSCFYN.js +0 -1
- package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
- package/dist/session-reconnect-U77QFUR7.js +0 -1
|
@@ -0,0 +1,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
|
+
});
|