@runtypelabs/persona 4.7.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -2
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -2
  6. package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
  7. package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
  8. package/dist/animations/wipe.cjs +2 -2
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/chunk-5EIIHQLQ.js +1 -0
  12. package/dist/chunk-IO5VVUKP.js +3 -0
  13. package/dist/chunk-IPVK3KOM.js +1 -0
  14. package/dist/chunk-UPO4GUFC.js +1 -0
  15. package/dist/codegen.cjs +13 -13
  16. package/dist/codegen.js +14 -14
  17. package/dist/context-mentions-7S5KVUTG.js +169 -0
  18. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  19. package/dist/context-mentions-inline.cjs +4 -0
  20. package/dist/context-mentions-inline.d.cts +203 -0
  21. package/dist/context-mentions-inline.d.ts +203 -0
  22. package/dist/context-mentions-inline.js +4 -0
  23. package/dist/context-mentions.cjs +295 -0
  24. package/dist/context-mentions.d.cts +7025 -0
  25. package/dist/context-mentions.d.ts +7025 -0
  26. package/dist/context-mentions.js +295 -0
  27. package/dist/index.cjs +99 -68
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1507 -182
  30. package/dist/index.d.ts +1507 -182
  31. package/dist/index.global.js +88 -56
  32. package/dist/index.global.js.map +1 -1
  33. package/dist/index.js +99 -68
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.global.js +1 -1
  36. package/dist/install.global.js.map +1 -1
  37. package/dist/launcher.global.js +3 -2
  38. package/dist/launcher.global.js.map +1 -1
  39. package/dist/markdown-parsers.js +24 -24
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +17 -0
  42. package/dist/plugin-kit.d.ts +17 -0
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  45. package/dist/runtype-tts.js +1 -1
  46. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  47. package/dist/smart-dom-reader.cjs +18 -17
  48. package/dist/smart-dom-reader.d.cts +1260 -16
  49. package/dist/smart-dom-reader.d.ts +1260 -16
  50. package/dist/smart-dom-reader.js +18 -17
  51. package/dist/testing.cjs +3 -3
  52. package/dist/testing.js +3 -3
  53. package/dist/theme-editor-preview.cjs +260 -58
  54. package/dist/theme-editor-preview.d.cts +1234 -15
  55. package/dist/theme-editor-preview.d.ts +1234 -15
  56. package/dist/theme-editor-preview.js +87 -58
  57. package/dist/theme-editor.cjs +10 -10
  58. package/dist/theme-editor.d.cts +1226 -15
  59. package/dist/theme-editor.d.ts +1226 -15
  60. package/dist/theme-editor.js +10 -10
  61. package/dist/theme-reference.cjs +1 -1
  62. package/dist/theme-reference.d.cts +76 -0
  63. package/dist/theme-reference.d.ts +76 -0
  64. package/dist/theme-reference.js +1 -1
  65. package/dist/voice-worklet-player.cjs +2 -2
  66. package/dist/voice-worklet-player.js +2 -2
  67. package/dist/webmcp-polyfill.js +2 -2
  68. package/dist/widget.css +1 -1
  69. package/package.json +16 -5
  70. package/src/artifacts-session.test.ts +178 -0
  71. package/src/client.test.ts +255 -1
  72. package/src/client.ts +157 -60
  73. package/src/components/artifact-card.test.ts +333 -0
  74. package/src/components/artifact-card.ts +75 -28
  75. package/src/components/artifact-inline.test.ts +1328 -0
  76. package/src/components/artifact-inline.ts +920 -0
  77. package/src/components/artifact-pane.test.ts +1042 -0
  78. package/src/components/artifact-pane.ts +440 -131
  79. package/src/components/artifact-preview.test.ts +1155 -0
  80. package/src/components/artifact-preview.ts +994 -0
  81. package/src/components/composer-parts.ts +3 -12
  82. package/src/components/context-mention-button.test.ts +70 -0
  83. package/src/components/context-mention-button.ts +82 -0
  84. package/src/components/context-mention-chip.ts +134 -0
  85. package/src/components/context-mention-menu.test.ts +508 -0
  86. package/src/components/context-mention-menu.ts +0 -0
  87. package/src/components/message-bubble.test.ts +175 -0
  88. package/src/components/message-bubble.ts +177 -19
  89. package/src/components/panel.ts +7 -10
  90. package/src/components/pill-composer-builder.test.ts +6 -2
  91. package/src/components/pill-composer-builder.ts +6 -6
  92. package/src/components/reasoning-bubble.ts +1 -13
  93. package/src/components/registry.ts +38 -3
  94. package/src/components/tool-bubble.ts +1 -13
  95. package/src/context-mentions-bundle.test.ts +163 -0
  96. package/src/context-mentions-entry.ts +185 -0
  97. package/src/context-mentions-inline-entry.test.ts +136 -0
  98. package/src/context-mentions-inline-entry.ts +226 -0
  99. package/src/context-mentions-inline-loader.test.ts +30 -0
  100. package/src/context-mentions-inline-loader.ts +36 -0
  101. package/src/context-mentions-inline.ts +15 -0
  102. package/src/context-mentions-loader.ts +32 -0
  103. package/src/context-mentions.ts +16 -0
  104. package/src/defaults.ts +1 -0
  105. package/src/generated/runtype-openapi-contract.ts +55 -3
  106. package/src/index-core.ts +46 -1
  107. package/src/index-global.ts +51 -0
  108. package/src/index.ts +8 -0
  109. package/src/markdown-parsers-loader.test.ts +158 -0
  110. package/src/markdown-parsers-loader.ts +83 -27
  111. package/src/plugin-kit.test.ts +40 -0
  112. package/src/plugin-kit.ts +39 -5
  113. package/src/runtime/host-layout.test.ts +163 -0
  114. package/src/runtime/host-layout.ts +110 -7
  115. package/src/runtime/init.ts +18 -61
  116. package/src/session.mentions.test.ts +175 -0
  117. package/src/session.test.ts +52 -4
  118. package/src/session.ts +197 -27
  119. package/src/smart-dom-reader.test.ts +129 -2
  120. package/src/smart-dom-reader.ts +127 -1
  121. package/src/styles/context-mention-menu-css.ts +176 -0
  122. package/src/styles/widget.css +1002 -152
  123. package/src/theme-editor/preview.ts +2 -0
  124. package/src/theme-editor/sections.test.ts +26 -1
  125. package/src/theme-editor/sections.ts +13 -5
  126. package/src/theme-reference.ts +2 -2
  127. package/src/tool-call-display-defaults.test.ts +1 -0
  128. package/src/types/theme.ts +79 -0
  129. package/src/types.ts +1041 -17
  130. package/src/ui.artifact-pane-gating.test.ts +636 -0
  131. package/src/ui.component-directive.test.ts +104 -0
  132. package/src/ui.composer-bar.test.ts +60 -2
  133. package/src/ui.detached-panel.test.ts +1049 -0
  134. package/src/ui.mention-submit.test.ts +235 -0
  135. package/src/ui.tool-display.test.ts +51 -0
  136. package/src/ui.ts +998 -159
  137. package/src/utils/artifact-custom-actions.ts +128 -0
  138. package/src/utils/artifact-display.test.ts +42 -0
  139. package/src/utils/artifact-display.ts +84 -0
  140. package/src/utils/artifact-file.test.ts +116 -0
  141. package/src/utils/artifact-file.ts +117 -0
  142. package/src/utils/artifact-gate.test.ts +112 -5
  143. package/src/utils/artifact-gate.ts +39 -14
  144. package/src/utils/artifact-loading-status.ts +55 -0
  145. package/src/utils/artifact-status-label.ts +190 -0
  146. package/src/utils/buttons.ts +7 -1
  147. package/src/utils/chunk-loader.test.ts +97 -0
  148. package/src/utils/chunk-loader.ts +88 -0
  149. package/src/utils/code-highlight.test.ts +186 -0
  150. package/src/utils/code-highlight.ts +400 -0
  151. package/src/utils/composer-contenteditable.test.ts +507 -0
  152. package/src/utils/composer-contenteditable.ts +626 -0
  153. package/src/utils/composer-document.test.ts +280 -0
  154. package/src/utils/composer-document.ts +293 -0
  155. package/src/utils/composer-history.test.ts +35 -7
  156. package/src/utils/composer-history.ts +30 -20
  157. package/src/utils/composer-input.ts +159 -0
  158. package/src/utils/context-mention-controller.test.ts +1215 -0
  159. package/src/utils/context-mention-controller.ts +1186 -0
  160. package/src/utils/context-mention-manager.test.ts +422 -0
  161. package/src/utils/context-mention-manager.ts +410 -0
  162. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  163. package/src/utils/context-mention-orchestrator.ts +348 -0
  164. package/src/utils/icons.ts +2 -0
  165. package/src/utils/live-region.test.ts +108 -0
  166. package/src/utils/live-region.ts +94 -0
  167. package/src/utils/mention-channels.ts +63 -0
  168. package/src/utils/mention-llm-format.test.ts +91 -0
  169. package/src/utils/mention-llm-format.ts +79 -0
  170. package/src/utils/mention-matcher.test.ts +86 -0
  171. package/src/utils/mention-matcher.ts +221 -0
  172. package/src/utils/mention-token.ts +72 -0
  173. package/src/utils/mention-trigger.test.ts +155 -0
  174. package/src/utils/mention-trigger.ts +156 -0
  175. package/src/utils/roving-tablist.test.ts +152 -0
  176. package/src/utils/roving-tablist.ts +111 -0
  177. package/src/utils/spinner.ts +45 -0
  178. package/src/utils/theme.test.ts +76 -4
  179. package/src/utils/theme.ts +7 -0
  180. package/src/utils/tokens.ts +118 -11
  181. package/src/utils/tool-loading-animation.test.ts +32 -0
  182. package/src/utils/tool-loading-animation.ts +24 -0
  183. package/dist/chunk-DFBSCFYN.js +0 -1
  184. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  185. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Core-bundle orchestrator for context mentions.
3
+ *
4
+ * Tiny by design: it renders the affordance button + chip row immediately (so
5
+ * the feature is discoverable before any heavy code loads), then lazy-loads and
6
+ * mounts the mention runtime on first `@`/click via `context-mentions-loader`.
7
+ * Everything heavy (controller, manager, menu, chip) lives in the lazy chunk and
8
+ * is reached only through the dynamic import — never statically. Sites that
9
+ * leave `contextMentions` off still pay a small eager cost (this orchestrator +
10
+ * the affordance button + the exported source helpers, ~3 kB gz — the same on
11
+ * ESM and the CDN); the ~15 kB runtime stays out of the core bundle until first
12
+ * use. See `docs/context-mentions-plan.md` (Bundle strategy).
13
+ */
14
+
15
+ import { createNode } from "./dom";
16
+ import { parseAnyTrigger, isMenuOpeningInput } from "./mention-trigger";
17
+ import { normalizeMentionChannels, type NormalizedMentionChannel } from "./mention-channels";
18
+ import { createMentionButton } from "../components/context-mention-button";
19
+ import { loadContextMentions } from "../context-mentions-loader";
20
+ import { loadContextMentionsInline } from "../context-mentions-inline-loader";
21
+ import { createMentionTokenElement } from "./mention-token";
22
+ import type { ComposerInputCapability } from "./composer-input";
23
+ import type {
24
+ ContextMentionEngine,
25
+ InlineCommandResult,
26
+ } from "../context-mentions-entry";
27
+ import type { MentionSubmitBundle } from "./context-mention-manager";
28
+ import type {
29
+ AgentWidgetConfig,
30
+ AgentWidgetContextMentionRef,
31
+ AgentWidgetMessage,
32
+ } from "../types";
33
+
34
+ export interface ContextMentionOrchestrator {
35
+ /**
36
+ * Affordance button wrappers to place in the composer — one per channel that
37
+ * opts into `showButton` (empty when all channels hide their button). The `@`
38
+ * channel defaults to shown; extra `/`-style channels default to hidden.
39
+ */
40
+ affordanceButtons: HTMLElement[];
41
+ /** Chip row to place above the textarea. */
42
+ contextRow: HTMLElement;
43
+ /** Call on composer `input`; pass the event's `inputType` for paste gating. */
44
+ handleInput: (inputType?: string) => void;
45
+ /** Call FIRST in the composer keydown handler; returns true when consumed. */
46
+ handleKeydown: (event: KeyboardEvent) => boolean;
47
+ isMenuOpen: () => boolean;
48
+ hasMentions: () => boolean;
49
+ collectForSubmit: () =>
50
+ | { refs: AgentWidgetContextMentionRef[]; finalize: () => Promise<MentionSubmitBundle> }
51
+ | null;
52
+ /**
53
+ * Dispatch a leading inline slash command in the composer `text` at submit
54
+ * (Slack-style). Loads the runtime if a command-channel trigger leads the text
55
+ * and it isn't loaded yet; returns null when there's no inline command.
56
+ */
57
+ takeInlineCommand: (text: string) => Promise<InlineCommandResult | null>;
58
+ clear: () => void;
59
+ /** Warm the chunk (e.g. on composer focus) so the first `@` is instant. */
60
+ prefetch: () => void;
61
+ /**
62
+ * Register a callback fired when the composer element is swapped for the inline
63
+ * contenteditable surface (`display: "inline"`), so the host can move its
64
+ * composer listeners onto the new element. Fires immediately if the swap has
65
+ * already happened; never fires in chip mode.
66
+ */
67
+ onComposerSwap: (
68
+ cb: (next: HTMLElement, prev: HTMLElement) => void
69
+ ) => void;
70
+ destroy: () => void;
71
+ }
72
+
73
+ export function createContextMentionOrchestrator(opts: {
74
+ config: AgentWidgetConfig;
75
+ textarea: HTMLTextAreaElement;
76
+ /** Popover anchor — the composer form/pill. */
77
+ anchor: HTMLElement;
78
+ getMessages: () => AgentWidgetMessage[];
79
+ /**
80
+ * Widget container that will host the mention live regions. The regions are
81
+ * created inside the lazy engine chunk on mount — never here — so the
82
+ * live-region helper stays out of the core bundle.
83
+ */
84
+ liveRegionHost: HTMLElement | ShadowRoot;
85
+ popoverContainer?: HTMLElement | ShadowRoot;
86
+ }): ContextMentionOrchestrator | null {
87
+ const mentionConfig = opts.config.contextMentions;
88
+ if (!mentionConfig?.enabled) return null;
89
+
90
+ // Normalize the primary `@` channel + any extra `triggers` channels, then drop
91
+ // channels with no sources. A config may ship ONLY extra channels (a `/`-only
92
+ // widget), leaving the default `@` channel empty — that channel must not paint
93
+ // a button or match its trigger.
94
+ const channels: NormalizedMentionChannel[] = normalizeMentionChannels(
95
+ mentionConfig
96
+ ).filter((c) => c.sources.length > 0);
97
+ if (channels.length === 0) {
98
+ if (typeof console !== "undefined") {
99
+ console.warn(
100
+ "[Persona] contextMentions.enabled is true but no sources were provided; mentions are disabled."
101
+ );
102
+ }
103
+ return null;
104
+ }
105
+
106
+ // Command channels expose `matchCommand` on their sources. A cheap eager check
107
+ // so a plain-text send never loads the runtime just to look for a command.
108
+ const commandChannels = channels.filter((c) =>
109
+ c.sources.some((s) => typeof s.matchCommand === "function")
110
+ );
111
+ const looksLikeCommand = (text: string): boolean =>
112
+ commandChannels.some((c) => {
113
+ if (!c.trigger) return false;
114
+ // Match the live trigger parser: a line-start command may lead ANY line,
115
+ // input-start only the first, anywhere the whole text (unchanged).
116
+ if (c.position === "line-start") {
117
+ return text.split("\n").some((line) => line.startsWith(c.trigger));
118
+ }
119
+ const line = c.position === "anywhere" ? text : text.split("\n")[0];
120
+ return line.startsWith(c.trigger);
121
+ });
122
+
123
+ // Analytics: `persona:mention:*` DOM events on window (opened / searched /
124
+ // selected / rejected / resolve-error). Best-effort, guarded for SSR.
125
+ const emit = (event: string, detail: unknown) => {
126
+ if (typeof window === "undefined") return;
127
+ try {
128
+ window.dispatchEvent(
129
+ new CustomEvent(`persona:mention:${event}`, { detail })
130
+ );
131
+ } catch {
132
+ /* CustomEvent unavailable — ignore */
133
+ }
134
+ };
135
+
136
+ const contextRow = createNode("div", {
137
+ className: "persona-mention-context-row",
138
+ attrs: { "data-persona-mention-context-row": "" },
139
+ });
140
+
141
+ let engine: ContextMentionEngine | null = null;
142
+ let mountPromise: Promise<ContextMentionEngine | null> | null = null;
143
+
144
+ // Inline display (`display: "inline"`): the composer is upgraded from the live
145
+ // textarea to a contenteditable surface on mount (via the lazy inline chunk).
146
+ // `composerEl` tracks whichever element is current so the eager pre-engine
147
+ // handlers read the right one; `inlineInput` is the contenteditable capability
148
+ // handed to the controller when the menu engine mounts.
149
+ let composerEl: HTMLElement = opts.textarea;
150
+ let inlineInput: ComposerInputCapability | null = null;
151
+ let inlineDestroy: (() => void) | null = null;
152
+ let swapListener:
153
+ | ((next: HTMLElement, prev: HTMLElement) => void)
154
+ | null = null;
155
+ let swapped: { next: HTMLElement; prev: HTMLElement } | null = null;
156
+
157
+ const ensureEngine = (): Promise<ContextMentionEngine | null> => {
158
+ if (engine) return Promise.resolve(engine);
159
+ if (mountPromise) return mountPromise;
160
+ const attempt = loadContextMentions()
161
+ .then((mod) => {
162
+ engine = mod.mountContextMentions({
163
+ mentionConfig,
164
+ textarea: opts.textarea,
165
+ // Inline mode hands the pre-built contenteditable capability; chip mode
166
+ // leaves this undefined and the entry builds a textarea adapter.
167
+ composerInput: inlineInput ?? undefined,
168
+ anchor: opts.anchor,
169
+ contextRow,
170
+ getMessages: opts.getMessages,
171
+ getConfig: () => opts.config,
172
+ liveRegionHost: opts.liveRegionHost,
173
+ popoverContainer: opts.popoverContainer,
174
+ onPickerOpenChange,
175
+ emit,
176
+ });
177
+ return engine;
178
+ })
179
+ .catch((err) => {
180
+ if (typeof console !== "undefined") {
181
+ console.warn("[Persona] Failed to load context mentions runtime", err);
182
+ }
183
+ // A transient chunk-load failure must not disable mentions for the whole
184
+ // session: clear the cached promise so the next trigger retries (the
185
+ // chunk loader resets its own loadPromise on rejection). Concurrent
186
+ // callers awaiting `attempt` still each resolve to null cleanly.
187
+ if (mountPromise === attempt) mountPromise = null;
188
+ return null;
189
+ });
190
+ mountPromise = attempt;
191
+ return attempt;
192
+ };
193
+
194
+ // Load the inline chunk and swap the textarea for the contenteditable surface.
195
+ // The textarea stays live during the fetch; anything typed before the swap is
196
+ // plain text (tokens require the menu chunk), so migration is lossless. A failed
197
+ // fetch simply leaves the textarea in place — inline degrades to chip behavior.
198
+ const setupInlineComposer = (): void => {
199
+ loadContextMentionsInline()
200
+ .then((mod) => {
201
+ const handle = mod.mountInlineComposer({
202
+ textarea: opts.textarea,
203
+ // Built in core (owns the icon renderer + any host token override), so
204
+ // the inline chunk never bundles the icon set.
205
+ renderToken: (ref) =>
206
+ createMentionTokenElement(ref, {
207
+ render: mentionConfig.renderMentionToken,
208
+ }),
209
+ onMentionRemoved: (id) => engine?.untrackMention(id),
210
+ });
211
+ inlineInput = handle.input;
212
+ inlineDestroy = handle.destroy;
213
+ const prev = composerEl;
214
+ prev.replaceWith(handle.element);
215
+ composerEl = handle.element;
216
+ swapped = { next: handle.element, prev };
217
+ swapListener?.(handle.element, prev);
218
+ // If the menu engine already mounted (user opened the menu before the
219
+ // inline chunk resolved), it is bound to a textarea adapter around the
220
+ // now-detached textarea — a dead menu for the session. Rebind it to the
221
+ // live contenteditable: only the menu layer re-mounts, so a mention the
222
+ // user COMMITTED pre-swap (chip + in-flight resolve) survives and still
223
+ // finalizes at submit — it stays a valid chip, since inline tokens can't
224
+ // be retro-inserted into text already typed. Rebind closes any open menu,
225
+ // which is acceptable; a dead menu (or a discarded mention) is not. When
226
+ // the mount is still in flight it may capture the pre-swap textarea
227
+ // adapter, so rebind once it lands (no-op if it picked up `inlineInput`).
228
+ if (engine) {
229
+ engine.rebindComposer(handle.input);
230
+ } else if (mountPromise) {
231
+ void mountPromise.then((e) => e?.rebindComposer(handle.input));
232
+ }
233
+ })
234
+ .catch((err) => {
235
+ if (typeof console !== "undefined") {
236
+ console.warn("[Persona] Failed to load inline mention composer", err);
237
+ }
238
+ });
239
+ };
240
+ if (mentionConfig.display === "inline") setupInlineComposer();
241
+
242
+ // One affordance button per channel that opts into `showButton`. Each opens
243
+ // ITS channel's picker (no char inserted) via the channel's trigger.
244
+ const buttonPartsList: ReturnType<typeof createMentionButton>[] = [];
245
+ // Address a button by its channel trigger so `onPickerOpenChange` can flip the
246
+ // right button's aria-expanded/aria-controls when the picker opens/closes.
247
+ const buttonByTrigger = new Map<string, HTMLButtonElement>();
248
+ for (const channel of channels) {
249
+ if (!channel.showButton) continue;
250
+ const parts = createMentionButton({
251
+ config: {
252
+ ...mentionConfig,
253
+ buttonIconName: channel.buttonIconName,
254
+ buttonTooltipText: channel.buttonTooltipText,
255
+ },
256
+ buttonSize: opts.config.sendButton?.size,
257
+ onOpen: () => {
258
+ void ensureEngine().then((e) => e?.openMenu(channel.trigger));
259
+ },
260
+ });
261
+ buttonPartsList.push(parts);
262
+ buttonByTrigger.set(channel.trigger, parts.button);
263
+ }
264
+
265
+ // Reflect the picker's open state on the affordance button that opened it: set
266
+ // aria-controls to the menu it now owns while open, and drop it on close (a
267
+ // dangling aria-controls to a torn-down listbox is invalid).
268
+ const onPickerOpenChange = (
269
+ open: boolean,
270
+ trigger: string,
271
+ listboxId: string
272
+ ): void => {
273
+ const button = buttonByTrigger.get(trigger);
274
+ if (!button) return;
275
+ button.setAttribute("aria-expanded", open ? "true" : "false");
276
+ if (open) button.setAttribute("aria-controls", listboxId);
277
+ else button.removeAttribute("aria-controls");
278
+ };
279
+
280
+ return {
281
+ affordanceButtons: buttonPartsList.map((p) => p.wrapper),
282
+ contextRow,
283
+
284
+ handleInput: (inputType) => {
285
+ if (engine) {
286
+ engine.handleInput();
287
+ return;
288
+ }
289
+ if (!isMenuOpeningInput(inputType)) return;
290
+ // `composerEl` is the textarea (chip) or the swapped contenteditable
291
+ // (inline); both expose `.value`/`.selectionStart` (the inline element via
292
+ // textarea-compatible shims), so the eager trigger check is uniform.
293
+ const el = composerEl as HTMLTextAreaElement;
294
+ const caret = el.selectionStart ?? 0;
295
+ if (parseAnyTrigger(el.value, caret, channels)) {
296
+ void ensureEngine().then((e) => e?.handleInput());
297
+ }
298
+ },
299
+
300
+ handleKeydown: (event) => {
301
+ if (engine?.isMenuOpen()) return engine.handleKeydown(event);
302
+ // Backspace-removes-last-chip is a chip-mode affordance; inline tokens are
303
+ // deleted in the contenteditable itself (which untracks via the adapter).
304
+ // Gate on the RUNTIME mode (`swapped` = the contenteditable actually mounted),
305
+ // not the config: when the inline chunk fails to load the widget degrades to
306
+ // chips while config still says "inline", and this affordance must survive.
307
+ if (
308
+ event.key === "Backspace" &&
309
+ engine?.hasMentions() &&
310
+ !swapped
311
+ ) {
312
+ const ta = composerEl as HTMLTextAreaElement;
313
+ const atStart = ta.selectionStart === 0 && ta.selectionEnd === 0;
314
+ if (ta.value.length === 0 || atStart) {
315
+ if (engine.removeLastChip()) {
316
+ event.preventDefault();
317
+ return true;
318
+ }
319
+ }
320
+ }
321
+ return false;
322
+ },
323
+
324
+ isMenuOpen: () => engine?.isMenuOpen() ?? false,
325
+ hasMentions: () => engine?.hasMentions() ?? false,
326
+ collectForSubmit: () => engine?.collectForSubmit() ?? null,
327
+ takeInlineCommand: async (text) => {
328
+ if (!looksLikeCommand(text)) return null;
329
+ const e = engine ?? (await ensureEngine());
330
+ return e?.dispatchInlineCommand(text) ?? null;
331
+ },
332
+ clear: () => engine?.clear(),
333
+ prefetch: () => {
334
+ void loadContextMentions().catch(() => {});
335
+ },
336
+ onComposerSwap: (cb) => {
337
+ swapListener = cb;
338
+ // If the swap already happened before the host registered, replay it now.
339
+ if (swapped) cb(swapped.next, swapped.prev);
340
+ },
341
+ destroy: () => {
342
+ engine?.destroy();
343
+ inlineDestroy?.();
344
+ for (const parts of buttonPartsList) parts.wrapper.remove();
345
+ contextRow.remove();
346
+ },
347
+ };
348
+ }
@@ -13,6 +13,7 @@ import {
13
13
  Check,
14
14
  Clipboard,
15
15
  ClipboardCopy,
16
+ CodeXml,
16
17
  Copy,
17
18
  File as FileIcon,
18
19
  FileCode,
@@ -162,6 +163,7 @@ const LUCIDE_ICONS = {
162
163
  "check": Check,
163
164
  "clipboard": Clipboard,
164
165
  "clipboard-copy": ClipboardCopy,
166
+ "code-xml": CodeXml,
165
167
  "copy": Copy,
166
168
  "file": FileIcon,
167
169
  "file-code": FileCode,
@@ -0,0 +1,108 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
4
+ import { createLiveRegion } from "./live-region";
5
+
6
+ describe("createLiveRegion", () => {
7
+ beforeEach(() => {
8
+ document.body.innerHTML = "";
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ it("sets politeness/atomic/role attributes for a polite region", () => {
16
+ const host = document.createElement("div");
17
+ document.body.appendChild(host);
18
+ const region = createLiveRegion("polite", host);
19
+ const el = host.querySelector<HTMLElement>("[data-persona-mention-live-region]")!;
20
+ expect(el.getAttribute("aria-live")).toBe("polite");
21
+ expect(el.getAttribute("aria-atomic")).toBe("true");
22
+ expect(el.getAttribute("role")).toBe("status");
23
+ expect(el.classList.contains("persona-sr-only")).toBe(true);
24
+ region.destroy();
25
+ });
26
+
27
+ it("uses role=alert for an assertive region", () => {
28
+ const host = document.createElement("div");
29
+ document.body.appendChild(host);
30
+ createLiveRegion("assertive", host);
31
+ const el = host.querySelector<HTMLElement>("[data-persona-mention-live-region]")!;
32
+ expect(el.getAttribute("aria-live")).toBe("assertive");
33
+ expect(el.getAttribute("role")).toBe("alert");
34
+ });
35
+
36
+ it("announce clears synchronously then sets the message on the next task", () => {
37
+ vi.useFakeTimers();
38
+ const host = document.createElement("div");
39
+ document.body.appendChild(host);
40
+ const region = createLiveRegion("polite", host);
41
+ const el = host.querySelector<HTMLElement>("[data-persona-mention-live-region]")!;
42
+ region.announce("2 results");
43
+ // Cleared synchronously; the message lands only after the macrotask runs.
44
+ expect(el.textContent).toBe("");
45
+ vi.runAllTimers();
46
+ expect(el.textContent).toBe("2 results");
47
+ });
48
+
49
+ it("identical consecutive announcements each produce a fresh write", () => {
50
+ vi.useFakeTimers();
51
+ const host = document.createElement("div");
52
+ document.body.appendChild(host);
53
+ const region = createLiveRegion("polite", host);
54
+ const el = host.querySelector<HTMLElement>("[data-persona-mention-live-region]")!;
55
+
56
+ region.announce("2 results");
57
+ vi.runAllTimers();
58
+ expect(el.textContent).toBe("2 results");
59
+
60
+ // A repeat must clear first so the a11y tree re-serializes the reset.
61
+ region.announce("2 results");
62
+ expect(el.textContent).toBe("");
63
+ vi.runAllTimers();
64
+ expect(el.textContent).toBe("2 results");
65
+ });
66
+
67
+ it("a rapid second announce supersedes the first (old message never lands)", () => {
68
+ vi.useFakeTimers();
69
+ const host = document.createElement("div");
70
+ document.body.appendChild(host);
71
+ const region = createLiveRegion("polite", host);
72
+ const el = host.querySelector<HTMLElement>("[data-persona-mention-live-region]")!;
73
+
74
+ region.announce("2 results");
75
+ region.announce("5 results");
76
+ vi.runAllTimers();
77
+ // Only the newest message is ever written.
78
+ expect(el.textContent).toBe("5 results");
79
+ });
80
+
81
+ it("hosts in the light DOM (document.body) with inline sr-only styles when the host is shadow-rooted", () => {
82
+ const shell = document.createElement("div");
83
+ document.body.appendChild(shell);
84
+ const shadow = shell.attachShadow({ mode: "open" });
85
+ const inner = document.createElement("div");
86
+ shadow.appendChild(inner);
87
+
88
+ createLiveRegion("polite", inner);
89
+ // Not inside the shadow root…
90
+ expect(shadow.querySelector("[data-persona-mention-live-region]")).toBeNull();
91
+ // …but on document.body, with inline visually-hidden styling.
92
+ const el = document.body.querySelector<HTMLElement>(
93
+ "[data-persona-mention-live-region]"
94
+ )!;
95
+ expect(el.parentNode).toBe(document.body);
96
+ expect(el.style.position).toBe("absolute");
97
+ expect(el.style.width).toBe("1px");
98
+ });
99
+
100
+ it("destroy removes the node", () => {
101
+ const host = document.createElement("div");
102
+ document.body.appendChild(host);
103
+ const region = createLiveRegion("polite", host);
104
+ expect(host.querySelector("[data-persona-mention-live-region]")).not.toBeNull();
105
+ region.destroy();
106
+ expect(host.querySelector("[data-persona-mention-live-region]")).toBeNull();
107
+ });
108
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Reusable ARIA live-region helper for screen-reader announcements.
3
+ *
4
+ * Politeness maps to a matching role so assistive tech treats each region
5
+ * correctly: `"polite"` → `role="status"` (queued behind current speech),
6
+ * `"assertive"` → `role="alert"` (interrupts). `aria-atomic="true"` makes the
7
+ * whole message re-read on every update rather than just the changed text node.
8
+ *
9
+ * Shadow-DOM hosting (per the mention-a11y decision): a visually-hidden live
10
+ * region nested inside a shadow root is inconsistently surfaced by assistive
11
+ * technologies, so when the resolved host lives inside a Shadow DOM we host the
12
+ * region in the LIGHT DOM (`document.body`) instead and apply the sr-only styles
13
+ * inline (the widget's prefixed `widget.css` may not reach `document.body`).
14
+ * Outside a shadow root the region stays in the given host and relies on the
15
+ * `persona-sr-only` class.
16
+ *
17
+ * NOTE (pre-existing limitation): this helper is currently only wired for the
18
+ * context-mention live regions. The widget's GENERAL live region (`ui.ts`,
19
+ * `data-persona-live-region`) still renders inside the shadow root under
20
+ * `useShadowDom` and is intentionally left untouched here — narrowing the fix to
21
+ * the mention regions keeps the change surface small.
22
+ */
23
+
24
+ /** Visually-hidden styling applied inline when hosting on `document.body`. */
25
+ const SR_ONLY_STYLE: Partial<CSSStyleDeclaration> = {
26
+ position: "absolute",
27
+ width: "1px",
28
+ height: "1px",
29
+ margin: "-1px",
30
+ padding: "0",
31
+ overflow: "hidden",
32
+ clip: "rect(0 0 0 0)",
33
+ clipPath: "inset(50%)",
34
+ whiteSpace: "nowrap",
35
+ border: "0",
36
+ };
37
+
38
+ export type LiveRegionPoliteness = "polite" | "assertive";
39
+
40
+ export interface LiveRegion {
41
+ /** Re-announce `message`; clears first so identical repeats still speak. */
42
+ announce: (message: string) => void;
43
+ /** Remove the region node from the DOM. */
44
+ destroy: () => void;
45
+ }
46
+
47
+ /**
48
+ * Create an ARIA live region hosted per the Shadow-DOM decision above.
49
+ * `host` is the element (or shadow root) the widget renders into; the helper
50
+ * inspects its root node to decide between light-DOM and in-host placement.
51
+ */
52
+ export function createLiveRegion(
53
+ politeness: LiveRegionPoliteness,
54
+ host: HTMLElement | ShadowRoot
55
+ ): LiveRegion {
56
+ const el = document.createElement("div");
57
+ el.className = "persona-sr-only";
58
+ el.setAttribute("aria-live", politeness);
59
+ el.setAttribute("aria-atomic", "true");
60
+ el.setAttribute("role", politeness === "assertive" ? "alert" : "status");
61
+ el.setAttribute("data-persona-mention-live-region", "");
62
+
63
+ const root = host.getRootNode();
64
+ const inShadow =
65
+ typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot;
66
+ if (inShadow) {
67
+ // Host in the light DOM with inline sr-only styles (widget.css may not reach
68
+ // document.body).
69
+ Object.assign(el.style, SR_ONLY_STYLE);
70
+ document.body.appendChild(el);
71
+ } else {
72
+ host.appendChild(el);
73
+ }
74
+
75
+ // Clear and set must land in separate tasks: browsers serialize the a11y tree
76
+ // once per task, so a synchronous clear+set hides the reset and identical
77
+ // repeats never re-announce.
78
+ let pendingSet: ReturnType<typeof setTimeout> | undefined;
79
+
80
+ return {
81
+ announce: (message: string) => {
82
+ if (pendingSet !== undefined) clearTimeout(pendingSet);
83
+ el.textContent = "";
84
+ pendingSet = setTimeout(() => {
85
+ pendingSet = undefined;
86
+ el.textContent = message;
87
+ }, 0);
88
+ },
89
+ destroy: () => {
90
+ if (pendingSet !== undefined) clearTimeout(pendingSet);
91
+ el.remove();
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared normalization of the public `contextMentions` config into trigger
3
+ * channels. Both the core orchestrator (which paints affordance buttons) and the
4
+ * lazy controller (which drives the menu/search) derive their channel list from
5
+ * here, so the field-mapping and per-channel defaults live in exactly one place.
6
+ *
7
+ * Type-only imports of the public config types keep this module runtime-pure
8
+ * (no DOM), matching `mention-trigger.ts`.
9
+ */
10
+
11
+ import type {
12
+ AgentWidgetContextMentionConfig,
13
+ AgentWidgetContextMentionSource,
14
+ } from "../types";
15
+ import type { MentionTriggerPosition } from "./mention-trigger";
16
+
17
+ /**
18
+ * A trigger channel normalized from config: the primary `@` channel (from the
19
+ * legacy top-level fields) followed by any extra `triggers` channels. This is a
20
+ * superset of what each consumer needs — the orchestrator reads the button
21
+ * fields, the controller reads sources/search — and satisfies `MentionTriggerSpec`
22
+ * so it can be passed straight to `parseAnyTrigger`. Channels are NOT filtered
23
+ * here; each caller drops empty ones per its own policy.
24
+ */
25
+ export type NormalizedMentionChannel = {
26
+ trigger: string;
27
+ position: MentionTriggerPosition;
28
+ allowSpaces: boolean;
29
+ sources: AgentWidgetContextMentionSource[];
30
+ searchPlaceholder?: string;
31
+ /** Primary defaults to shown; extra channels default to hidden (typed-only). */
32
+ showButton: boolean;
33
+ buttonIconName?: string;
34
+ buttonTooltipText?: string;
35
+ };
36
+
37
+ export function normalizeMentionChannels(
38
+ cfg: AgentWidgetContextMentionConfig
39
+ ): NormalizedMentionChannel[] {
40
+ const primary: NormalizedMentionChannel = {
41
+ trigger: cfg.trigger ?? "@",
42
+ position: cfg.triggerPosition ?? "anywhere",
43
+ allowSpaces: false,
44
+ sources: Array.isArray(cfg.sources) ? cfg.sources : [],
45
+ searchPlaceholder: cfg.searchPlaceholder,
46
+ showButton: cfg.showButton !== false,
47
+ buttonIconName: cfg.buttonIconName,
48
+ buttonTooltipText: cfg.buttonTooltipText,
49
+ };
50
+ const extra: NormalizedMentionChannel[] = (cfg.triggers ?? []).map((ch) => ({
51
+ trigger: ch.trigger,
52
+ position: ch.triggerPosition ?? "anywhere",
53
+ allowSpaces: ch.allowSpaces ?? false,
54
+ sources: Array.isArray(ch.sources) ? ch.sources : [],
55
+ searchPlaceholder: ch.searchPlaceholder,
56
+ // Extra channels (e.g. `/`) default to NO button to keep the composer's
57
+ // action cluster uncluttered — typed-trigger only unless opted in.
58
+ showButton: ch.showButton === true,
59
+ buttonIconName: ch.buttonIconName,
60
+ buttonTooltipText: ch.buttonTooltipText,
61
+ }));
62
+ return [primary, ...extra];
63
+ }