@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,626 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contenteditable adapter — a `ComposerInputCapability` backed by a
|
|
3
|
+
* `contenteditable` surface with atomic inline mention tokens (inline mode,
|
|
4
|
+
* `display: "inline"`). This is the HEAVY, DOM-bearing half of the inline
|
|
5
|
+
* composer; it lives in the separate `context-mentions-inline.js` chunk and is
|
|
6
|
+
* the only place `composer-document.ts` is used at runtime on the widget path.
|
|
7
|
+
*
|
|
8
|
+
* Sync strategy (decided in docs/context-mentions-inline-plan.md — do not
|
|
9
|
+
* re-litigate): the DOM is the editing surface and the document is DERIVED by
|
|
10
|
+
* re-parsing, never the other way around.
|
|
11
|
+
*
|
|
12
|
+
* 1. Plain typing is handled natively inside text nodes — no `beforeinput`
|
|
13
|
+
* interception for text (fighting the caret/undo/IME machinery is where
|
|
14
|
+
* contenteditable projects die).
|
|
15
|
+
* 2. Reads (`getDocument`/`getLogicalText`/`getValue`/`getSelection`) re-parse
|
|
16
|
+
* the DOM live: `data-mention-id` spans → mention blocks (ref looked up from
|
|
17
|
+
* `refs`, never parsed from DOM text); everything else → text. Cheap at
|
|
18
|
+
* composer size, and it removes any chance of stale state after native edits.
|
|
19
|
+
* 3. `beforeinput` is intercepted ONLY to make token deletion atomic when the
|
|
20
|
+
* caret is adjacent to a token; `contenteditable="false"` on the span handles
|
|
21
|
+
* arrow/selection atomicity natively.
|
|
22
|
+
* 4. IME: never mutate between `compositionstart`/`compositionend`.
|
|
23
|
+
* 5. Programmatic edits render the document, restore the caret from a logical
|
|
24
|
+
* offset, then dispatch `input`.
|
|
25
|
+
* 6. Paste inserts `text/plain` only (rich token paste across composers is a
|
|
26
|
+
* non-goal — a pasted `@App.tsx` is just text).
|
|
27
|
+
*
|
|
28
|
+
* Token removal (by backspace, cut, or select-all-delete) is detected uniformly
|
|
29
|
+
* by reconciling `refs` against the DOM on `input` and firing `onMentionRemoved`
|
|
30
|
+
* for each id that vanished — so the manager aborts that mention's resolve.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
emptyDocument,
|
|
35
|
+
documentFromTextarea,
|
|
36
|
+
toDisplayText,
|
|
37
|
+
toLogicalText,
|
|
38
|
+
logicalLength,
|
|
39
|
+
insertMention,
|
|
40
|
+
removeMention,
|
|
41
|
+
spliceDocument,
|
|
42
|
+
MENTION_PLACEHOLDER,
|
|
43
|
+
type ComposerDocument,
|
|
44
|
+
type ComposerMentionId
|
|
45
|
+
} from "./composer-document";
|
|
46
|
+
import type { MentionTriggerMatch } from "./mention-trigger";
|
|
47
|
+
import { requestFormSubmit, type ComposerInputCapability } from "./composer-input";
|
|
48
|
+
import type { AgentWidgetContextMentionRef } from "../types";
|
|
49
|
+
|
|
50
|
+
export const MENTION_TOKEN_CLASS = "persona-mention-token";
|
|
51
|
+
const MENTION_TOKEN_ERROR_CLASS = "persona-mention-token-error";
|
|
52
|
+
const MENTION_ID_ATTR = "data-mention-id";
|
|
53
|
+
// Pre-error aria-label/title stash, so error recovery can restore host-rendered
|
|
54
|
+
// tokens (renderMentionToken) exactly as they were (see setMentionStatus).
|
|
55
|
+
const ERROR_PREV_LABEL_ATTR = "data-mention-prev-label";
|
|
56
|
+
const ERROR_PREV_TITLE_ATTR = "data-mention-prev-title";
|
|
57
|
+
const NEWLINE = "\n";
|
|
58
|
+
|
|
59
|
+
export interface ContentEditableComposerInputOptions {
|
|
60
|
+
/** Generate a stable id for a newly inserted mention (manager/uuid-backed). */
|
|
61
|
+
generateId: () => ComposerMentionId;
|
|
62
|
+
/**
|
|
63
|
+
* Build a token's full element. Supplied by the core orchestrator (which owns
|
|
64
|
+
* the shared pill + icon renderer). The adapter marks the returned element
|
|
65
|
+
* `contenteditable="false"` and stamps `data-mention-id`.
|
|
66
|
+
*/
|
|
67
|
+
renderToken: (ref: AgentWidgetContextMentionRef) => HTMLElement;
|
|
68
|
+
/** Fired when a token is inserted — the manager starts resolve keyed by id. */
|
|
69
|
+
onMentionInserted?: (
|
|
70
|
+
id: ComposerMentionId,
|
|
71
|
+
ref: AgentWidgetContextMentionRef
|
|
72
|
+
) => void;
|
|
73
|
+
/** Fired when a token disappears from the DOM — the manager aborts its resolve. */
|
|
74
|
+
onMentionRemoved?: (id: ComposerMentionId) => void;
|
|
75
|
+
/** Placeholder text shown while the surface is empty. */
|
|
76
|
+
placeholder?: string;
|
|
77
|
+
/** Existing element to upgrade in place; otherwise a fresh `<div>` is created. */
|
|
78
|
+
element?: HTMLElement;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isMentionSpan(node: Node): node is HTMLElement {
|
|
82
|
+
return (
|
|
83
|
+
node.nodeType === 1 &&
|
|
84
|
+
(node as HTMLElement).hasAttribute(MENTION_ID_ATTR)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A `<br>` the browser inserts on Shift+Enter / line-break input. */
|
|
89
|
+
function isLineBreak(node: Node): boolean {
|
|
90
|
+
return node.nodeType === 1 && (node as HTMLElement).tagName === "BR";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Logical length one DOM child contributes: text nodes count their chars, a
|
|
95
|
+
* mention token is one ``, and a `<br>` is one `\n` (native line breaks read back
|
|
96
|
+
* as newlines). Any other wrapper counts its `textContent`.
|
|
97
|
+
*/
|
|
98
|
+
function childLogicalLength(node: Node): number {
|
|
99
|
+
if (node.nodeType === 3) return (node as Text).data.length;
|
|
100
|
+
if (isMentionSpan(node)) return 1;
|
|
101
|
+
if (isLineBreak(node)) return 1;
|
|
102
|
+
return node.textContent?.length ?? 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Map a DOM position (node + offset within it) to a logical-text offset. Exported
|
|
107
|
+
* for unit tests — it's the load-bearing caret math and jsdom's Selection is too
|
|
108
|
+
* thin to exercise it end-to-end.
|
|
109
|
+
*/
|
|
110
|
+
export function domPositionToLogical(
|
|
111
|
+
root: HTMLElement,
|
|
112
|
+
node: Node,
|
|
113
|
+
offset: number
|
|
114
|
+
): number {
|
|
115
|
+
let logical = 0;
|
|
116
|
+
for (const child of Array.from(root.childNodes)) {
|
|
117
|
+
if (child === node) {
|
|
118
|
+
if (child.nodeType === 3) return logical + offset;
|
|
119
|
+
// The caret is on the root's child list at this element: offset 0 = before.
|
|
120
|
+
return logical + (offset > 0 ? childLogicalLength(child) : 0);
|
|
121
|
+
}
|
|
122
|
+
if (child.contains(node)) {
|
|
123
|
+
// Caret nested inside this child. A token is atomic — clamp to its edges;
|
|
124
|
+
// any other wrapper (foreign paste) counts its text up to the target.
|
|
125
|
+
if (isMentionSpan(child)) return logical + 1;
|
|
126
|
+
return logical + nestedTextOffset(child, node, offset);
|
|
127
|
+
}
|
|
128
|
+
logical += childLogicalLength(child);
|
|
129
|
+
}
|
|
130
|
+
// `node` is the root itself: offset counts child slots.
|
|
131
|
+
if (node === root) {
|
|
132
|
+
let logicalAt = 0;
|
|
133
|
+
const kids = Array.from(root.childNodes);
|
|
134
|
+
for (let i = 0; i < Math.min(offset, kids.length); i++) {
|
|
135
|
+
logicalAt += childLogicalLength(kids[i]);
|
|
136
|
+
}
|
|
137
|
+
return logicalAt;
|
|
138
|
+
}
|
|
139
|
+
return logical;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Text length inside `container` up to (`target`, `offset`). */
|
|
143
|
+
function nestedTextOffset(container: Node, target: Node, offset: number): number {
|
|
144
|
+
let count = 0;
|
|
145
|
+
const walk = (node: Node): boolean => {
|
|
146
|
+
if (node === target) {
|
|
147
|
+
count += node.nodeType === 3 ? offset : 0;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (node.nodeType === 3) {
|
|
151
|
+
count += (node as Text).data.length;
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
for (const child of Array.from(node.childNodes)) {
|
|
155
|
+
if (walk(child)) return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
};
|
|
159
|
+
walk(container);
|
|
160
|
+
return count;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Map a logical-text offset to a DOM position (node + offset) for caret
|
|
165
|
+
* restoration. Exported for unit tests. Tokens are atomic, so an offset landing
|
|
166
|
+
* on a token resolves to a root-level position immediately before or after it.
|
|
167
|
+
*/
|
|
168
|
+
export function logicalToDomPosition(
|
|
169
|
+
root: HTMLElement,
|
|
170
|
+
offset: number
|
|
171
|
+
): { node: Node; offset: number } {
|
|
172
|
+
let acc = 0;
|
|
173
|
+
const kids = Array.from(root.childNodes);
|
|
174
|
+
for (let i = 0; i < kids.length; i++) {
|
|
175
|
+
const child = kids[i];
|
|
176
|
+
const len = childLogicalLength(child);
|
|
177
|
+
if (offset <= acc + len) {
|
|
178
|
+
if (child.nodeType === 3) return { node: child, offset: offset - acc };
|
|
179
|
+
// Element (token / wrapper): before it when at its start, else after it.
|
|
180
|
+
return { node: root, offset: offset <= acc ? i : i + 1 };
|
|
181
|
+
}
|
|
182
|
+
acc += len;
|
|
183
|
+
}
|
|
184
|
+
return { node: root, offset: kids.length };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Read the DOM subtree into a `ComposerDocument`. `data-mention-id` spans become
|
|
189
|
+
* mention blocks (ref from `refs`); text nodes become text blocks; any other node
|
|
190
|
+
* contributes its `textContent` as text. Unknown ids are dropped (a token whose
|
|
191
|
+
* ref we lost is treated as gone).
|
|
192
|
+
*/
|
|
193
|
+
function parseDomToDocument(
|
|
194
|
+
root: HTMLElement,
|
|
195
|
+
refs: Map<ComposerMentionId, AgentWidgetContextMentionRef>
|
|
196
|
+
): ComposerDocument {
|
|
197
|
+
const blocks: ComposerDocument["blocks"] = [];
|
|
198
|
+
for (const child of Array.from(root.childNodes)) {
|
|
199
|
+
if (isMentionSpan(child)) {
|
|
200
|
+
const id = child.getAttribute(MENTION_ID_ATTR)!;
|
|
201
|
+
const ref = refs.get(id);
|
|
202
|
+
if (ref) blocks.push({ kind: "mention", id, ref });
|
|
203
|
+
} else if (isLineBreak(child)) {
|
|
204
|
+
// A native line break (Shift+Enter) reads back as a newline so caret math,
|
|
205
|
+
// getValue(), and line-start `/command` detection all stay aligned.
|
|
206
|
+
blocks.push({ kind: "text", value: NEWLINE });
|
|
207
|
+
} else if (child.nodeType === 3) {
|
|
208
|
+
blocks.push({ kind: "text", value: (child as Text).data });
|
|
209
|
+
} else {
|
|
210
|
+
const text = child.textContent ?? "";
|
|
211
|
+
if (text) blocks.push({ kind: "text", value: text });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (blocks.length === 0) blocks.push({ kind: "text", value: "" });
|
|
215
|
+
return { blocks };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Create a contenteditable-backed `ComposerInputCapability`. The returned object
|
|
220
|
+
* additionally carries `destroy()` to detach listeners on unmount.
|
|
221
|
+
*/
|
|
222
|
+
export function createContentEditableComposerInput(
|
|
223
|
+
options: ContentEditableComposerInputOptions
|
|
224
|
+
): ComposerInputCapability & { destroy: () => void } {
|
|
225
|
+
const renderToken = options.renderToken;
|
|
226
|
+
const refs = new Map<ComposerMentionId, AgentWidgetContextMentionRef>();
|
|
227
|
+
|
|
228
|
+
const root = options.element ?? document.createElement("div");
|
|
229
|
+
root.setAttribute("contenteditable", "true");
|
|
230
|
+
root.setAttribute("data-persona-composer-input", "");
|
|
231
|
+
root.setAttribute("role", "textbox");
|
|
232
|
+
root.setAttribute("aria-multiline", "true");
|
|
233
|
+
root.classList.add("persona-composer-contenteditable");
|
|
234
|
+
if (options.placeholder != null) {
|
|
235
|
+
root.setAttribute("data-placeholder", options.placeholder);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let isComposing = false;
|
|
239
|
+
// Last logical caret we intended. Mirrors a textarea's `selectionStart` after a
|
|
240
|
+
// `.value =` write: it survives even when the composer is unfocused (so we don't
|
|
241
|
+
// touch the host-page selection) and backs `getSelection()` when no live range
|
|
242
|
+
// sits inside the surface.
|
|
243
|
+
let logicalCaret = 0;
|
|
244
|
+
|
|
245
|
+
const getDocument = (): ComposerDocument => parseDomToDocument(root, refs);
|
|
246
|
+
|
|
247
|
+
/** Re-render the document; empty text blocks are skipped (caret sits at the
|
|
248
|
+
* root child boundary), so an empty document leaves the root `:empty` for the
|
|
249
|
+
* placeholder. Refs are (re)registered as tokens are painted. */
|
|
250
|
+
const render = (doc: ComposerDocument): void => {
|
|
251
|
+
root.replaceChildren();
|
|
252
|
+
for (const block of doc.blocks) {
|
|
253
|
+
if (block.kind === "text") {
|
|
254
|
+
if (block.value.length > 0) {
|
|
255
|
+
root.appendChild(document.createTextNode(block.value));
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
refs.set(block.id, block.ref);
|
|
259
|
+
// The token element is the atomic node: mark it non-editable and stamp its
|
|
260
|
+
// id (used by re-parse + removal reconcile), regardless of who rendered it.
|
|
261
|
+
const el = renderToken(block.ref);
|
|
262
|
+
el.setAttribute("contenteditable", "false");
|
|
263
|
+
el.setAttribute(MENTION_ID_ATTR, block.id);
|
|
264
|
+
root.appendChild(el);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** Does the composer currently own the document/shadow-root selection focus? */
|
|
270
|
+
const ownsFocus = (): boolean => {
|
|
271
|
+
const activeRoot = root.getRootNode() as Document | ShadowRoot;
|
|
272
|
+
const active = activeRoot.activeElement;
|
|
273
|
+
return active === root || (active != null && root.contains(active));
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Place the collapsed caret at a logical offset. By default this only touches
|
|
278
|
+
* the document selection when the composer OWNS focus — a programmatic write
|
|
279
|
+
* (mount migration, `.value` shim) must never wipe a selection the user is
|
|
280
|
+
* making elsewhere on the host page. Pass `{ focus: true }` for flows that
|
|
281
|
+
* intentionally drive the caret while focused (mention insertion, paste).
|
|
282
|
+
*/
|
|
283
|
+
const setCaret = (offset: number, opts?: { focus?: boolean }): void => {
|
|
284
|
+
logicalCaret = offset; // remember intent even when we don't touch the DOM
|
|
285
|
+
if (!opts?.focus && !ownsFocus()) return;
|
|
286
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
287
|
+
if (!sel) return;
|
|
288
|
+
const pos = logicalToDomPosition(root, offset);
|
|
289
|
+
try {
|
|
290
|
+
const range = document.createRange();
|
|
291
|
+
range.setStart(pos.node, pos.offset);
|
|
292
|
+
range.collapse(true);
|
|
293
|
+
sel.removeAllRanges();
|
|
294
|
+
sel.addRange(range);
|
|
295
|
+
} catch {
|
|
296
|
+
/* selection unavailable (SSR/jsdom edge) — ignore */
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const dispatchInput = (): void => {
|
|
301
|
+
root.dispatchEvent(new Event("input", { bubbles: true }));
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const getSelection = (): { start: number; end: number } => {
|
|
305
|
+
// No live range inside the surface (unfocused / focus elsewhere): fall back to
|
|
306
|
+
// the last intended caret, mirroring a textarea's persisted `selectionStart`.
|
|
307
|
+
const fallback = { start: logicalCaret, end: logicalCaret };
|
|
308
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
309
|
+
if (!sel || sel.rangeCount === 0) return fallback;
|
|
310
|
+
const range = sel.getRangeAt(0);
|
|
311
|
+
if (!root.contains(range.startContainer)) return fallback;
|
|
312
|
+
const start = domPositionToLogical(
|
|
313
|
+
root,
|
|
314
|
+
range.startContainer,
|
|
315
|
+
range.startOffset
|
|
316
|
+
);
|
|
317
|
+
const end = range.collapsed
|
|
318
|
+
? start
|
|
319
|
+
: domPositionToLogical(root, range.endContainer, range.endOffset);
|
|
320
|
+
return { start: Math.min(start, end), end: Math.max(start, end) };
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/** Fire `onMentionRemoved` for any tracked token no longer present in the DOM. */
|
|
324
|
+
const reconcileRemovedMentions = (): void => {
|
|
325
|
+
const present = new Set<string>();
|
|
326
|
+
for (const child of Array.from(root.childNodes)) {
|
|
327
|
+
if (isMentionSpan(child)) present.add(child.getAttribute(MENTION_ID_ATTR)!);
|
|
328
|
+
}
|
|
329
|
+
for (const id of Array.from(refs.keys())) {
|
|
330
|
+
if (!present.has(id)) {
|
|
331
|
+
refs.delete(id);
|
|
332
|
+
options.onMentionRemoved?.(id);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const onInput = (): void => {
|
|
338
|
+
if (isComposing) return;
|
|
339
|
+
if (refs.size === 0) return; // no tracked tokens → nothing to reconcile
|
|
340
|
+
reconcileRemovedMentions();
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const onCompositionStart = (): void => {
|
|
344
|
+
isComposing = true;
|
|
345
|
+
};
|
|
346
|
+
const onCompositionEnd = (): void => {
|
|
347
|
+
isComposing = false;
|
|
348
|
+
reconcileRemovedMentions();
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const onPaste = (event: ClipboardEvent): void => {
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
const text = event.clipboardData?.getData("text/plain") ?? "";
|
|
354
|
+
if (typeof document.execCommand === "function") {
|
|
355
|
+
document.execCommand("insertText", false, text);
|
|
356
|
+
} else {
|
|
357
|
+
// Fallback: splice at the DOCUMENT-MODEL level so tokens outside the pasted
|
|
358
|
+
// range survive (a plain-text splice would turn every `` into a literal
|
|
359
|
+
// character and orphan its mention). Paste runs focused → keep the caret.
|
|
360
|
+
const { start, end } = getSelection();
|
|
361
|
+
const result = spliceDocument(getDocument(), start, end, text);
|
|
362
|
+
render(result.doc);
|
|
363
|
+
setCaret(result.caret, { focus: true });
|
|
364
|
+
reconcileRemovedMentions();
|
|
365
|
+
}
|
|
366
|
+
dispatchInput();
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Intercept ONLY the deletion cases browsers can get wrong around an atomic
|
|
371
|
+
* token: a collapsed caret adjacent to a token (delete the whole token) — the
|
|
372
|
+
* common `contenteditable="false"` handling already covers most of this, so
|
|
373
|
+
* this is a defensive backstop. Everything else falls through to native editing.
|
|
374
|
+
*/
|
|
375
|
+
const onBeforeInput = (event: Event): void => {
|
|
376
|
+
if (isComposing) return;
|
|
377
|
+
const type = (event as InputEvent).inputType;
|
|
378
|
+
// Normalize a paragraph split to a plain newline so the flat document model
|
|
379
|
+
// never has to unwind browser `<div>`/`<p>` wrapping. (A bare line break —
|
|
380
|
+
// Shift+Enter's `insertLineBreak` — inserts a native `<br>`, which the parser
|
|
381
|
+
// already reads back as `\n`, so we leave it to native editing to keep the
|
|
382
|
+
// caret/undo machinery intact.)
|
|
383
|
+
if (type === "insertParagraph") {
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
const { start, end } = getSelection();
|
|
386
|
+
const result = spliceDocument(getDocument(), start, end, NEWLINE);
|
|
387
|
+
render(result.doc);
|
|
388
|
+
setCaret(result.caret, { focus: true });
|
|
389
|
+
if (refs.size > 0) reconcileRemovedMentions();
|
|
390
|
+
dispatchInput();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (type !== "deleteContentBackward" && type !== "deleteContentForward") {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (refs.size === 0) return; // no tokens to protect → let native delete run
|
|
397
|
+
const { start, end } = getSelection();
|
|
398
|
+
if (start !== end) return; // range deletes: let native handle, reconcile after
|
|
399
|
+
const doc = getDocument();
|
|
400
|
+
const logical = toLogicalText(doc);
|
|
401
|
+
const probe = type === "deleteContentBackward" ? start - 1 : start;
|
|
402
|
+
if (probe < 0 || probe >= logical.length) return;
|
|
403
|
+
if (logical[probe] !== MENTION_PLACEHOLDER) return; // not adjacent to a token
|
|
404
|
+
// Find which mention sits at `probe` and remove it atomically.
|
|
405
|
+
let offset = 0;
|
|
406
|
+
let targetId: ComposerMentionId | null = null;
|
|
407
|
+
for (const block of doc.blocks) {
|
|
408
|
+
const len = block.kind === "text" ? block.value.length : 1;
|
|
409
|
+
if (block.kind === "mention" && probe >= offset && probe < offset + len) {
|
|
410
|
+
targetId = block.id;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
offset += len;
|
|
414
|
+
}
|
|
415
|
+
if (!targetId) return;
|
|
416
|
+
event.preventDefault();
|
|
417
|
+
const result = removeMention(doc, targetId);
|
|
418
|
+
render(result.doc);
|
|
419
|
+
setCaret(result.caret, { focus: true });
|
|
420
|
+
reconcileRemovedMentions();
|
|
421
|
+
dispatchInput();
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const setValueWithCaret = (value: string, caret: number): void => {
|
|
425
|
+
render(documentFromTextarea(value));
|
|
426
|
+
setCaret(caret);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Viewport-coordinate rect of the LOGICAL range `[start, end)`. Builds a
|
|
431
|
+
* NON-collapsed DOM Range around a real glyph (the whole trick — collapsed
|
|
432
|
+
* ranges at a node boundary measure empty in most engines) and returns its
|
|
433
|
+
* bounding rect. Returns `null` for an empty/invalid range or a degenerate
|
|
434
|
+
* (zero-size) rect so callers fall back to composer anchoring. Cheap: one Range
|
|
435
|
+
* + one `getBoundingClientRect`, only invoked on measure (not per keystroke).
|
|
436
|
+
*/
|
|
437
|
+
const getLogicalRangeRect = (
|
|
438
|
+
start: number,
|
|
439
|
+
end: number
|
|
440
|
+
): DOMRect | null => {
|
|
441
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
const s = logicalToDomPosition(root, start);
|
|
446
|
+
const e = logicalToDomPosition(root, end);
|
|
447
|
+
const range = document.createRange();
|
|
448
|
+
range.setStart(s.node, s.offset);
|
|
449
|
+
range.setEnd(e.node, e.offset);
|
|
450
|
+
const rect = range.getBoundingClientRect();
|
|
451
|
+
if (rect.width === 0 && rect.height === 0) return null;
|
|
452
|
+
return rect;
|
|
453
|
+
} catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Token-preserving logical-range replace (the controller's query-strip /
|
|
460
|
+
* slash-completion / prompt-macro rewrites). Splices at the DOCUMENT level so
|
|
461
|
+
* every token outside `[start, end)` survives; the dispatched `input` reconciles
|
|
462
|
+
* any token the range covered. Does not itself fire `input` — the caller does,
|
|
463
|
+
* matching `setValueWithCaret`.
|
|
464
|
+
*/
|
|
465
|
+
const replaceLogicalRange = (
|
|
466
|
+
start: number,
|
|
467
|
+
end: number,
|
|
468
|
+
text: string
|
|
469
|
+
): void => {
|
|
470
|
+
const result = spliceDocument(getDocument(), start, end, text);
|
|
471
|
+
render(result.doc);
|
|
472
|
+
setCaret(result.caret, { focus: true });
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const insertMentionAtTrigger = (
|
|
476
|
+
ref: AgentWidgetContextMentionRef,
|
|
477
|
+
match: MentionTriggerMatch
|
|
478
|
+
): ComposerMentionId | null => {
|
|
479
|
+
const doc = getDocument();
|
|
480
|
+
const logical = toLogicalText(doc);
|
|
481
|
+
const start = match.triggerIndex;
|
|
482
|
+
const end = match.triggerIndex + 1 + match.query.length;
|
|
483
|
+
// Staleness guard: the typed query region must still be intact.
|
|
484
|
+
if (
|
|
485
|
+
start < 0 ||
|
|
486
|
+
end > logical.length ||
|
|
487
|
+
logical.slice(start + 1, end) !== match.query
|
|
488
|
+
) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
const id = options.generateId();
|
|
492
|
+
const result = insertMention(doc, { start, end }, ref, id);
|
|
493
|
+
render(result.doc);
|
|
494
|
+
setCaret(result.caret, { focus: true });
|
|
495
|
+
options.onMentionInserted?.(id, ref);
|
|
496
|
+
dispatchInput();
|
|
497
|
+
return id;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const insertMentionAtSelection = (
|
|
501
|
+
ref: AgentWidgetContextMentionRef
|
|
502
|
+
): ComposerMentionId => {
|
|
503
|
+
const doc = getDocument();
|
|
504
|
+
const sel = getSelection();
|
|
505
|
+
// Picker path: no live caret in the composer (focus was in the menu search) →
|
|
506
|
+
// append at the end of the document.
|
|
507
|
+
const hasCaret =
|
|
508
|
+
typeof window !== "undefined" &&
|
|
509
|
+
!!window.getSelection()?.rangeCount &&
|
|
510
|
+
root.contains(window.getSelection()!.getRangeAt(0).startContainer);
|
|
511
|
+
const range = hasCaret
|
|
512
|
+
? { start: sel.start, end: sel.end }
|
|
513
|
+
: { start: logicalLength(doc), end: logicalLength(doc) };
|
|
514
|
+
const id = options.generateId();
|
|
515
|
+
const result = insertMention(doc, range, ref, id);
|
|
516
|
+
render(result.doc);
|
|
517
|
+
setCaret(result.caret, { focus: true });
|
|
518
|
+
options.onMentionInserted?.(id, ref);
|
|
519
|
+
dispatchInput();
|
|
520
|
+
return id;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/** Toggle the inline error affordance on the token element for `id` (no-op for
|
|
524
|
+
* unknown ids); any non-error status clears it. On error, also surface the
|
|
525
|
+
* failure to assistive tech via both the `title` tooltip AND the token's
|
|
526
|
+
* `aria-label` (the red styling alone is visual-only, so a screen-reader user
|
|
527
|
+
* would otherwise never learn the context failed to attach). The token's own
|
|
528
|
+
* name/tooltip are stashed before the error override and restored verbatim on
|
|
529
|
+
* recovery — tokens can come from the host's `renderMentionToken` hook, which
|
|
530
|
+
* carries none of the default builder's markup to re-derive them from, so
|
|
531
|
+
* non-error statuses must never rewrite attributes on a token that was never
|
|
532
|
+
* in the error state. */
|
|
533
|
+
const setMentionStatus = (
|
|
534
|
+
id: ComposerMentionId,
|
|
535
|
+
status: "pending" | "resolved" | "error"
|
|
536
|
+
): void => {
|
|
537
|
+
for (const child of Array.from(root.childNodes)) {
|
|
538
|
+
if (isMentionSpan(child) && child.getAttribute(MENTION_ID_ATTR) === id) {
|
|
539
|
+
const isError = status === "error";
|
|
540
|
+
child.classList.toggle(MENTION_TOKEN_ERROR_CLASS, isError);
|
|
541
|
+
if (isError) {
|
|
542
|
+
if (!child.hasAttribute(ERROR_PREV_LABEL_ATTR)) {
|
|
543
|
+
child.setAttribute(
|
|
544
|
+
ERROR_PREV_LABEL_ATTR,
|
|
545
|
+
child.getAttribute("aria-label") ?? ""
|
|
546
|
+
);
|
|
547
|
+
child.setAttribute(
|
|
548
|
+
ERROR_PREV_TITLE_ATTR,
|
|
549
|
+
child.getAttribute("title") ?? ""
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
const label = refs.get(id)?.label ?? "";
|
|
553
|
+
child.setAttribute("title", `${label}: failed to attach context`);
|
|
554
|
+
child.setAttribute("aria-label", `${label}, failed to attach context`);
|
|
555
|
+
} else if (child.hasAttribute(ERROR_PREV_LABEL_ATTR)) {
|
|
556
|
+
// A stashed empty string means the attribute was absent pre-error.
|
|
557
|
+
const prevLabel = child.getAttribute(ERROR_PREV_LABEL_ATTR);
|
|
558
|
+
const prevTitle = child.getAttribute(ERROR_PREV_TITLE_ATTR);
|
|
559
|
+
if (prevLabel) child.setAttribute("aria-label", prevLabel);
|
|
560
|
+
else child.removeAttribute("aria-label");
|
|
561
|
+
if (prevTitle) child.setAttribute("title", prevTitle);
|
|
562
|
+
else child.removeAttribute("title");
|
|
563
|
+
child.removeAttribute(ERROR_PREV_LABEL_ATTR);
|
|
564
|
+
child.removeAttribute(ERROR_PREV_TITLE_ATTR);
|
|
565
|
+
}
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
root.addEventListener("input", onInput);
|
|
572
|
+
root.addEventListener("compositionstart", onCompositionStart);
|
|
573
|
+
root.addEventListener("compositionend", onCompositionEnd);
|
|
574
|
+
root.addEventListener("paste", onPaste);
|
|
575
|
+
root.addEventListener("beforeinput", onBeforeInput);
|
|
576
|
+
|
|
577
|
+
// Start empty (placeholder visible).
|
|
578
|
+
render(emptyDocument());
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
element: root,
|
|
582
|
+
getValue: () => toDisplayText(getDocument()),
|
|
583
|
+
getLogicalText: () => toLogicalText(getDocument()),
|
|
584
|
+
getDocument,
|
|
585
|
+
getSelection,
|
|
586
|
+
setSelection: (start, end = start) => {
|
|
587
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
588
|
+
if (!sel) return;
|
|
589
|
+
try {
|
|
590
|
+
const s = logicalToDomPosition(root, start);
|
|
591
|
+
const e = logicalToDomPosition(root, end);
|
|
592
|
+
const range = document.createRange();
|
|
593
|
+
range.setStart(s.node, s.offset);
|
|
594
|
+
range.setEnd(e.node, e.offset);
|
|
595
|
+
sel.removeAllRanges();
|
|
596
|
+
sel.addRange(range);
|
|
597
|
+
} catch {
|
|
598
|
+
/* ignore */
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
setValueWithCaret,
|
|
602
|
+
getLogicalRangeRect,
|
|
603
|
+
setValue: (value: string) => {
|
|
604
|
+
// The `.value` shim setter: focus FIRST so the subsequent caret placement
|
|
605
|
+
// owns the selection (setCaret is a no-op when unfocused, by design).
|
|
606
|
+
render(documentFromTextarea(value));
|
|
607
|
+
root.focus();
|
|
608
|
+
setCaret(value.length, { focus: true });
|
|
609
|
+
dispatchInput();
|
|
610
|
+
},
|
|
611
|
+
replaceLogicalRange,
|
|
612
|
+
insertMentionAtTrigger,
|
|
613
|
+
insertMentionAtSelection,
|
|
614
|
+
setMentionStatus,
|
|
615
|
+
submit: () => requestFormSubmit(root.closest("form")),
|
|
616
|
+
dispatchInput,
|
|
617
|
+
focus: () => root.focus(),
|
|
618
|
+
destroy: () => {
|
|
619
|
+
root.removeEventListener("input", onInput);
|
|
620
|
+
root.removeEventListener("compositionstart", onCompositionStart);
|
|
621
|
+
root.removeEventListener("compositionend", onCompositionEnd);
|
|
622
|
+
root.removeEventListener("paste", onPaste);
|
|
623
|
+
root.removeEventListener("beforeinput", onBeforeInput);
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
}
|