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