@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.
Files changed (130) hide show
  1. package/README.md +14 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
  5. package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-IO5VVUKP.js +3 -0
  9. package/dist/chunk-IPVK3KOM.js +1 -0
  10. package/dist/chunk-UPO4GUFC.js +1 -0
  11. package/dist/codegen.cjs +6 -6
  12. package/dist/codegen.js +8 -8
  13. package/dist/context-mentions-7S5KVUTG.js +169 -0
  14. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  15. package/dist/context-mentions-inline.cjs +4 -0
  16. package/dist/context-mentions-inline.d.cts +203 -0
  17. package/dist/context-mentions-inline.d.ts +203 -0
  18. package/dist/context-mentions-inline.js +4 -0
  19. package/dist/context-mentions.cjs +295 -0
  20. package/dist/context-mentions.d.cts +7025 -0
  21. package/dist/context-mentions.d.ts +7025 -0
  22. package/dist/context-mentions.js +295 -0
  23. package/dist/index.cjs +72 -64
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +634 -3
  26. package/dist/index.d.ts +634 -3
  27. package/dist/index.global.js +59 -51
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +64 -56
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js +2 -2
  32. package/dist/launcher.global.js.map +1 -1
  33. package/dist/plugin-kit.cjs +1 -1
  34. package/dist/plugin-kit.d.cts +17 -0
  35. package/dist/plugin-kit.d.ts +17 -0
  36. package/dist/plugin-kit.js +1 -1
  37. package/dist/smart-dom-reader.cjs +17 -16
  38. package/dist/smart-dom-reader.d.cts +507 -1
  39. package/dist/smart-dom-reader.d.ts +507 -1
  40. package/dist/smart-dom-reader.js +17 -16
  41. package/dist/theme-editor-preview.cjs +236 -57
  42. package/dist/theme-editor-preview.d.cts +485 -1
  43. package/dist/theme-editor-preview.d.ts +485 -1
  44. package/dist/theme-editor-preview.js +53 -47
  45. package/dist/theme-editor.cjs +7 -7
  46. package/dist/theme-editor.d.cts +473 -0
  47. package/dist/theme-editor.d.ts +473 -0
  48. package/dist/theme-editor.js +5 -5
  49. package/dist/theme-reference.cjs +1 -1
  50. package/dist/theme-reference.d.cts +2 -0
  51. package/dist/theme-reference.d.ts +2 -0
  52. package/dist/widget.css +1 -1
  53. package/package.json +15 -3
  54. package/src/client.test.ts +69 -0
  55. package/src/client.ts +65 -51
  56. package/src/components/artifact-pane.test.ts +47 -0
  57. package/src/components/artifact-pane.ts +25 -2
  58. package/src/components/composer-parts.ts +3 -12
  59. package/src/components/context-mention-button.test.ts +70 -0
  60. package/src/components/context-mention-button.ts +82 -0
  61. package/src/components/context-mention-chip.ts +134 -0
  62. package/src/components/context-mention-menu.test.ts +508 -0
  63. package/src/components/context-mention-menu.ts +0 -0
  64. package/src/components/message-bubble.test.ts +175 -0
  65. package/src/components/message-bubble.ts +177 -19
  66. package/src/components/panel.ts +7 -10
  67. package/src/context-mentions-bundle.test.ts +163 -0
  68. package/src/context-mentions-entry.ts +185 -0
  69. package/src/context-mentions-inline-entry.test.ts +136 -0
  70. package/src/context-mentions-inline-entry.ts +226 -0
  71. package/src/context-mentions-inline-loader.test.ts +30 -0
  72. package/src/context-mentions-inline-loader.ts +36 -0
  73. package/src/context-mentions-inline.ts +15 -0
  74. package/src/context-mentions-loader.ts +32 -0
  75. package/src/context-mentions.ts +16 -0
  76. package/src/index-core.ts +27 -0
  77. package/src/index-global.ts +51 -0
  78. package/src/markdown-parsers-loader.ts +35 -44
  79. package/src/plugin-kit.test.ts +40 -0
  80. package/src/plugin-kit.ts +39 -5
  81. package/src/runtime/init-update-reset.test.ts +81 -0
  82. package/src/runtime/init.test.ts +62 -0
  83. package/src/runtime/init.ts +7 -14
  84. package/src/session.mentions.test.ts +175 -0
  85. package/src/session.test.ts +52 -4
  86. package/src/session.ts +121 -5
  87. package/src/smart-dom-reader.test.ts +129 -2
  88. package/src/smart-dom-reader.ts +127 -1
  89. package/src/styles/context-mention-menu-css.ts +176 -0
  90. package/src/styles/widget.css +243 -126
  91. package/src/theme-editor/sections.ts +3 -3
  92. package/src/types/theme.ts +2 -0
  93. package/src/types.ts +542 -0
  94. package/src/ui.artifact-pane-gating.test.ts +11 -1
  95. package/src/ui.attachments-drop.test.ts +90 -0
  96. package/src/ui.header-update-stability.test.ts +149 -0
  97. package/src/ui.launcher-update-merge.test.ts +83 -0
  98. package/src/ui.mention-submit.test.ts +235 -0
  99. package/src/ui.send-button-stream-update.test.ts +69 -0
  100. package/src/ui.ts +379 -84
  101. package/src/utils/chunk-loader.test.ts +97 -0
  102. package/src/utils/chunk-loader.ts +88 -0
  103. package/src/utils/composer-contenteditable.test.ts +507 -0
  104. package/src/utils/composer-contenteditable.ts +626 -0
  105. package/src/utils/composer-document.test.ts +280 -0
  106. package/src/utils/composer-document.ts +293 -0
  107. package/src/utils/composer-history.test.ts +35 -7
  108. package/src/utils/composer-history.ts +30 -20
  109. package/src/utils/composer-input.ts +159 -0
  110. package/src/utils/config-merge.test.ts +131 -0
  111. package/src/utils/config-merge.ts +61 -0
  112. package/src/utils/context-mention-controller.test.ts +1215 -0
  113. package/src/utils/context-mention-controller.ts +1186 -0
  114. package/src/utils/context-mention-manager.test.ts +422 -0
  115. package/src/utils/context-mention-manager.ts +410 -0
  116. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  117. package/src/utils/context-mention-orchestrator.ts +348 -0
  118. package/src/utils/live-region.test.ts +108 -0
  119. package/src/utils/live-region.ts +94 -0
  120. package/src/utils/mention-channels.ts +63 -0
  121. package/src/utils/mention-llm-format.test.ts +91 -0
  122. package/src/utils/mention-llm-format.ts +79 -0
  123. package/src/utils/mention-matcher.test.ts +86 -0
  124. package/src/utils/mention-matcher.ts +221 -0
  125. package/src/utils/mention-token.ts +72 -0
  126. package/src/utils/mention-trigger.test.ts +155 -0
  127. package/src/utils/mention-trigger.ts +156 -0
  128. package/src/utils/theme.test.ts +54 -4
  129. package/src/utils/theme.ts +6 -3
  130. package/src/utils/tokens.ts +27 -11
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Context-mentions runtime entry — the HEAVY module that assembles the menu
3
+ * controller + chip manager. It is NEVER statically imported on the core path:
4
+ * the IIFE/CDN build marks it external (`tsup.global.config.ts`) and loads the
5
+ * standalone `context-mentions.js` chunk from a sibling URL only when
6
+ * `contextMentions.enabled` and the user first interacts. The core orchestrator
7
+ * (`utils/context-mention-orchestrator.ts`) reaches it through
8
+ * `context-mentions-loader.ts`. See `docs/context-mentions-plan.md`.
9
+ */
10
+
11
+ import { ContextMentionManager } from "./utils/context-mention-manager";
12
+ import {
13
+ ContextMentionController,
14
+ type InlineCommandResult,
15
+ } from "./utils/context-mention-controller";
16
+ import {
17
+ createTextareaComposerInput,
18
+ type ComposerInputCapability,
19
+ } from "./utils/composer-input";
20
+ import { createLiveRegion } from "./utils/live-region";
21
+ import type { MentionSubmitBundle } from "./utils/context-mention-manager";
22
+ import type {
23
+ AgentWidgetConfig,
24
+ AgentWidgetContextMentionConfig,
25
+ AgentWidgetContextMentionRef,
26
+ AgentWidgetMessage,
27
+ } from "./types";
28
+
29
+ export interface ContextMentionMountContext {
30
+ mentionConfig: AgentWidgetContextMentionConfig;
31
+ textarea: HTMLTextAreaElement;
32
+ /**
33
+ * Pre-built composer input surface. Provided by the inline chunk in
34
+ * `display: "inline"` mode (a contenteditable adapter that replaced the
35
+ * textarea); omitted in chip mode, where a textarea adapter is built here.
36
+ */
37
+ composerInput?: ComposerInputCapability;
38
+ /** Popover anchor — the composer form/pill (menu opens upward, full width). */
39
+ anchor: HTMLElement;
40
+ /** Chip row created by the core orchestrator. */
41
+ contextRow: HTMLElement;
42
+ getMessages: () => AgentWidgetMessage[];
43
+ getConfig: () => AgentWidgetConfig;
44
+ /**
45
+ * Widget container that hosts the mention live regions (created here, on
46
+ * engine mount, so the helper stays out of the core bundle). Announcements
47
+ * can't fire before the engine exists, so lazy creation loses nothing.
48
+ */
49
+ liveRegionHost: HTMLElement | ShadowRoot;
50
+ popoverContainer?: HTMLElement | ShadowRoot;
51
+ /**
52
+ * Reflect the affordance-button picker's open state onto the button
53
+ * (`aria-expanded`/`aria-controls`). See the controller option of the same name.
54
+ */
55
+ onPickerOpenChange?: (open: boolean, trigger: string, listboxId: string) => void;
56
+ /** Emit a `persona:mention:<event>` analytics DOM event. */
57
+ emit?: (event: string, detail: unknown) => void;
58
+ }
59
+
60
+ export interface ContextMentionEngine {
61
+ /**
62
+ * Open the menu from an affordance button as a picker (no char inserted).
63
+ * Pass the channel's `trigger` to open a specific channel (e.g. `"/"`).
64
+ */
65
+ openMenu(trigger?: string): void;
66
+ isMenuOpen(): boolean;
67
+ /** Re-parse the textarea on input and open/update/close the menu. */
68
+ handleInput(): void;
69
+ /** Returns true when the key was consumed. */
70
+ handleKeydown(event: KeyboardEvent): boolean;
71
+ hasMentions(): boolean;
72
+ removeLastChip(): boolean;
73
+ /**
74
+ * Dispatch a leading inline slash command in the composer `text` at submit
75
+ * (Slack-style). Returns null when `text` isn't an inline command. See
76
+ * {@link InlineCommandResult}.
77
+ */
78
+ dispatchInlineCommand(text: string): Promise<InlineCommandResult | null>;
79
+ collectForSubmit():
80
+ | { refs: AgentWidgetContextMentionRef[]; finalize: () => Promise<MentionSubmitBundle> }
81
+ | null;
82
+ /**
83
+ * INLINE display only. Stop tracking a mention whose token the user deleted from
84
+ * the composer (aborts its in-flight resolve). Wired to the contenteditable
85
+ * adapter's removal detection by the orchestrator.
86
+ */
87
+ untrackMention(id: string): void;
88
+ /**
89
+ * Rebind the engine to a new composer input surface (the inline swap landing
90
+ * while the engine is already live). Only the menu layer (controller) re-mounts
91
+ * on the new element — the manager, with any committed chips and their
92
+ * in-flight resolves, survives, so a mention picked pre-swap still finalizes
93
+ * into the submit payload. Any open menu closes. No-op when already bound.
94
+ */
95
+ rebindComposer(input: ComposerInputCapability): void;
96
+ clear(): void;
97
+ destroy(): void;
98
+ }
99
+
100
+ export function mountContextMentions(
101
+ ctx: ContextMentionMountContext
102
+ ): ContextMentionEngine {
103
+ // Inline mode supplies a pre-built contenteditable capability; chip mode builds
104
+ // a textarea-backed one here. Mutable: `rebindComposer` swaps it when the inline
105
+ // composer replaces the textarea while the engine is already live — closures
106
+ // below read it at call time so they always drive the current surface.
107
+ let composerInput =
108
+ ctx.composerInput ?? createTextareaComposerInput(ctx.textarea);
109
+
110
+ // Polite region for result-count + add/remove announcements; assertive region
111
+ // for resolve failures. Hosted via the shared helper so they land in the light
112
+ // DOM when the widget renders inside a Shadow DOM (see live-region.ts).
113
+ const liveRegion = createLiveRegion("polite", ctx.liveRegionHost);
114
+ const errorRegion = createLiveRegion("assertive", ctx.liveRegionHost);
115
+ const announce = (message: string): void => liveRegion.announce(message);
116
+ const announceError = (message: string): void => errorRegion.announce(message);
117
+
118
+ const manager = new ContextMentionManager({
119
+ mentionConfig: ctx.mentionConfig,
120
+ contextRow: ctx.contextRow,
121
+ getMessages: ctx.getMessages,
122
+ getConfig: ctx.getConfig,
123
+ getComposerText: () => composerInput.getValue(),
124
+ announce,
125
+ announceError,
126
+ emit: ctx.emit,
127
+ });
128
+
129
+ const buildController = (): ContextMentionController =>
130
+ new ContextMentionController({
131
+ mentionConfig: ctx.mentionConfig,
132
+ composerInput,
133
+ anchor: ctx.anchor,
134
+ getMessages: ctx.getMessages,
135
+ getConfig: ctx.getConfig,
136
+ onSelect: (source, item, args) => manager.add(source, item, args),
137
+ // Inline commit: the controller inserts the token, then tracks its resolve
138
+ // keyed by the composer id. `reportStatus` reflects the resolve outcome onto
139
+ // the live token element (error styling — inline tokens carry no chip).
140
+ onInsertMention: (id, source, item, args) =>
141
+ manager.track(id, source, item, args, (status) =>
142
+ composerInput.setMentionStatus?.(id, status)
143
+ ),
144
+ admitMention: (source, item) => manager.admit(source, item),
145
+ announce,
146
+ popoverContainer: ctx.popoverContainer,
147
+ onPickerOpenChange: ctx.onPickerOpenChange,
148
+ emit: ctx.emit,
149
+ });
150
+
151
+ let controller = buildController();
152
+
153
+ return {
154
+ openMenu: (trigger) => controller.openFromButton(trigger),
155
+ isMenuOpen: () => controller.isOpen(),
156
+ handleInput: () => controller.onInput(),
157
+ handleKeydown: (event) => controller.handleKeydown(event),
158
+ hasMentions: () => manager.hasMentions(),
159
+ removeLastChip: () => manager.removeLast(),
160
+ dispatchInlineCommand: (text) => controller.dispatchInlineCommand(text),
161
+ collectForSubmit: () =>
162
+ manager.hasMentions() ? manager.collectForSubmit() : null,
163
+ untrackMention: (id) => manager.remove(id),
164
+ rebindComposer: (input) => {
165
+ if (input === composerInput) return;
166
+ // Re-mount ONLY the menu layer on the new surface. The manager — committed
167
+ // chips and their in-flight resolves — must survive: a mention picked
168
+ // pre-swap stays a valid chip post-swap (tokens can't be retro-inserted
169
+ // into text the user already typed) and still finalizes at submit.
170
+ controller.destroy();
171
+ composerInput = input;
172
+ controller = buildController();
173
+ },
174
+ clear: () => manager.clear(),
175
+ destroy: () => {
176
+ controller.destroy();
177
+ manager.clear();
178
+ liveRegion.destroy();
179
+ errorRegion.destroy();
180
+ },
181
+ };
182
+ }
183
+
184
+ export type { MentionSubmitBundle };
185
+ export type { InlineCommandResult };
@@ -0,0 +1,136 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { mountInlineComposer } from "./context-mentions-inline-entry";
5
+ import type { AgentWidgetContextMentionRef } from "./types";
6
+
7
+ const renderToken = (ref: AgentWidgetContextMentionRef): HTMLElement => {
8
+ const el = document.createElement("span");
9
+ el.textContent = `@${ref.label}`;
10
+ return el;
11
+ };
12
+
13
+ const makeTextarea = (): HTMLTextAreaElement => {
14
+ const ta = document.createElement("textarea");
15
+ document.body.appendChild(ta);
16
+ return ta;
17
+ };
18
+
19
+ const appRef: AgentWidgetContextMentionRef = {
20
+ sourceId: "files",
21
+ itemId: "app",
22
+ label: "App.tsx",
23
+ };
24
+
25
+ describe("mountInlineComposer", () => {
26
+ it("mirrors the textarea's inline styles onto the contenteditable (no visual pop)", () => {
27
+ const ta = makeTextarea();
28
+ ta.className = "persona-composer-textarea";
29
+ ta.style.maxHeight = "60px";
30
+ ta.style.overflowY = "auto";
31
+ ta.style.fontWeight = "500";
32
+ ta.setAttribute("placeholder", "Ask about a file…");
33
+
34
+ const handle = mountInlineComposer({ textarea: ta, renderToken });
35
+ const el = handle.element;
36
+
37
+ expect(el.style.maxHeight).toBe("60px");
38
+ expect(el.style.overflowY).toBe("auto");
39
+ expect(el.style.fontWeight).toBe("500");
40
+ expect(el.className).toContain("persona-composer-textarea");
41
+ // Placeholder + accessible name mirror across.
42
+ expect(el.getAttribute("data-placeholder")).toBe("Ask about a file…");
43
+ expect(el.getAttribute("aria-label")).toBe("Ask about a file…");
44
+ handle.destroy();
45
+ });
46
+
47
+ it("maps the shimmed `placeholder` property onto data-placeholder", () => {
48
+ const ta = makeTextarea();
49
+ const handle = mountInlineComposer({ textarea: ta, renderToken });
50
+ const el = handle.element as unknown as HTMLTextAreaElement;
51
+
52
+ el.placeholder = "Type here";
53
+ expect(handle.element.getAttribute("data-placeholder")).toBe("Type here");
54
+ expect(handle.element.getAttribute("aria-label")).toBe("Type here");
55
+ expect(el.placeholder).toBe("Type here");
56
+ handle.destroy();
57
+ });
58
+
59
+ it("preserves an explicit host aria-label when placeholder updates land after swap", () => {
60
+ const ta = makeTextarea();
61
+ ta.setAttribute("aria-label", "Message support");
62
+ ta.setAttribute("placeholder", "Ask about a file…");
63
+
64
+ const handle = mountInlineComposer({ textarea: ta, renderToken });
65
+ const el = handle.element as unknown as HTMLTextAreaElement;
66
+ expect(handle.element.getAttribute("aria-label")).toBe("Message support");
67
+
68
+ // ui.ts updateCopy assigns truthy placeholder text on every config update;
69
+ // the host's accessible name must survive it.
70
+ el.placeholder = "Ask about a file…";
71
+ expect(handle.element.getAttribute("aria-label")).toBe("Message support");
72
+ expect(handle.element.getAttribute("data-placeholder")).toBe("Ask about a file…");
73
+ handle.destroy();
74
+ });
75
+
76
+ it("keeps a placeholder-derived aria-label in sync and clears it on empty", () => {
77
+ const ta = makeTextarea();
78
+ ta.setAttribute("placeholder", "Type a message");
79
+ const handle = mountInlineComposer({ textarea: ta, renderToken });
80
+ const el = handle.element as unknown as HTMLTextAreaElement;
81
+ expect(handle.element.getAttribute("aria-label")).toBe("Type a message");
82
+
83
+ el.placeholder = "First update";
84
+ expect(handle.element.getAttribute("aria-label")).toBe("First update");
85
+ el.placeholder = "Second update";
86
+ expect(handle.element.getAttribute("aria-label")).toBe("Second update");
87
+
88
+ // Empty placeholder leaves no stale accessible name behind.
89
+ el.placeholder = "";
90
+ expect(handle.element.getAttribute("aria-label")).toBeNull();
91
+ handle.destroy();
92
+ });
93
+
94
+ it("exposes ordered contentSegments via getInlineMessageFields", () => {
95
+ const ta = makeTextarea();
96
+ const handle = mountInlineComposer({ textarea: ta, renderToken });
97
+ handle.input.setValueWithCaret("Check ", 6);
98
+ handle.input.insertMentionAtSelection!(appRef);
99
+
100
+ const fields = (
101
+ handle.element as unknown as {
102
+ getInlineMessageFields: () => {
103
+ contentSegments: Array<{
104
+ kind: string;
105
+ text?: string;
106
+ ref?: { label?: string };
107
+ }>;
108
+ };
109
+ }
110
+ ).getInlineMessageFields();
111
+
112
+ const kinds = fields.contentSegments.map((s) => s.kind);
113
+ expect(kinds).toContain("mention");
114
+ const mention = fields.contentSegments.find((s) => s.kind === "mention");
115
+ expect(mention?.ref?.label).toBe("App.tsx");
116
+ handle.destroy();
117
+ });
118
+
119
+ it("untracks tokens when the shim value setter clears the composer", () => {
120
+ const ta = makeTextarea();
121
+ const onMentionRemoved = vi.fn();
122
+ const handle = mountInlineComposer({
123
+ textarea: ta,
124
+ renderToken,
125
+ onMentionRemoved,
126
+ });
127
+ const el = handle.element as unknown as HTMLTextAreaElement;
128
+ const id = handle.input.insertMentionAtSelection!(appRef);
129
+
130
+ // Programmatic `value =` (voice/clear path) drops the token DOM; the shim must
131
+ // fire the adapter's reconcile so the manager aborts that mention's resolve.
132
+ el.value = "";
133
+ expect(onMentionRemoved).toHaveBeenCalledWith(id);
134
+ handle.destroy();
135
+ });
136
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Inline-mention chunk entry — the HEAVY, DOM-bearing half of inline mode. Loaded
3
+ * on composer mount when `contextMentions.display === "inline"` and NEVER
4
+ * statically imported on the core path (the IIFE build marks
5
+ * `@runtypelabs/persona/context-mentions-inline` external; ESM/CJS mark the same
6
+ * subpath external via `build:client`). This is the only place the
7
+ * contenteditable engine + `composer-document` enter the widget at runtime.
8
+ *
9
+ * `mountInlineComposer` builds a contenteditable `ComposerInputCapability` that
10
+ * visually mirrors the live `<textarea>` and migrates its current value + caret.
11
+ * It does NOT touch the DOM tree or rebind listeners — the caller (`ui.ts`) swaps
12
+ * the element in place and re-points the composer's event handlers, because those
13
+ * live in the core and the chunk must stay free of that wiring.
14
+ */
15
+
16
+ import {
17
+ createContentEditableComposerInput,
18
+ type ContentEditableComposerInputOptions,
19
+ } from "./utils/composer-contenteditable";
20
+ import { documentToMessageFields } from "./utils/composer-document";
21
+ import type { ComposerInputCapability } from "./utils/composer-input";
22
+ import type {
23
+ AgentWidgetContentSegment,
24
+ AgentWidgetContextMentionRef,
25
+ } from "./types";
26
+
27
+ /**
28
+ * Display/transcript fields a sent inline-mode message carries, derived from the
29
+ * live composer document. Computed in THIS chunk (it owns `composer-document.ts`
30
+ * at runtime) and read by the core `ui.ts` submit path via `getInlineMessageFields`
31
+ * below, so the core never imports the document model.
32
+ */
33
+ export interface InlineComposerMessageFields {
34
+ content: string;
35
+ contextMentions: AgentWidgetContextMentionRef[];
36
+ contentSegments: AgentWidgetContentSegment[];
37
+ }
38
+
39
+ /**
40
+ * The `<textarea>`-shaped element the inline composer swaps in. Beyond the
41
+ * textarea API shims it also exposes `getInlineMessageFields()` so the core
42
+ * submit path can read the ordered document segments without importing the
43
+ * document model. `ui.ts` references this via a structural (type-only) shape.
44
+ */
45
+ export type InlineComposerElement = HTMLElement & {
46
+ getInlineMessageFields: () => InlineComposerMessageFields;
47
+ };
48
+
49
+ export interface InlineComposerMountContext {
50
+ /** The live composer textarea to migrate from (value + caret + look). */
51
+ textarea: HTMLTextAreaElement;
52
+ /**
53
+ * Build a token's full element. Supplied by the CORE orchestrator (which owns
54
+ * `createMentionTokenElement` + the shared icon renderer) so this lazy chunk
55
+ * never bundles the icon set — it already ships in core. The adapter marks the
56
+ * returned element `contenteditable="false"` and stamps `data-mention-id`.
57
+ */
58
+ renderToken: (ref: AgentWidgetContextMentionRef) => HTMLElement;
59
+ /**
60
+ * Fired when a token is deleted from the composer (backspace/cut). The caller
61
+ * routes this to `engine.untrackMention(id)` so the manager aborts its resolve.
62
+ */
63
+ onMentionRemoved?: (id: string) => void;
64
+ }
65
+
66
+ export interface InlineComposerHandle {
67
+ /** The contenteditable input surface; hand this to `mountContextMentions`. */
68
+ input: ComposerInputCapability;
69
+ /** The contenteditable root element to swap in for the textarea. */
70
+ element: HTMLElement;
71
+ /** Detach the adapter's listeners on unmount. */
72
+ destroy: () => void;
73
+ }
74
+
75
+ let idSeq = 0;
76
+ /** Page-unique mention id (composer-scoped; only needs uniqueness within a doc). */
77
+ const generateId = (): string => `pmention-${++idSeq}`;
78
+
79
+ /**
80
+ * Copy the visual hooks that make the contenteditable read like the textarea it
81
+ * replaces. The composer sets most of its look via INLINE styles at runtime (font
82
+ * vars from config, the `maxHeight`/`overflowY` auto-resize cap, border/outline
83
+ * stripping — see `composer-parts.ts` and `ui.ts` `updateCopy`), not just utility
84
+ * classes, so copy every inline style property across as well; otherwise the
85
+ * contenteditable falls back to its CSS defaults (a taller max-height, a different
86
+ * font) and visibly pops on swap. Also mirror the classes, the placeholder, and
87
+ * the accessible name.
88
+ */
89
+ function mirrorTextareaLook(
90
+ textarea: HTMLTextAreaElement,
91
+ el: HTMLElement
92
+ ): void {
93
+ el.className = `${textarea.className} persona-composer-contenteditable`.trim();
94
+ // Copy every inline style property (font*, lineHeight, maxHeight, overflowY,
95
+ // border, outline, background, padding, color, …) so the box model + typography
96
+ // match exactly. Iterating keeps this in sync as the composer's inline styles
97
+ // evolve, without re-enumerating a fixed property list here. Skip `height`: the
98
+ // textarea's auto-resize sets it to a fixed px on input, but the contenteditable
99
+ // grows with its content (capped by the copied `maxHeight`/`overflowY`), so a
100
+ // frozen height would clip it.
101
+ for (let i = 0; i < textarea.style.length; i++) {
102
+ const prop = textarea.style.item(i);
103
+ if (prop === "height") continue;
104
+ el.style.setProperty(
105
+ prop,
106
+ textarea.style.getPropertyValue(prop),
107
+ textarea.style.getPropertyPriority(prop)
108
+ );
109
+ }
110
+ const placeholder = textarea.getAttribute("placeholder");
111
+ if (placeholder) {
112
+ el.setAttribute("data-placeholder", placeholder);
113
+ // Carry the placeholder in aria-placeholder (its correct role) rather than
114
+ // overloading the accessible name with it.
115
+ el.setAttribute("aria-placeholder", placeholder);
116
+ }
117
+ // Stable accessible name: prefer an explicit host/config aria-label on the
118
+ // textarea (ui.ts stamps config input copy there); fall back to the placeholder
119
+ // text for parity with chip mode (the plain textarea's name is its placeholder)
120
+ // so inline mode never regresses to an unnamed field.
121
+ const label = textarea.getAttribute("aria-label") ?? placeholder;
122
+ if (label) el.setAttribute("aria-label", label);
123
+ }
124
+
125
+ /**
126
+ * Make the contenteditable element quack like a `<textarea>` for the specific API
127
+ * surface the host composer code touches (`value`, `selectionStart/End`,
128
+ * `setSelectionRange`). This lets `ui.ts` keep reading/writing the composer the
129
+ * same way after the swap without threading the capability through every call
130
+ * site (doSubmit clears `value`; history sets `value` + caret; the send gate
131
+ * reads `value`). Everything is delegated to the capability so the document stays
132
+ * the source of truth. NOTE: `value` writes render PLAIN TEXT (no tokens) — inline
133
+ * history recall is plain-text in v1; tokens survive only within a live session.
134
+ */
135
+ function shimTextareaApi(
136
+ el: HTMLElement,
137
+ input: ComposerInputCapability,
138
+ // True when the host stamped an explicit aria-label on the textarea. Its
139
+ // accessible name is authoritative, so placeholder writes must not clobber it.
140
+ hasHostLabel: boolean
141
+ ): void {
142
+ Object.defineProperty(el, "value", {
143
+ configurable: true,
144
+ get: () => input.getValue(),
145
+ set: (v: string) => {
146
+ input.setValueWithCaret(v, v.length);
147
+ // `setValueWithCaret` rebuilds the DOM as plain text (dropping any token
148
+ // spans) but does NOT itself run the adapter's removal reconcile. Fire an
149
+ // input event so vanished tokens untrack (the manager aborts their resolve)
150
+ // — otherwise a programmatic `value =` (voice transcript, clear, history
151
+ // recall) leaves stale resolved context tracked against the next send.
152
+ input.dispatchInput();
153
+ },
154
+ });
155
+ // The contenteditable reads its placeholder from `data-placeholder`, so a plain
156
+ // `.placeholder =` (e.g. `ui.ts` `updateCopy` after the swap, which assigns
157
+ // truthy placeholder text on every config update) would no-op. Map the textarea
158
+ // `placeholder` property onto the attribute. Only touch the accessible name when
159
+ // it is placeholder-derived — an explicit host aria-label must survive.
160
+ Object.defineProperty(el, "placeholder", {
161
+ configurable: true,
162
+ get: () => el.getAttribute("data-placeholder") ?? "",
163
+ set: (v: string) => {
164
+ if (v) {
165
+ el.setAttribute("data-placeholder", v);
166
+ el.setAttribute("aria-placeholder", v);
167
+ if (!hasHostLabel) el.setAttribute("aria-label", v);
168
+ } else {
169
+ el.removeAttribute("data-placeholder");
170
+ el.removeAttribute("aria-placeholder");
171
+ // Clear the placeholder-derived name too, so no stale label lingers.
172
+ if (!hasHostLabel) el.removeAttribute("aria-label");
173
+ }
174
+ },
175
+ });
176
+ Object.defineProperty(el, "selectionStart", {
177
+ configurable: true,
178
+ get: () => input.getSelection().start,
179
+ });
180
+ Object.defineProperty(el, "selectionEnd", {
181
+ configurable: true,
182
+ get: () => input.getSelection().end,
183
+ });
184
+ (el as unknown as HTMLTextAreaElement).setSelectionRange = (
185
+ start: number,
186
+ end: number
187
+ ) => input.setSelection(start ?? 0, end ?? start ?? 0);
188
+ }
189
+
190
+ export function mountInlineComposer(
191
+ ctx: InlineComposerMountContext
192
+ ): InlineComposerHandle {
193
+ const { textarea } = ctx;
194
+ const options: ContentEditableComposerInputOptions = {
195
+ generateId,
196
+ // Token DOM (incl. icon + any host `renderMentionToken`) is built in core and
197
+ // passed in, so this chunk stays free of the icon renderer.
198
+ renderToken: ctx.renderToken,
199
+ onMentionRemoved: ctx.onMentionRemoved,
200
+ placeholder: textarea.getAttribute("placeholder") ?? undefined,
201
+ };
202
+ const capability = createContentEditableComposerInput(options);
203
+ const el = capability.element;
204
+ // Capture before mirroring/shimming: whether the accessible name is an explicit
205
+ // host label (authoritative) or placeholder-derived (kept in sync with copy).
206
+ const hasHostLabel = textarea.getAttribute("aria-label") != null;
207
+ mirrorTextareaLook(textarea, el);
208
+ shimTextareaApi(el, capability, hasHostLabel);
209
+
210
+ // Expose the ordered document as sent-message fields for the core submit path.
211
+ // Computed here (this chunk owns `composer-document.ts`) so `ui.ts` can attach
212
+ // `contentSegments` to the user message without importing the document model.
213
+ (el as InlineComposerElement).getInlineMessageFields = () =>
214
+ documentToMessageFields(capability.getDocument?.() ?? { blocks: [] });
215
+
216
+ // Migrate current text + caret. Anything typed before the swap is plain text
217
+ // (tokens can't exist yet — the menu chunk gates selection), so this is lossless.
218
+ const caret = textarea.selectionStart ?? textarea.value.length;
219
+ capability.setValueWithCaret(textarea.value, caret);
220
+
221
+ return {
222
+ input: capability,
223
+ element: el,
224
+ destroy: capability.destroy,
225
+ };
226
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import {
3
+ loadContextMentionsInline,
4
+ setContextMentionsInlineLoader,
5
+ type ContextMentionsInlineModule,
6
+ } from "./context-mentions-inline-loader";
7
+
8
+ const fakeModule = {
9
+ mountInlineComposer: vi.fn(),
10
+ } as unknown as ContextMentionsInlineModule;
11
+
12
+ describe("loadContextMentionsInline", () => {
13
+ it("retries after a rejected load instead of caching the failure forever", async () => {
14
+ const loader = vi
15
+ .fn<() => Promise<ContextMentionsInlineModule>>()
16
+ .mockRejectedValueOnce(new Error("network"))
17
+ .mockResolvedValueOnce(fakeModule);
18
+ setContextMentionsInlineLoader(loader);
19
+
20
+ // First call surfaces the rejection to the caller.
21
+ await expect(loadContextMentionsInline()).rejects.toThrow("network");
22
+ // A later call retries (the failed promise was not cached) and resolves.
23
+ await expect(loadContextMentionsInline()).resolves.toBe(fakeModule);
24
+ expect(loader).toHaveBeenCalledTimes(2);
25
+
26
+ // Once resolved, the module is cached (no third loader call).
27
+ await expect(loadContextMentionsInline()).resolves.toBe(fakeModule);
28
+ expect(loader).toHaveBeenCalledTimes(2);
29
+ });
30
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Loader indirection for the lazy inline-mention chunk (core bundle, tiny).
3
+ *
4
+ * Sibling of `context-mentions-loader.ts`. The IIFE/CDN entry (`index-global.ts`)
5
+ * registers a loader that imports the standalone `context-mentions-inline.js`
6
+ * chunk from a sibling URL; ESM/CJS consumers fall back to the package's
7
+ * `./context-mentions-inline` subpath — which `build:client` marks external, so
8
+ * the contenteditable engine is NOT inlined into `dist/index.{js,cjs}` and stays
9
+ * out of the core bundle. (A relative `./context-mentions-inline-entry` import
10
+ * would be inlined by the no-splitting build, defeating the split.)
11
+ *
12
+ * This is loaded on composer mount when `contextMentions.display === "inline"`,
13
+ * distinct from the chip menu chunk which loads on the first `@`/click.
14
+ */
15
+
16
+ import { createChunkLoader } from "./utils/chunk-loader";
17
+ import type {
18
+ InlineComposerMountContext,
19
+ InlineComposerHandle,
20
+ } from "./context-mentions-inline-entry";
21
+
22
+ export type ContextMentionsInlineModule = {
23
+ mountInlineComposer: (ctx: InlineComposerMountContext) => InlineComposerHandle;
24
+ };
25
+
26
+ // Memoization + rejection-retry semantics live in `createChunkLoader`.
27
+ // `resetOnSetLoader` mirrors this loader's original behavior: registering a new
28
+ // loader source invalidates whatever the previous loader produced (so a
29
+ // mid-session swap for test injection takes effect deterministically).
30
+ const { setLoader, load } = createChunkLoader<ContextMentionsInlineModule>({
31
+ fallbackImport: () => import("@runtypelabs/persona/context-mentions-inline"),
32
+ resetOnSetLoader: true,
33
+ });
34
+
35
+ export const setContextMentionsInlineLoader = setLoader;
36
+ export const loadContextMentionsInline = load;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Public entry for the lazy inline-mention chunk (`dist/context-mentions-inline.js`).
3
+ *
4
+ * Loaded on composer mount when `contextMentions.display === "inline"`. Carries
5
+ * the contenteditable engine (`composer-document`, `composer-contenteditable`)
6
+ * so chip-only and feature-off sites never download it. See
7
+ * `context-mentions-inline-loader.ts` and the loader registration in
8
+ * `index-global.ts`.
9
+ */
10
+
11
+ export { mountInlineComposer } from "./context-mentions-inline-entry";
12
+ export type {
13
+ InlineComposerMountContext,
14
+ InlineComposerHandle,
15
+ } from "./context-mentions-inline-entry";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Loader indirection for the lazy context-mentions chunk (core bundle, tiny).
3
+ *
4
+ * Mirrors `markdown-parsers-loader.ts`: the IIFE/CDN entry (`index-global.ts`)
5
+ * registers a loader that imports the standalone `context-mentions.js` chunk
6
+ * from a sibling URL. ESM/CJS consumers fall back to importing the package's
7
+ * `./context-mentions` subpath — which `build:client` marks external, so the
8
+ * runtime is NOT inlined into `dist/index.{js,cjs}` and stays out of the core
9
+ * bundle until first use. (A relative `./context-mentions-entry` import would be
10
+ * inlined by the no-splitting build, defeating the whole point.)
11
+ */
12
+
13
+ import { createChunkLoader } from "./utils/chunk-loader";
14
+ import type {
15
+ ContextMentionMountContext,
16
+ ContextMentionEngine,
17
+ } from "./context-mentions-entry";
18
+
19
+ export type ContextMentionsModule = {
20
+ mountContextMentions: (ctx: ContextMentionMountContext) => ContextMentionEngine;
21
+ };
22
+
23
+ // IIFE/CDN: sibling-URL chunk via the registered loader.
24
+ // ESM/CJS fallback: the package's own `./context-mentions` subpath (external, so
25
+ // the runtime chunk is code-split out of dist/index.{js,cjs} rather than
26
+ // inlined). Memoization + rejection-retry semantics live in `createChunkLoader`.
27
+ const { setLoader, load } = createChunkLoader<ContextMentionsModule>({
28
+ fallbackImport: () => import("@runtypelabs/persona/context-mentions"),
29
+ });
30
+
31
+ export const setContextMentionsLoader = setLoader;
32
+ export const loadContextMentions = load;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Standalone context-mentions chunk (`dist/context-mentions.js`).
3
+ *
4
+ * The IIFE/CDN widget bundle marks `./context-mentions-entry` external so the
5
+ * mention runtime is excluded from the core payload; this file is built as a
6
+ * self-contained sibling chunk (`tsup.context-mentions.config.ts`) and loaded on
7
+ * demand by the loader registered in `index-global.ts`. ESM/CJS consumers import
8
+ * the entry directly. See `docs/context-mentions-plan.md`.
9
+ */
10
+
11
+ export { mountContextMentions } from "./context-mentions-entry";
12
+ export type {
13
+ ContextMentionMountContext,
14
+ ContextMentionEngine,
15
+ MentionSubmitBundle,
16
+ } from "./context-mentions-entry";