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