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