authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-16 19:17:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 23:17:44-03:00
logd359e94cabca4722029359c77d60cb6d13da1e59
treeab1108d46ea7dcec1692b2da73d54375abc2d01d
parentdf466c79810046d20c0f3ccc4e51709c5703814e
signature Signed by PGP key B5690EEEBB952194

feat(web): enable rich text composer by default (#12160)

Co-authored-by: maria-rcks <maria@kuuro.net>

28 files changed, 3557 insertions(+), 4071 deletions(-)

apps/web/package.json+6-2
......@@ -23,7 +23,6 @@
2323 "@effect/atom-react": "catalog:",
2424 "@formkit/auto-animate": "^0.9.0",
2525 "@legendapp/list": "catalog:",
26 "@lexical/react": "^0.41.0",
2726 "@noble/hashes": "catalog:",
2827 "@pierre/diffs": "catalog:",
2928 "@pierre/trees": "1.0.0-beta.4",
......@@ -32,6 +31,12 @@
3231 "@t3tools/shared": "workspace:*",
3332 "@tanstack/react-pacer": "^0.19.4",
3433 "@tanstack/react-router": "^1.160.2",
34 "@tiptap/core": "^3.31.3",
35 "@tiptap/extension-task-item": "^3.31.3",
36 "@tiptap/extension-task-list": "^3.31.3",
37 "@tiptap/pm": "^3.31.3",
38 "@tiptap/react": "^3.31.3",
39 "@tiptap/starter-kit": "^3.31.3",
3540 "class-variance-authority": "^0.7.1",
3641 "culori": "^4.0.2",
3742 "effect": "catalog:",
......@@ -41,7 +46,6 @@
4146 "jose": "catalog:",
4247 "jsonc-parser": "3.3.1",
4348 "jszip": "3.10.1",
44 "lexical": "^0.41.0",
4549 "lucide-react": "^0.564.0",
4650 "react": "19.2.6",
4751 "react-dom": "19.2.6",
apps/web/src/components/AppSidebarLayout.tsx+14-1
......@@ -11,7 +11,11 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
1111
1212import { isElectron } from "../env";
1313import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage";
14import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
14import {
15 isRichTextBoldShortcut,
16 resolveShortcutCommand,
17 shortcutLabelForCommand,
18} from "../keybindings";
1519import { cn, isMacPlatform } from "../lib/utils";
1620import { primaryServerKeybindingsAtom } from "../state/server";
1721import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
......@@ -88,6 +92,15 @@ function SidebarControl() {
8892 ) {
8993 return;
9094 }
95 if (
96 isRichTextBoldShortcut(event) &&
97 event.target instanceof HTMLElement &&
98 event.target.closest('[data-composer-rich-text="true"]')
99 ) {
100 // The rich-text composer claims Mod+B for bold; the toggle stays
101 // available everywhere else, including the plain-text composer.
102 return;
103 }
91104 if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return;
92105
93106 event.preventDefault();
apps/web/src/components/ComposerCitationNode.tsx deleted-223
......@@ -1,223 +0,0 @@
1import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
2import type { AssistantCitation } from "@t3tools/contracts";
3import {
4 serializeAssistantCitation,
5 withAssistantCitationComment,
6} from "@t3tools/shared/assistantCitations";
7import {
8 $applyNodeReplacement,
9 $getNodeByKey,
10 $getRoot,
11 $isElementNode,
12 DecoratorNode,
13 HISTORY_PUSH_TAG,
14 SKIP_DOM_SELECTION_TAG,
15 type NodeKey,
16 type SerializedLexicalNode,
17 type Spread,
18} from "lexical";
19import { createContext, use, type ReactElement } from "react";
20import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
21
22import { AssistantCitationChip } from "./chat/AssistantCitationChip";
23import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME } from "./composerInlineChip";
24
25type SerializedComposerCitationNode = Spread<
26 {
27 citation: AssistantCitation;
28 source: string;
29 type: "composer-citation";
30 version: 1;
31 },
32 SerializedLexicalNode
33>;
34
35export type ComposerCitationCommentRequest = {
36 previousValue: string;
37 value: string;
38 citationStart: number;
39 sourceAnchor: AssistantCitationSourceAnchor;
40};
41
42export type ComposerCitationCommentTarget = {
43 nodeKey: NodeKey;
44 sourceAnchor?: AssistantCitationSourceAnchor;
45 removeOnCancel?: boolean;
46};
47
48export const ComposerCitationCommentContext = createContext<{
49 openComment: ComposerCitationCommentTarget | null;
50 onOpenChange: (nodeKey: NodeKey, open: boolean) => void;
51 onSubmitAndSend: () => void;
52}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} });
53
54/** Consume a cite action once its controlled prompt has been committed to the editor. */
55export function $consumeComposerCitationCommentRequest(requestRef: {
56 current: ComposerCitationCommentRequest | null;
57}): ComposerCitationCommentTarget | null {
58 const request = requestRef.current;
59 if (!request) return null;
60 const root = $getRoot();
61 const value = root.getTextContent();
62 if (value === request.previousValue) return null;
63 requestRef.current = null;
64 if (value !== request.value) return null;
65
66 // Controlled prompts use one paragraph with inline nodes and explicit line breaks.
67 const paragraph = root.getFirstChild();
68 if (!$isElementNode(paragraph)) return null;
69 let offset = 0;
70 for (const node of paragraph.getChildren()) {
71 if (offset === request.citationStart && node instanceof ComposerCitationNode) {
72 return {
73 nodeKey: node.getKey(),
74 sourceAnchor: request.sourceAnchor,
75 removeOnCancel: true,
76 };
77 }
78 offset += node.getTextContentSize();
79 }
80 return null;
81}
82
83function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey: NodeKey }) {
84 const [editor] = useLexicalComposerContext();
85 const commentContext = use(ComposerCitationCommentContext);
86 const commentTarget =
87 commentContext.openComment?.nodeKey === props.nodeKey ? commentContext.openComment : null;
88 const onSaveComment = (comment: string): boolean => {
89 if (!editor.isEditable()) return false;
90 let accepted = false;
91 editor.update(
92 () => {
93 const node = $getNodeByKey(props.nodeKey);
94 if (node instanceof ComposerCitationNode && node.isAttached()) {
95 node.setComment(comment);
96 accepted = true;
97 }
98 },
99 { discrete: true, tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] },
100 );
101 return accepted;
102 };
103 /** Cancelling a comment on a just-created citation removes the chip the cite action added. */
104 const onRemove = () => {
105 if (!editor.isEditable()) return;
106 editor.update(
107 () => {
108 const node = $getNodeByKey(props.nodeKey);
109 if (node instanceof ComposerCitationNode) {
110 node.selectPrevious();
111 node.remove();
112 }
113 },
114 { tag: HISTORY_PUSH_TAG },
115 );
116 editor.getRootElement()?.focus({ preventScroll: true });
117 };
118 return (
119 <span
120 className="inline-flex min-w-0 max-w-full"
121 contentEditable={false}
122 spellCheck={false}
123 data-composer-citation-chip="true"
124 >
125 <AssistantCitationChip
126 citation={props.citation}
127 composer
128 commentEditor={{
129 open: commentTarget !== null,
130 sourceAnchor: commentTarget?.sourceAnchor,
131 onOpenChange: (open) => {
132 if (open && !editor.isEditable()) return;
133 commentContext.onOpenChange(props.nodeKey, open);
134 },
135 ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}),
136 onSave: onSaveComment,
137 onSaveAndSend: (comment) => {
138 if (!onSaveComment(comment)) return false;
139 commentContext.onSubmitAndSend();
140 return true;
141 },
142 }}
143 />
144 </span>
145 );
146}
147
148export class ComposerCitationNode extends DecoratorNode<ReactElement> {
149 __citation: AssistantCitation;
150 __source: string;
151
152 static override getType(): string {
153 return "composer-citation";
154 }
155
156 static override clone(node: ComposerCitationNode): ComposerCitationNode {
157 return new ComposerCitationNode(node.__citation, node.__source, node.__key);
158 }
159
160 static override importJSON(serializedNode: SerializedComposerCitationNode): ComposerCitationNode {
161 return $createComposerCitationNode(
162 serializedNode.citation,
163 serializedNode.source,
164 ).updateFromJSON(serializedNode);
165 }
166
167 constructor(citation: AssistantCitation, source: string, key?: NodeKey) {
168 super(key);
169 this.__citation = citation;
170 this.__source = source;
171 }
172
173 override exportJSON(): SerializedComposerCitationNode {
174 const node = this.getLatest();
175 return {
176 ...super.exportJSON(),
177 citation: node.__citation,
178 source: node.__source,
179 type: "composer-citation",
180 version: 1,
181 };
182 }
183
184 override createDOM(): HTMLElement {
185 const dom = document.createElement("span");
186 dom.className = `${COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME} max-w-full`;
187 return dom;
188 }
189
190 override updateDOM(): false {
191 return false;
192 }
193
194 override getTextContent(): string {
195 return this.getLatest().__source;
196 }
197
198 setComment(comment: string): this {
199 const latest = this.getLatest();
200 const citation = withAssistantCitationComment(latest.__citation, comment);
201 if (citation.comment === latest.__citation.comment) return latest;
202 const source = serializeAssistantCitation(citation);
203 const writable = this.getWritable();
204 writable.__citation = citation;
205 writable.__source = source;
206 return writable;
207 }
208
209 override isInline(): true {
210 return true;
211 }
212
213 override decorate(): ReactElement {
214 return <ComposerCitationDecorator citation={this.__citation} nodeKey={this.__key} />;
215 }
216}
217
218export function $createComposerCitationNode(
219 citation: AssistantCitation,
220 source: string,
221): ComposerCitationNode {
222 return $applyNodeReplacement(new ComposerCitationNode(citation, source));
223}
apps/web/src/components/ComposerContextReferenceNode.test.ts deleted-92
......@@ -1,92 +0,0 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import { $createParagraphNode, $getRoot, $isElementNode, createEditor } from "lexical";
3
4import {
5 $createComposerContextReferenceNode,
6 ComposerContextReferenceNode,
7} from "./ComposerContextReferenceNode";
8import { splitPromptIntoComposerSegments } from "../composer-editor-mentions";
9
10vi.mock("./composerContextPresentation", () => ({
11 ComposerContextReferenceChip: () => null,
12}));
13
14const reference = { kind: "terminal", contextId: "ctx-1", label: "Terminal 1 lines 3-4" };
15const link = "[Terminal 1 lines 3-4](t3-context://v1/terminal/ctx-1)";
16
17function createReferenceEditor() {
18 const editor = createEditor({ nodes: [ComposerContextReferenceNode] });
19 editor.update(
20 () => {
21 $getRoot().append($createParagraphNode());
22 },
23 { discrete: true },
24 );
25 return editor;
26}
27
28function $referenceNodes() {
29 const paragraph = $getRoot().getFirstChildOrThrow();
30 if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph");
31 return paragraph.getChildren().filter((node) => node instanceof ComposerContextReferenceNode);
32}
33
34describe("ComposerContextReferenceNode", () => {
35 it("renders its canonical link as text so the prompt carries identity", () => {
36 const editor = createReferenceEditor();
37 editor.update(
38 () => {
39 const paragraph = $getRoot().getFirstChild();
40 if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph");
41 paragraph.append($createComposerContextReferenceNode(reference, "ref-1"));
42 },
43 { discrete: true },
44 );
45 const text = editor.getEditorState().read(() => $getRoot().getTextContent());
46 expect(text).toBe(link);
47 expect(splitPromptIntoComposerSegments(text)).toEqual([
48 { type: "context-reference", ...reference, source: link },
49 ]);
50 });
51
52 it("round-trips through JSON keeping the occurrence id", () => {
53 const editor = createReferenceEditor();
54 editor.update(
55 () => {
56 const paragraph = $getRoot().getFirstChild();
57 if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph");
58 paragraph.append($createComposerContextReferenceNode(reference, "ref-1"));
59 },
60 { discrete: true },
61 );
62 const serialized = editor.getEditorState().toJSON();
63 const restored = createReferenceEditor();
64 restored.setEditorState(restored.parseEditorState(serialized));
65 const nodes = restored.getEditorState().read(() =>
66 $referenceNodes().map((node) => ({
67 referenceId: node.__referenceId,
68 reference: node.getReference(),
69 })),
70 );
71 expect(nodes).toEqual([{ referenceId: "ref-1", reference }]);
72 });
73
74 it("mints a distinct occurrence id per created node while sharing the payload id", () => {
75 const editor = createReferenceEditor();
76 editor.update(
77 () => {
78 const paragraph = $getRoot().getFirstChild();
79 if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph");
80 paragraph.append(
81 $createComposerContextReferenceNode(reference),
82 $createComposerContextReferenceNode(reference),
83 );
84 },
85 { discrete: true },
86 );
87 const nodes = editor.getEditorState().read(() => $referenceNodes());
88 expect(nodes[0]!.__contextId).toBe(nodes[1]!.__contextId);
89 expect(nodes[0]!.__referenceId).not.toBe(nodes[1]!.__referenceId);
90 expect(nodes[0]!.__referenceId).toMatch(/^[0-9a-f-]{36}$/);
91 });
92});
apps/web/src/components/ComposerContextReferenceNode.tsx deleted-133
......@@ -1,133 +0,0 @@
1import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
2import type { ComposerContextId } from "@t3tools/contracts";
3import {
4 $applyNodeReplacement,
5 DecoratorNode,
6 type NodeKey,
7 type SerializedLexicalNode,
8 type Spread,
9} from "lexical";
10import type { ReactElement } from "react";
11
12import { randomUUID } from "~/lib/utils";
13import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME } from "./composerInlineChip";
14import { ComposerContextReferenceChip } from "./composerContextPresentation";
15
16export interface ComposerContextReference {
17 kind: string;
18 contextId: string;
19 label: string;
20}
21
22export type SerializedComposerContextReferenceNode = Spread<
23 ComposerContextReference & {
24 referenceId: string;
25 type: "composer-context-reference";
26 version: 1;
27 },
28 SerializedLexicalNode
29>;
30
31/**
32 * One inline occurrence of a context payload. The node's text is the canonical link, so the
33 * prompt string carries kind and id and rebuilding the editor from it restores the same chip.
34 * `referenceId` identifies this occurrence; duplicating the node mints a new one.
35 */
36export class ComposerContextReferenceNode extends DecoratorNode<ReactElement> {
37 __kind: string;
38 __contextId: string;
39 __label: string;
40 __referenceId: string;
41
42 static override getType(): "composer-context-reference" {
43 return "composer-context-reference";
44 }
45
46 static override clone(node: ComposerContextReferenceNode): ComposerContextReferenceNode {
47 return new ComposerContextReferenceNode(
48 { kind: node.__kind, contextId: node.__contextId, label: node.__label },
49 node.__referenceId,
50 node.__key,
51 );
52 }
53
54 static override importJSON(
55 serializedNode: SerializedComposerContextReferenceNode,
56 ): ComposerContextReferenceNode {
57 return $createComposerContextReferenceNode(
58 {
59 kind: serializedNode.kind,
60 contextId: serializedNode.contextId,
61 label: serializedNode.label,
62 },
63 serializedNode.referenceId,
64 ).updateFromJSON(serializedNode);
65 }
66
67 constructor(reference: ComposerContextReference, referenceId: string, key?: NodeKey) {
68 super(key);
69 this.__kind = reference.kind;
70 this.__contextId = reference.contextId;
71 this.__label = reference.label;
72 this.__referenceId = referenceId;
73 }
74
75 override exportJSON(): SerializedComposerContextReferenceNode {
76 const latest = this.getLatest();
77 return {
78 ...super.exportJSON(),
79 kind: latest.__kind,
80 contextId: latest.__contextId,
81 label: latest.__label,
82 referenceId: latest.__referenceId,
83 type: "composer-context-reference",
84 version: 1,
85 };
86 }
87
88 override createDOM(): HTMLElement {
89 const dom = document.createElement("span");
90 dom.className = `${COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME} max-w-full`;
91 return dom;
92 }
93
94 override updateDOM(): false {
95 return false;
96 }
97
98 override getTextContent(): string {
99 const latest = this.getLatest();
100 return formatComposerContextReference({
101 kind: latest.__kind,
102 contextId: latest.__contextId as ComposerContextId,
103 label: latest.__label,
104 });
105 }
106
107 override isInline(): true {
108 return true;
109 }
110
111 getReference(): ComposerContextReference {
112 const latest = this.getLatest();
113 return { kind: latest.__kind, contextId: latest.__contextId, label: latest.__label };
114 }
115
116 override decorate(): ReactElement {
117 const latest = this.getLatest();
118 return (
119 <ComposerContextReferenceChip
120 kind={latest.__kind}
121 contextId={latest.__contextId}
122 label={latest.__label}
123 />
124 );
125 }
126}
127
128export function $createComposerContextReferenceNode(
129 reference: ComposerContextReference,
130 referenceId: string = randomUUID(),
131): ComposerContextReferenceNode {
132 return $applyNodeReplacement(new ComposerContextReferenceNode(reference, referenceId));
133}
apps/web/src/components/ComposerPromptEditor.serialization.test.tsx deleted-183
......@@ -1,183 +0,0 @@
1import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
2import { $copyNode, $getRoot, $isElementNode, PASTE_COMMAND, type LexicalEditor } from "lexical";
3import { act, createRef } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import { collapseExpandedComposerCursor } from "../composer-logic";
8import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
9
10vi.mock("./chat/FileTagChip", () => ({
11 FILE_TAG_CHIP_CLASS_NAME: "",
12 FileTagChipContent: () => null,
13}));
14vi.mock("./chat/ComposerPendingTerminalContexts", () => ({
15 ComposerPendingTerminalContextChip: () => null,
16}));
17vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null }));
18
19let lexicalEditor: LexicalEditor;
20// Keep the real composer, registered nodes, updates, and snapshot API. Only the
21// DOM view is omitted so Lexical runs headlessly in this component test.
22vi.mock("@lexical/react/LexicalPlainTextPlugin", () => ({
23 PlainTextPlugin: function HeadlessEditor() {
24 [lexicalEditor] = useLexicalComposerContext();
25 return null;
26 },
27}));
28
29let renderer: ReactTestRenderer | undefined;
30const editorRef = createRef<ComposerPromptEditorHandle>();
31
32function composer(value: string) {
33 return (
34 <ComposerPromptEditor
35 value={value}
36 cursor={collapseExpandedComposerCursor(value, value.length)}
37 contextRecords={new Map()}
38 skills={[]}
39 disabled={false}
40 placeholder="Write a prompt"
41 onChange={() => {}}
42 onPaste={() => {}}
43 editorRef={editorRef}
44 />
45 );
46}
47
48async function renderPrompt(value: string) {
49 await act(() => {
50 if (renderer) renderer.update(composer(value));
51 else renderer = create(composer(value));
52 });
53}
54
55function $firstMention() {
56 const paragraph = $getRoot().getFirstChildOrThrow();
57 if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph");
58 const mention = paragraph.getFirstChildOrThrow();
59 if (mention.getType() !== "composer-mention") throw new Error("Expected a mention");
60 return mention;
61}
62
63class TestClipboardEvent extends Event {
64 readonly clipboardData: DataTransfer;
65
66 constructor(text: string) {
67 super("paste", { cancelable: true });
68 this.clipboardData = {
69 files: [],
70 getData: (type: string) => (type === "text/plain" ? text : ""),
71 } as unknown as DataTransfer;
72 }
73}
74
75beforeEach(() => {
76 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
77 vi.stubGlobal("document", { activeElement: null });
78});
79
80afterEach(async () => {
81 await act(() => renderer?.unmount());
82 renderer = undefined;
83 vi.unstubAllGlobals();
84});
85
86describe("composer mention serialization", () => {
87 it.each([
88 "@README.md control",
89 "@terminal-1:3 Explain this output\n\n<terminal_context>\n- Terminal 1 line 3:\n 3 | output\n</terminal_context>",
90 '@"docs/My \\"File\\".md" please',
91 '@"docs/雪 👋.md" please',
92 "[README.md](README.md) control",
93 "[config#draft?.json](config%23draft%3f.json) control",
94 "Plain text\n Keep indentation 👋",
95 ])("preserves the initial prompt %s", async (prompt) => {
96 await renderPrompt(prompt);
97 expect(editorRef.current?.readSnapshot().value).toBe(prompt);
98 });
99
100 it("preserves original source when replacing the controlled prompt", async () => {
101 for (const prompt of ["", "@README.md control", "Older plain control", "@README.md control"]) {
102 await renderPrompt(prompt);
103 expect(editorRef.current?.readSnapshot()).toMatchObject({
104 value: prompt,
105 expandedCursor: prompt.length,
106 });
107 if (prompt === "@README.md control") {
108 expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true);
109 }
110 }
111 });
112
113 it("preserves source when Lexical clones the mention and reloads exported state", async () => {
114 const prompt = '@"docs/雪 👋.md" remains a chip';
115 await renderPrompt(prompt);
116 const originalKey = lexicalEditor.getEditorState().read(() => $firstMention().getKey());
117
118 await act(() => {
119 lexicalEditor.update(
120 () => {
121 const mention = $firstMention();
122 mention.replace($copyNode(mention));
123 },
124 { discrete: true },
125 );
126 });
127 expect(lexicalEditor.getEditorState().read(() => $firstMention().getKey())).not.toBe(
128 originalKey,
129 );
130 expect(editorRef.current?.readSnapshot().value).toBe(prompt);
131 const exportedState = lexicalEditor.getEditorState().toJSON();
132
133 await renderPrompt("");
134 await act(() => {
135 lexicalEditor.setEditorState(lexicalEditor.parseEditorState(exportedState));
136 });
137 expect(editorRef.current?.readSnapshot().value).toBe(prompt);
138 expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true);
139 });
140
141 it("keeps canonical serialization when importing legacy mention JSON without source", async () => {
142 await renderPrompt("");
143 await act(() => {
144 lexicalEditor.setEditorState(
145 lexicalEditor.parseEditorState(
146 JSON.stringify({
147 root: {
148 type: "root",
149 version: 1,
150 children: [
151 {
152 type: "paragraph",
153 version: 1,
154 children: [{ type: "composer-mention", version: 1, path: "README.md" }],
155 },
156 ],
157 },
158 }),
159 ),
160 );
161 });
162 expect(editorRef.current?.readSnapshot().value).toBe("[README.md](README.md)");
163 expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true);
164 });
165
166 it("still serializes a newly inserted mention canonically", async () => {
167 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
168 await renderPrompt("");
169 const event = new TestClipboardEvent("@README.md ");
170 await act(() => {
171 lexicalEditor.update(
172 () => {
173 $getRoot().selectEnd();
174 lexicalEditor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
175 },
176 { discrete: true },
177 );
178 });
179 expect(event.defaultPrevented).toBe(true);
180 expect(editorRef.current?.readSnapshot().value).toBe("[README.md](README.md) ");
181 expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true);
182 });
183});
apps/web/src/components/ComposerPromptEditor.test.ts deleted-864
......@@ -1,864 +0,0 @@
1import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy";
2import { elementContextToPreviewAnnotation } from "../lib/elementContext";
3import { previewAnnotationContextRecord } from "../lib/composerContextRecords";
4import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts";
5import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
6import { afterEach, describe, expect, it, vi } from "vite-plus/test";
7import {
8 $createParagraphNode,
9 $createTextNode,
10 $getRoot,
11 $getSelection,
12 $isElementNode,
13 $isRangeSelection,
14 COMMAND_PRIORITY_EDITOR,
15 createEditor,
16 PASTE_COMMAND,
17} from "lexical";
18
19import {
20 importPastedComposerText,
21 readPastedComposerContext,
22 registerComposerInlineTokenPaste,
23} from "./composerInlineTokenPaste";
24import {
25 $consumeComposerCitationCommentRequest,
26 $createComposerCitationNode,
27 ComposerCitationNode,
28 type ComposerCitationCommentRequest,
29} from "./ComposerCitationNode";
30import { splitPromptIntoComposerSegments } from "../composer-editor-mentions";
31import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
32
33vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null }));
34
35const citation: AssistantCitation = {
36 version: 1,
37 environmentId: EnvironmentId.make("remote/雪"),
38 threadId: ThreadId.make("thread-1"),
39 messageId: MessageId.make("message-1"),
40 text: 'Keep "👋", (punctuation) and @AGENTS.md.',
41 start: 5,
42 end: 43,
43 prefix: "前: ",
44 suffix: " 後",
45};
46const citationSource = serializeAssistantCitation(citation).replaceAll("+", "%20");
47
48function createCitationEditor(text = "") {
49 const editor = createEditor({ nodes: [ComposerCitationNode] });
50 editor.update(
51 () => {
52 const paragraph = $createParagraphNode();
53 $getRoot().append(paragraph);
54 if (text) paragraph.append($createTextNode(text));
55 paragraph.selectEnd();
56 },
57 { discrete: true },
58 );
59 registerComposerInlineTokenPaste(editor, {
60 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
61 createCitationNode: $createComposerCitationNode,
62 createContextReferenceNode: (reference) => $createTextNode(`<context:${reference.contextId}>`),
63 getExpandedAbsoluteOffsetForPoint: (_node, offset) => offset,
64 });
65 return editor;
66}
67
68function $citationNodes() {
69 const paragraph = $getRoot().getFirstChildOrThrow();
70 if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph");
71 return paragraph.getChildren().filter((node) => node instanceof ComposerCitationNode);
72}
73
74function pasteText(editor: ReturnType<typeof createEditor>, text: string) {
75 const event = new TestClipboardEvent(text);
76 editor.update(
77 () => {
78 editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
79 },
80 { discrete: true },
81 );
82 return event;
83}
84
85class TestClipboardEvent extends Event {
86 readonly clipboardData: DataTransfer;
87
88 constructor(text: string, extra: Record<string, string> = {}) {
89 super("paste", { cancelable: true });
90 this.clipboardData = {
91 files: [],
92 getData: (type: string) => (type === "text/plain" ? text : (extra[type] ?? "")),
93 } as unknown as DataTransfer;
94 }
95}
96
97describe("registerComposerInlineTokenPaste", () => {
98 afterEach(() => {
99 vi.unstubAllGlobals();
100 });
101
102 it("handles a copied mention without also running the plain-text paste fallback", () => {
103 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
104 const editor = createEditor();
105 const mention = "[improve-deploy-error-logging.md](.changeset/improve-deploy-error-logging.md)";
106 const plainTextFallback = vi.fn(() => {
107 const selection = $getSelection();
108 if (!$isRangeSelection(selection)) return false;
109 selection.insertText(mention);
110 return true;
111 });
112
113 editor.update(
114 () => {
115 const paragraph = $createParagraphNode();
116 $getRoot().append(paragraph);
117 paragraph.selectEnd();
118 },
119 { discrete: true },
120 );
121 registerComposerInlineTokenPaste(editor, {
122 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
123 createCitationNode: $createComposerCitationNode,
124 createContextReferenceNode: (reference) =>
125 $createTextNode(`<context:${reference.contextId}>`),
126 getExpandedAbsoluteOffsetForPoint: () => 0,
127 });
128 editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
129
130 const event = new TestClipboardEvent(mention);
131 let handled = false;
132 editor.update(
133 () => {
134 handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
135 },
136 { discrete: true },
137 );
138
139 expect(handled).toBe(true);
140 expect(plainTextFallback).not.toHaveBeenCalled();
141 expect(event.defaultPrevented).toBe(true);
142 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
143 "<mention:.changeset/improve-deploy-error-logging.md> ",
144 );
145 });
146
147 it.each([
148 "yarn expo install @expo/ui",
149 "npm install @jane/foo.js",
150 "import '@scope/pkg/sub/path'",
151 ])("leaves scoped package command %s to the plain-text paste fallback", (command) => {
152 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
153 const editor = createEditor();
154 const plainTextFallback = vi.fn((event: ClipboardEvent) => {
155 const selection = $getSelection();
156 if (!$isRangeSelection(selection)) return false;
157 selection.insertText(event.clipboardData?.getData("text/plain") ?? "");
158 return true;
159 });
160
161 editor.update(
162 () => {
163 const paragraph = $createParagraphNode();
164 $getRoot().append(paragraph);
165 paragraph.selectEnd();
166 },
167 { discrete: true },
168 );
169 registerComposerInlineTokenPaste(editor, {
170 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
171 createCitationNode: $createComposerCitationNode,
172 createContextReferenceNode: (reference) =>
173 $createTextNode(`<context:${reference.contextId}>`),
174 getExpandedAbsoluteOffsetForPoint: () => 0,
175 });
176 editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
177
178 const event = new TestClipboardEvent(command);
179 let handled = false;
180 editor.update(
181 () => {
182 handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
183 },
184 { discrete: true },
185 );
186
187 expect(handled).toBe(true);
188 expect(plainTextFallback).toHaveBeenCalledOnce();
189 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(command);
190 });
191
192 it("pastes a canonical scoped folder link as a mention", () => {
193 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
194 const editor = createEditor();
195 const mention = "[sub](@scope/pkg/sub)";
196 const plainTextFallback = vi.fn(() => true);
197
198 editor.update(
199 () => {
200 const paragraph = $createParagraphNode();
201 $getRoot().append(paragraph);
202 paragraph.selectEnd();
203 },
204 { discrete: true },
205 );
206 registerComposerInlineTokenPaste(editor, {
207 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
208 createCitationNode: $createComposerCitationNode,
209 createContextReferenceNode: (reference) =>
210 $createTextNode(`<context:${reference.contextId}>`),
211 getExpandedAbsoluteOffsetForPoint: () => 0,
212 });
213 editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
214
215 const event = new TestClipboardEvent(mention);
216 let handled = false;
217 editor.update(
218 () => {
219 handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
220 },
221 { discrete: true },
222 );
223
224 expect(handled).toBe(true);
225 expect(plainTextFallback).not.toHaveBeenCalled();
226 expect(event.defaultPrevented).toBe(true);
227 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
228 "<mention:@scope/pkg/sub> ",
229 );
230 });
231
232 it("restores multiple citation nodes beside punctuation and Unicode without changing their source", () => {
233 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
234 const editor = createCitationEditor();
235 const secondSource = serializeAssistantCitation({
236 ...citation,
237 messageId: MessageId.make("message-2"),
238 text: "Another quote 👋",
239 });
240 const text = `前(${citationSource}),${secondSource}${citationSource}`;
241 const plainTextFallback = vi.fn(() => true);
242 editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
243
244 const event = pasteText(editor, text);
245
246 expect(event.defaultPrevented).toBe(true);
247 expect(plainTextFallback).not.toHaveBeenCalled();
248 editor.getEditorState().read(() => {
249 expect($getRoot().getTextContent()).toBe(text);
250 expect($citationNodes().map((node) => node.getTextContent())).toEqual([
251 citationSource,
252 secondSource,
253 citationSource,
254 ]);
255 });
256 });
257
258 it("restores citations alongside mentions and only pads a trailing mention", () => {
259 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
260 const editor = createCitationEditor();
261
262 pasteText(editor, `${citationSource}\n[AGENTS.md](AGENTS.md) ${citationSource}`);
263
264 editor.getEditorState().read(() => {
265 expect($getRoot().getTextContent()).toBe(
266 `${citationSource}\n<mention:AGENTS.md> ${citationSource}`,
267 );
268 expect($citationNodes()).toHaveLength(2);
269 });
270
271 const trailingMentionEditor = createCitationEditor();
272 pasteText(trailingMentionEditor, `${citationSource} [AGENTS.md](AGENTS.md)`);
273
274 expect(trailingMentionEditor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
275 `${citationSource} <mention:AGENTS.md> `,
276 );
277 });
278
279 it.each(["what do you mean??\nPlease keep the indentation:\n café 👋"])(
280 "keeps the edited comment %j bound to its quote through an editor reload and paste",
281 (comment) => {
282 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
283 const editor = createCitationEditor();
284 const commentedCitation = { ...citation, comment };
285 const source = serializeAssistantCitation(commentedCitation);
286 pasteText(editor, `前(${citationSource})後`);
287 editor.update(
288 () => {
289 $citationNodes()[0]!.setComment(comment);
290 },
291 { discrete: true },
292 );
293
294 editor.getEditorState().read(() => {
295 expect($getRoot().getTextContent()).toBe(`前(${source})後`);
296 expect($citationNodes()[0]!.exportJSON().citation).toEqual(commentedCitation);
297 });
298
299 const restored = createCitationEditor();
300 restored.setEditorState(restored.parseEditorState(editor.getEditorState().toJSON()));
301 restored.update(
302 () => {
303 const paragraph = $getRoot().getFirstChildOrThrow();
304 if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph");
305 paragraph.selectStart().insertText("About ");
306 },
307 { discrete: true },
308 );
309
310 restored.getEditorState().read(() => {
311 expect($getRoot().getTextContent()).toBe(`About 前(${source})後`);
312 expect($getRoot().getChildrenSize()).toBe(1);
313 expect($citationNodes()[0]!.exportJSON().citation).toEqual(commentedCitation);
314 });
315
316 const pasted = createCitationEditor();
317 pasteText(
318 pasted,
319 restored.getEditorState().read(() => $getRoot().getTextContent()),
320 );
321 pasted.getEditorState().read(() => {
322 expect($getRoot().getTextContent()).toBe(`About 前(${source})後`);
323 expect($citationNodes()[0]!.exportJSON().citation).toEqual(commentedCitation);
324 });
325 },
326 );
327
328 it("edits only one repeated quote without moving selection or mutating earlier editor states", () => {
329 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
330 const editor = createCitationEditor();
331 const prompt = `前(${citationSource}),(${citationSource})後`;
332 pasteText(editor, prompt);
333 editor.update(
334 () => {
335 $getRoot().getAllTextNodes().at(-1)!.select(2, 1);
336 },
337 { discrete: true },
338 );
339 const beforeEdit = editor.getEditorState();
340 const selection = beforeEdit.read(() => $getSelection()!.clone());
341 const node = beforeEdit.read(() => $citationNodes()[1]!);
342 editor.update(() => node.setComment("First draft"), { discrete: true });
343 const comment = "Use this occurrence, not the earlier quote.";
344 editor.update(() => node.setComment(comment), { discrete: true });
345 const afterEdit = editor.getEditorState();
346 const source = serializeAssistantCitation({ ...citation, comment });
347
348 afterEdit.read(() => {
349 expect($getRoot().getTextContent()).toBe(`前(${citationSource}),(${source})後`);
350 expect($citationNodes().map((citationNode) => citationNode.exportJSON().citation)).toEqual([
351 citation,
352 { ...citation, comment },
353 ]);
354 expect($getSelection()?.is(selection)).toBe(true);
355 });
356 beforeEdit.read(() => {
357 expect($getRoot().getTextContent()).toBe(prompt);
358 expect($citationNodes().map((citationNode) => citationNode.exportJSON().citation)).toEqual([
359 citation,
360 citation,
361 ]);
362 });
363
364 editor.setEditorState(beforeEdit);
365 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(prompt);
366 editor.setEditorState(afterEdit);
367 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
368 `前(${citationSource}),(${source})後`,
369 );
370 });
371
372 it.each(["", " \n\t "])("clears a comment with %j without removing the quote", (comment) => {
373 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
374 const editor = createCitationEditor();
375 pasteText(editor, citationSource);
376 editor.update(() => $citationNodes()[0]!.setComment("Remove this comment."), {
377 discrete: true,
378 });
379 editor.update(() => $citationNodes()[0]!.setComment(comment), { discrete: true });
380
381 editor.getEditorState().read(() => {
382 expect($citationNodes()).toHaveLength(1);
383 expect($citationNodes()[0]!.exportJSON().citation).toStrictEqual(citation);
384 expect($getRoot().getTextContent()).toBe(serializeAssistantCitation(citation));
385 });
386 });
387
388 it("keeps a citation pasted after an unfinished @ restorable as a chip", () => {
389 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
390 const editor = createCitationEditor("@");
391
392 pasteText(editor, citationSource);
393
394 const prompt = editor.getEditorState().read(() => $getRoot().getTextContent());
395 expect(splitPromptIntoComposerSegments(prompt)).toEqual([
396 { type: "text", text: "@" },
397 { type: "citation", citation, source: citationSource },
398 ]);
399 });
400
401 it("copies, cuts, and pastes selected citation nodes with all source metadata intact", () => {
402 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
403 const editor = createCitationEditor();
404 const firstCitation = { ...citation, comment: "Explain the first occurrence." };
405 const secondCitation = { ...citation, comment: "Keep the second occurrence.\n café 👋" };
406 const selectedText = `${serializeAssistantCitation(firstCitation)},${serializeAssistantCitation(secondCitation)}`;
407 pasteText(editor, `before ${citationSource},${citationSource} after`);
408 editor.update(
409 () => {
410 $citationNodes()[0]!.setComment(firstCitation.comment);
411 $citationNodes()[1]!.setComment(secondCitation.comment);
412 },
413 { discrete: true },
414 );
415 let copied = "";
416 editor.update(
417 () => {
418 const paragraph = $getRoot().getFirstChildOrThrow();
419 if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph");
420 paragraph.select(1, 4);
421 const selection = $getSelection();
422 if (!$isRangeSelection(selection)) throw new Error("Expected a range selection");
423 copied = selection.getTextContent();
424 selection.removeText();
425 },
426 { discrete: true },
427 );
428
429 expect(copied).toBe(selectedText);
430 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe("before after");
431
432 pasteText(editor, copied);
433
434 editor.getEditorState().read(() => {
435 expect($getRoot().getTextContent()).toBe(`before ${selectedText} after`);
436 expect($citationNodes().map((node) => node.exportJSON().citation)).toEqual([
437 firstCitation,
438 secondCitation,
439 ]);
440 });
441 });
442
443 it.each([true, false])(
444 "deletes a citation and its comment atomically, backwards=%s",
445 (backwards) => {
446 const editor = createCitationEditor();
447 const commentedCitation = { ...citation, comment: "Delete this with its quote.\n café 👋" };
448 const source = serializeAssistantCitation(commentedCitation);
449 editor.update(
450 () => {
451 const paragraph = $getRoot().getFirstChildOrThrow();
452 if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph");
453 const before = $createTextNode("前(");
454 const after = $createTextNode(")後");
455 paragraph.append(
456 before,
457 $createComposerCitationNode(citation, citationSource).setComment(
458 commentedCitation.comment,
459 ),
460 after,
461 );
462 if (backwards) {
463 after.selectStart();
464 } else {
465 before.selectEnd();
466 }
467 },
468 { discrete: true },
469 );
470 const beforeDeletion = editor.getEditorState();
471 const serialized = beforeDeletion.toJSON();
472
473 editor.update(
474 () => {
475 const selection = $getSelection();
476 if (!$isRangeSelection(selection)) throw new Error("Expected a range selection");
477 selection.deleteCharacter(backwards);
478 },
479 { discrete: true },
480 );
481
482 const afterDeletion = editor.getEditorState();
483 expect(afterDeletion.read(() => $getRoot().getTextContent())).toBe("前()後");
484
485 editor.setEditorState(beforeDeletion);
486 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
487 `前(${source})後`,
488 );
489 editor.setEditorState(afterDeletion);
490 expect(editor.getEditorState().read(() => $citationNodes())).toHaveLength(0);
491
492 editor.setEditorState(editor.parseEditorState(serialized));
493 editor.getEditorState().read(() => {
494 expect($getRoot().getTextContent()).toBe(`前(${source})後`);
495 expect($citationNodes()[0]?.exportJSON().citation).toEqual(commentedCitation);
496 });
497 },
498 );
499});
500
501describe("context reference paste", () => {
502 it.each([
503 { focus: "focused", prefix: "" },
504 { focus: "blurred", prefix: "" },
505 { focus: "focused", prefix: "log ".repeat(10_000) },
506 { focus: "blurred", prefix: "log ".repeat(10_000) },
507 ])(
508 "imports structured paste when $focus with $prefix.length extra characters",
509 ({ focus, prefix }) => {
510 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
511 const editor = createEditor({ nodes: [ComposerCitationNode] });
512 editor.update(
513 () => {
514 const paragraph = $createParagraphNode();
515 $getRoot().append(paragraph);
516 paragraph.selectEnd();
517 },
518 { discrete: true },
519 );
520 const imported: string[] = [];
521 const importFragment = (
522 fragment: import("@t3tools/contracts").ComposerContextClipboardFragment,
523 ) => {
524 imported.push(...fragment.records.map((record) => record.contextId));
525 return new Map([["img-old", "img-new"]]);
526 };
527 registerComposerInlineTokenPaste(editor, {
528 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
529 createCitationNode: $createComposerCitationNode,
530 createContextReferenceNode: (reference) =>
531 $createTextNode(`<context:${reference.contextId}>`),
532 getExpandedAbsoluteOffsetForPoint: () => 0,
533 importContextFragment: importFragment,
534 });
535 const event = new TestClipboardEvent(
536 `${prefix}![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)`,
537 {
538 "web application/x-t3-context-fragment+json": JSON.stringify({
539 version: 1,
540 source: { environmentId: "env-1" },
541 records: [
542 {
543 version: 1,
544 contextId: "img-old",
545 kind: "image",
546 label: "shot",
547 attachmentId: "a",
548 name: "shot.png",
549 mimeType: "image/png",
550 sizeBytes: 1,
551 },
552 {
553 version: 1,
554 contextId: "ctx-t",
555 kind: "terminal",
556 label: "T",
557 terminalId: "t",
558 terminalLabel: "T",
559 lineStart: 1,
560 lineEnd: 1,
561 text: "x",
562 },
563 {
564 version: 1,
565 contextId: "img-unrelated",
566 kind: "image",
567 label: "other",
568 attachmentId: "b",
569 name: "other.png",
570 mimeType: "image/png",
571 sizeBytes: 1,
572 },
573 ],
574 }),
575 },
576 );
577 expect(
578 readPastedComposerContext(event.clipboardData)?.records.map((record) => record.contextId),
579 ).toEqual(["img-old", "ctx-t"]);
580 if (focus === "blurred") {
581 expect(importPastedComposerText(event.clipboardData, importFragment)).toBe(
582 `${prefix}![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)`,
583 );
584 expect(imported).toEqual(["img-old", "ctx-t"]);
585 return;
586 }
587 editor.update(
588 () => {
589 editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
590 },
591 { discrete: true },
592 );
593 expect(imported).toEqual(["img-old", "ctx-t"]);
594 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
595 `${prefix}<context:img-new> and <context:ctx-t>`,
596 );
597 },
598 );
599
600 it("converts a copied legacy element into a sendable annotation and rewrites its link", () => {
601 const copied = upgradeLegacyContextMessage(
602 [
603 "Fix this",
604 "",
605 "<element_context>",
606 "- <button>:",
607 " url: https://example.com",
608 " selector: #save",
609 " html:",
610 " <button>Save</button>",
611 " styles:",
612 " color: red;",
613 "</element_context>",
614 ].join("\n"),
615 );
616 const event = new TestClipboardEvent(copied.text, {
617 "web application/x-t3-context-fragment+json": JSON.stringify({
618 version: 1,
619 source: { environmentId: "env-1" },
620 records: copied.records,
621 }),
622 });
623 const annotations: ReturnType<typeof previewAnnotationContextRecord>[] = [];
624 const text = importPastedComposerText(event.clipboardData, (fragment) => {
625 const record = fragment.records[0]!;
626 if (record.kind !== "element" || "payload" in record) throw new Error("Expected element");
627 const annotation = previewAnnotationContextRecord(
628 elementContextToPreviewAnnotation(record, "imported", "2026-01-01T00:00:00Z"),
629 );
630 annotations.push(annotation);
631 return new Map([[record.contextId, annotation.contextId]]);
632 });
633 expect(text).toContain("t3-context://v1/preview-annotation/preview-annotation_imported");
634 expect(annotations[0]?.elements?.[0]).toMatchObject({
635 selector: "#save",
636 htmlPreview: "<button>Save</button>",
637 styles: "color: red;",
638 });
639 });
640
641 it("imports an annotation's dependent screenshot when only its chip is pasted", () => {
642 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
643 const editor = createEditor({ nodes: [ComposerCitationNode] });
644 editor.update(
645 () => {
646 const paragraph = $createParagraphNode();
647 $getRoot().append(paragraph);
648 paragraph.selectEnd();
649 },
650 { discrete: true },
651 );
652 const imported: string[] = [];
653 registerComposerInlineTokenPaste(editor, {
654 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
655 createCitationNode: $createComposerCitationNode,
656 createContextReferenceNode: (reference) =>
657 $createTextNode(`<context:${reference.contextId}>`),
658 getExpandedAbsoluteOffsetForPoint: () => 0,
659 importContextFragment: (fragment) => {
660 imported.push(...fragment.records.map((record) => record.contextId));
661 return new Map();
662 },
663 });
664 const annotationId = "preview-annotation_ann-1";
665 const screenshotId = "image_ann-1";
666 const event = new TestClipboardEvent(
667 `[Fix button](t3-context://v1/preview-annotation/${annotationId})`,
668 {
669 "web application/x-t3-context-fragment+json": JSON.stringify({
670 version: 1,
671 source: { environmentId: "env-1" },
672 records: [
673 {
674 version: 1,
675 contextId: annotationId,
676 kind: "preview-annotation",
677 label: "Fix button",
678 annotationId: "ann-1",
679 pageUrl: "https://example.com",
680 pageTitle: "Example",
681 comment: "Fix button",
682 targetSummary: "1 selected element",
683 styleChanges: [],
684 screenshotContextId: screenshotId,
685 },
686 {
687 version: 1,
688 contextId: screenshotId,
689 kind: "image",
690 label: "annotation.png",
691 attachmentId: "attachment-1",
692 name: "annotation.png",
693 mimeType: "image/png",
694 sizeBytes: 10,
695 },
696 {
697 version: 1,
698 contextId: "image_unrelated",
699 kind: "image",
700 label: "unrelated.png",
701 attachmentId: "attachment-2",
702 name: "unrelated.png",
703 mimeType: "image/png",
704 sizeBytes: 10,
705 },
706 ],
707 }),
708 },
709 );
710 editor.update(
711 () => {
712 editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
713 },
714 { discrete: true },
715 );
716
717 expect(imported).toEqual([annotationId, screenshotId]);
718 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
719 `<context:${annotationId}>`,
720 );
721 });
722
723 it("turns pasted context links into reference nodes", () => {
724 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
725 const editor = createCitationEditor("see ");
726 pasteText(editor, "[Terminal 1 line 4](t3-context://v1/terminal/ctx-1) now");
727 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
728 "see <context:ctx-1> now",
729 );
730 });
731});
732
733describe("citation comment opening", () => {
734 const sourceAnchor: AssistantCitationSourceAnchor = {
735 source: { nodeType: 1 } as HTMLElement,
736 range: { collapsed: false } as Range,
737 viewport: { nodeType: 1 } as HTMLElement,
738 };
739 afterEach(() => {
740 vi.unstubAllGlobals();
741 });
742
743 it("opens only the newly inserted occurrence after committing its exact prompt", () => {
744 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
745 const editor = createCitationEditor();
746 const previousValue = `前 ${citationSource} middle\nbefore after ${citationSource} 後`;
747 pasteText(editor, previousValue);
748 const citationStart = `前 ${citationSource} middle\nbefore `.length;
749 const value =
750 previousValue.slice(0, citationStart) +
751 `${citationSource} ` +
752 previousValue.slice(citationStart);
753 const request: ComposerCitationCommentRequest = {
754 previousValue,
755 value,
756 citationStart,
757 sourceAnchor,
758 };
759 const requestRef = { current: request as ComposerCitationCommentRequest | null };
760
761 editor.getEditorState().read(() => {
762 expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull();
763 expect(requestRef.current).toBe(request);
764 });
765 editor.update(
766 () => {
767 const text = $getRoot()
768 .getAllTextNodes()
769 .find((node) => node.getTextContent() === "before after ");
770 if (!text) throw new Error("Expected insertion text");
771 text.select("before ".length, "before ".length);
772 },
773 { discrete: true },
774 );
775 pasteText(editor, `${citationSource} `);
776
777 editor.getEditorState().read(() => {
778 const selection = $getSelection()?.clone();
779 expect($getRoot().getTextContent()).toBe(value);
780 expect($citationNodes()).toHaveLength(3);
781 const target = $consumeComposerCitationCommentRequest(requestRef);
782 expect(target?.nodeKey).toBe($citationNodes()[1]!.getKey());
783 expect(target?.sourceAnchor).toBe(sourceAnchor);
784 expect($getSelection()?.is(selection!)).toBe(true);
785 expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull();
786 });
787 });
788
789 it("does not reopen comments when an inserted citation is restored or pasted", () => {
790 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
791 const editor = createCitationEditor("before ");
792 const previousState = editor.getEditorState();
793 const value = `before ${citationSource} `;
794 const requestRef: { current: ComposerCitationCommentRequest | null } = {
795 current: { previousValue: "before ", value, citationStart: "before ".length, sourceAnchor },
796 };
797 pasteText(editor, `${citationSource} `);
798 const insertedState = editor.getEditorState();
799 insertedState.read(() => {
800 expect($consumeComposerCitationCommentRequest(requestRef)?.nodeKey).toBe(
801 $citationNodes()[0]!.getKey(),
802 );
803 });
804 editor.update(() => $citationNodes()[0]!.setComment("Keep this attached."), {
805 discrete: true,
806 });
807 const commentedState = editor.getEditorState();
808
809 for (const restoredState of [previousState, insertedState, commentedState]) {
810 editor.setEditorState(restoredState);
811 editor.getEditorState().read(() => {
812 expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull();
813 });
814 }
815
816 const reloaded = createCitationEditor();
817 reloaded.setEditorState(reloaded.parseEditorState(commentedState.toJSON()));
818 reloaded.getEditorState().read(() => {
819 expect($consumeComposerCitationCommentRequest({ current: null })).toBeNull();
820 expect($citationNodes()[0]!.exportJSON().citation.comment).toBe("Keep this attached.");
821 });
822 const pasted = createCitationEditor();
823 pasteText(
824 pasted,
825 commentedState.read(() => $getRoot().getTextContent()),
826 );
827 pasted.getEditorState().read(() => {
828 expect($consumeComposerCitationCommentRequest({ current: null })).toBeNull();
829 expect($citationNodes()[0]!.exportJSON().citation.comment).toBe("Keep this attached.");
830 });
831 });
832
833 it("discards an opening request if another prompt replaces the insertion", () => {
834 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
835 const editor = createCitationEditor();
836 const requestRef: { current: ComposerCitationCommentRequest | null } = {
837 current: { previousValue: "", value: citationSource, citationStart: 0, sourceAnchor },
838 };
839 editor.update(
840 () => {
841 const selection = $getSelection();
842 if (!$isRangeSelection(selection)) throw new Error("Expected an insertion point");
843 selection.insertText("A different draft");
844 },
845 { discrete: true },
846 );
847 editor.getEditorState().read(() => {
848 expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull();
849 });
850 editor.update(
851 () => {
852 const paragraph = $getRoot().getFirstChildOrThrow();
853 if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph");
854 paragraph.clear().selectStart();
855 },
856 { discrete: true },
857 );
858 pasteText(editor, citationSource);
859 editor.getEditorState().read(() => {
860 expect($getRoot().getTextContent()).toBe(citationSource);
861 expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull();
862 });
863 });
864});
apps/web/src/components/ComposerPromptEditor.tsx+12-2132
......@@ -1,2137 +1,17 @@
1import { ContextChipPopover } from "./contextChipParts";
2import { Button } from "./ui/button";
3import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer";
4import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
5import { ContentEditable } from "@lexical/react/LexicalContentEditable";
6import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
7import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
8import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
9import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
10import {
11 type ComposerContextClipboardFragment,
12 type ServerProviderSkill,
13} from "@t3tools/contracts";
14import {
15 COMPOSER_CONTEXT_CLIPBOARD_MIME,
16 encodeComposerContextClipboardHtml,
17} from "@t3tools/shared/composerContextClipboard";
18import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
19import {
20 $applyNodeReplacement,
21 $createRangeSelectionFromDom,
22 $createRangeSelection,
23 $getSelection,
24 $setSelection,
25 $isElementNode,
26 $isLineBreakNode,
27 $isRangeSelection,
28 $isTextNode,
29 $createLineBreakNode,
30 $createParagraphNode,
31 $createTextNode,
32 KEY_ARROW_DOWN_COMMAND,
33 KEY_ARROW_LEFT_COMMAND,
34 KEY_ARROW_RIGHT_COMMAND,
35 KEY_ARROW_UP_COMMAND,
36 KEY_DOWN_COMMAND,
37 KEY_ENTER_COMMAND,
38 KEY_TAB_COMMAND,
39 COMMAND_PRIORITY_HIGH,
40 COPY_COMMAND,
41 CUT_COMMAND,
42 COMMAND_PRIORITY_LOW,
43 KEY_BACKSPACE_COMMAND,
44 BLUR_COMMAND,
45 FOCUS_COMMAND,
46 $getRoot,
47 $getNodeByKey,
48 HISTORY_MERGE_TAG,
49 HISTORY_PUSH_TAG,
50 SKIP_DOM_SELECTION_TAG,
51 DecoratorNode,
52 type ElementNode,
53 type LexicalNode,
54 type SerializedLexicalNode,
55 type EditorState,
56 type NodeKey,
57 type Spread,
58} from "lexical";
59import {
60 createContext,
61 use,
62 useCallback,
63 useEffect,
64 useEffectEvent,
65 useImperativeHandle,
66 useLayoutEffect,
67 useMemo,
68 useRef,
69 useState,
70} from "react";
1import { ComposerPromptEditorTiptap } from "./ComposerPromptEditorTiptap";
2import type { ComposerPromptEditorProps } from "./ComposerPromptEditorTiptap";
713
72import {
73 clampCollapsedComposerCursor,
74 collapseExpandedComposerCursor,
75 expandCollapsedComposerCursor,
76 isCollapsedCursorAdjacentToInlineToken,
77} from "~/composer-logic";
78import {
79 selectionTouchesMentionBoundary,
80 splitPromptIntoComposerSegments,
81} from "~/composer-editor-mentions";
82import { collectInlineContextIds } from "~/lib/composerContextReferences";
83import { cn, isMacPlatform } from "~/lib/utils";
84import { basenameOfPath } from "~/pierre-icons";
85import {
86 COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME,
87 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
88 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
89 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
90 SKILL_CHIP_ICON_SVG,
91} from "./composerInlineChip";
92import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
93import { getTimelinePageScrollKey } from "./chat/pageScrollController";
94import {
95 $createComposerContextReferenceNode,
96 ComposerContextReferenceNode,
97} from "./ComposerContextReferenceNode";
98import {
99 ComposerContextActionsContext,
100 ComposerContextRecordsContext,
101 type ComposerDraftContextRecords,
102} from "./composerContextPresentation";
103import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
104import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
105import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
106import { didComposerSelectionChangeVisibly } from "./composerSelection";
107import {
108 $consumeComposerCitationCommentRequest,
109 $createComposerCitationNode,
110 ComposerCitationCommentContext,
111 ComposerCitationNode,
112 type ComposerCitationCommentRequest,
113 type ComposerCitationCommentTarget,
114} from "./ComposerCitationNode";
115
116const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;
117const SURROUND_SYMBOLS: [string, string][] = [
118 ["(", ")"],
119 ["[", "]"],
120 ["{", "}"],
121 ["'", "'"],
122 ['"', '"'],
123 ["“", "”"],
124 ["`", "`"],
125 ["<", ">"],
126 ["«", "»"],
127 ["*", "*"],
128 ["_", "_"],
129];
130const SURROUND_SYMBOLS_MAP = new Map<string, string>(SURROUND_SYMBOLS);
131const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null;
132
133type SerializedComposerMentionNode = Spread<
134 {
135 path: string;
136 source?: string;
137 type: "composer-mention";
138 version: 1;
139 },
140 SerializedLexicalNode
141>;
142
143type SerializedComposerSkillNode = Spread<
144 {
145 skillName: string;
146 skillLabel?: string;
147 skillDescription?: string;
148 type: "composer-skill";
149 version: 1;
150 },
151 SerializedLexicalNode
152>;
153
154function ComposerMentionDecorator(props: { path: string }) {
155 const actions = use(ComposerContextActionsContext);
156 const theme = resolvedThemeFromDocument();
157 const chip = (
158 <button
159 type="button"
160 onClick={() => actions.openMention(props.path)}
161 aria-label={`Preview ${props.path}`}
162 className={`${FILE_TAG_CHIP_CLASS_NAME} cursor-pointer focus-visible:outline-2`}
163 contentEditable={false}
164 spellCheck={false}
165 data-composer-mention-chip="true"
166 >
167 <FileTagChipContent path={props.path} label={basenameOfPath(props.path)} theme={theme} />
168 </button>
169 );
170
171 return (
172 <Tooltip>
173 <TooltipTrigger render={chip} />
174 <TooltipPopup side="top" className="max-w-120 whitespace-normal leading-tight wrap-anywhere">
175 {props.path}
176 </TooltipPopup>
177 </Tooltip>
178 );
179}
180
181class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
182 __path: string;
183 __source: string;
184
185 static override getType(): string {
186 return "composer-mention";
187 }
188
189 static override clone(node: ComposerMentionNode): ComposerMentionNode {
190 return new ComposerMentionNode(node.__path, node.__source, node.__key);
191 }
192
193 static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode {
194 return $createComposerMentionNode(serializedNode.path, serializedNode.source).updateFromJSON(
195 serializedNode,
196 );
197 }
198
199 constructor(path: string, source = serializeComposerFileLink(path), key?: NodeKey) {
200 super(key);
201 this.__path = path;
202 this.__source = source;
203 }
204
205 override exportJSON(): SerializedComposerMentionNode {
206 return {
207 ...super.exportJSON(),
208 path: this.__path,
209 source: this.__source,
210 type: "composer-mention",
211 version: 1,
212 };
213 }
214
215 override createDOM(): HTMLElement {
216 const dom = document.createElement("span");
217 dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME;
218 return dom;
219 }
220
221 override updateDOM(): false {
222 return false;
223 }
224
225 override getTextContent(): string {
226 return this.__source;
227 }
228
229 override isInline(): true {
230 return true;
231 }
232
233 override decorate(): React.ReactElement {
234 return <ComposerMentionDecorator path={this.__path} />;
235 }
236}
237
238function $createComposerMentionNode(path: string, source?: string): ComposerMentionNode {
239 return $applyNodeReplacement(new ComposerMentionNode(path, source));
240}
241
242function resolveSkillDescription(
243 skill: Pick<ServerProviderSkill, "shortDescription" | "description">,
244): string | null {
245 const shortDescription = skill.shortDescription?.trim();
246 if (shortDescription) {
247 return shortDescription;
248 }
249 const description = skill.description?.trim();
250 return description || null;
251}
252
253type ComposerSkillMetadata = {
254 label: string;
255 description: string | null;
256};
257
258function skillMetadataByName(
259 skills: ReadonlyArray<ServerProviderSkill>,
260): ReadonlyMap<string, ComposerSkillMetadata> {
261 return new Map(
262 skills.map((skill) => [
263 skill.name,
264 {
265 label: formatProviderSkillDisplayName(skill),
266 description: resolveSkillDescription(skill),
267 },
268 ]),
269 );
270}
271
272const ComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]);
273
274function ComposerSkillDecorator(props: {
275 skillName: string;
276 skillLabel: string;
277 skillDescription: string | null;
278}) {
279 const actions = use(ComposerContextActionsContext);
280 const skill = use(ComposerSkillsContext).find((candidate) => candidate.name === props.skillName);
281 return (
282 <ContextChipPopover
283 accessibleLabel={`Skill ${props.skillLabel}`}
284 triggerClassName={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}
285 chip={
286 <>
287 <span
288 aria-hidden="true"
289 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
290 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
291 />
292 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{props.skillLabel}</span>
293 </>
294 }
295 >
296 <div className="space-y-3 p-2 text-sm">
297 <p className="font-medium">{props.skillLabel}</p>
298 <p>
299 {skill?.description ??
300 props.skillDescription ??
301 "No description is available for this skill."}
302 </p>
303 {skill?.path ? (
304 <Button variant="outline" size="sm" onClick={() => actions.openMention(skill.path)}>
305 View instructions
306 </Button>
307 ) : null}
308 </div>
309 </ContextChipPopover>
310 );
311}
312
313class ComposerSkillNode extends DecoratorNode<React.ReactElement> {
314 __skillName: string;
315 __skillLabel: string;
316 __skillDescription: string | null;
317
318 static override getType(): string {
319 return "composer-skill";
320 }
321
322 static override clone(node: ComposerSkillNode): ComposerSkillNode {
323 return new ComposerSkillNode(
324 node.__skillName,
325 node.__skillLabel,
326 node.__skillDescription,
327 node.__key,
328 );
329 }
330
331 static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode {
332 return $createComposerSkillNode(
333 serializedNode.skillName,
334 serializedNode.skillLabel ?? serializedNode.skillName,
335 serializedNode.skillDescription ?? null,
336 ).updateFromJSON(serializedNode);
337 }
338
339 constructor(
340 skillName: string,
341 skillLabel: string,
342 skillDescription: string | null,
343 key?: NodeKey,
344 ) {
345 super(key);
346 const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName;
347 this.__skillName = normalizedSkillName;
348 this.__skillLabel = skillLabel;
349 this.__skillDescription = skillDescription;
350 }
351
352 override exportJSON(): SerializedComposerSkillNode {
353 return {
354 ...super.exportJSON(),
355 skillName: this.__skillName,
356 skillLabel: this.__skillLabel,
357 ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}),
358 type: "composer-skill",
359 version: 1,
360 };
361 }
362
363 override createDOM(): HTMLElement {
364 const dom = document.createElement("span");
365 dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME;
366 return dom;
367 }
368
369 override updateDOM(): false {
370 return false;
371 }
372
373 override getTextContent(): string {
374 return `$${this.__skillName}`;
375 }
376
377 override isInline(): true {
378 return true;
379 }
380
381 override decorate(): React.ReactElement {
382 return (
383 <ComposerSkillDecorator
384 skillName={this.__skillName}
385 skillLabel={this.__skillLabel}
386 skillDescription={this.__skillDescription}
387 />
388 );
389 }
390}
391
392function $createComposerSkillNode(
393 skillName: string,
394 skillLabel: string,
395 skillDescription: string | null,
396): ComposerSkillNode {
397 return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription));
398}
399
400type ComposerInlineTokenNode =
401 | ComposerMentionNode
402 | ComposerSkillNode
403 | ComposerCitationNode
404 | ComposerContextReferenceNode;
405
406function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode {
407 return (
408 candidate instanceof ComposerMentionNode ||
409 candidate instanceof ComposerSkillNode ||
410 candidate instanceof ComposerCitationNode ||
411 candidate instanceof ComposerContextReferenceNode
412 );
413}
414
415function resolvedThemeFromDocument(): "light" | "dark" {
416 return document.documentElement.classList.contains("dark") ? "dark" : "light";
417}
418
419function skillSignature(skills: ReadonlyArray<ServerProviderSkill>): string {
420 return skills
421 .map((skill) =>
422 [
423 skill.name,
424 skill.displayName ?? "",
425 skill.shortDescription ?? "",
426 skill.description ?? "",
427 skill.path,
428 skill.scope ?? "",
429 skill.enabled ? "1" : "0",
430 ].join("\u001f"),
431 )
432 .join("\u001e");
433}
434
435function clampExpandedCursor(value: string, cursor: number): number {
436 if (!Number.isFinite(cursor)) return value.length;
437 return Math.max(0, Math.min(value.length, Math.floor(cursor)));
438}
439
440function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 {
441 return 1;
442}
443
444function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number {
445 return node.getTextContentSize();
446}
447
448function getAbsoluteOffsetForInlineTokenPoint(
449 node: ComposerInlineTokenNode,
450 absoluteOffset: number,
451 pointOffset: number,
452): number {
453 return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0);
454}
455
456function getExpandedAbsoluteOffsetForInlineTokenPoint(
457 node: ComposerInlineTokenNode,
458 absoluteOffset: number,
459 pointOffset: number,
460): number {
461 return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0);
462}
463
464function findSelectionPointForInlineToken(
465 node: ComposerInlineTokenNode,
466 remainingRef: { value: number },
467): { key: string; offset: number; type: "element" } | null {
468 const parent = node.getParent();
469 if (!parent || !$isElementNode(parent)) return null;
470 const index = node.getIndexWithinParent();
471 if (remainingRef.value === 0) {
472 return {
473 key: parent.getKey(),
474 offset: index,
475 type: "element",
476 };
477 }
478 if (remainingRef.value === getComposerInlineTokenTextLength(node)) {
479 return {
480 key: parent.getKey(),
481 offset: index + 1,
482 type: "element",
483 };
484 }
485 remainingRef.value -= getComposerInlineTokenTextLength(node);
486 return null;
487}
488
489function getComposerNodeTextLength(node: LexicalNode): number {
490 if (isComposerInlineTokenNode(node)) {
491 return getComposerInlineTokenTextLength(node);
492 }
493 if ($isTextNode(node)) {
494 return node.getTextContentSize();
495 }
496 if ($isLineBreakNode(node)) {
497 return 1;
498 }
499 if ($isElementNode(node)) {
500 return node.getChildren().reduce((total, child) => total + getComposerNodeTextLength(child), 0);
501 }
502 return 0;
503}
504
505function getComposerNodeExpandedTextLength(node: LexicalNode): number {
506 if (isComposerInlineTokenNode(node)) {
507 return getComposerInlineTokenExpandedTextLength(node);
508 }
509 if ($isTextNode(node)) {
510 return node.getTextContentSize();
511 }
512 if ($isLineBreakNode(node)) {
513 return 1;
514 }
515 if ($isElementNode(node)) {
516 return node
517 .getChildren()
518 .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0);
519 }
520 return 0;
521}
522
523function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number {
524 let offset = 0;
525 let current: LexicalNode | null = node;
526
527 while (current) {
528 const nextParent = current.getParent() as LexicalNode | null;
529 if (!nextParent || !$isElementNode(nextParent)) {
530 break;
531 }
532 const siblings = nextParent.getChildren();
533 const index = current.getIndexWithinParent();
534 for (let i = 0; i < index; i += 1) {
535 const sibling = siblings[i];
536 if (!sibling) continue;
537 offset += getComposerNodeTextLength(sibling);
538 }
539 current = nextParent;
540 }
541
542 if ($isTextNode(node)) {
543 return offset + Math.min(pointOffset, node.getTextContentSize());
544 }
545 if (isComposerInlineTokenNode(node)) {
546 return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
547 }
548
549 if ($isLineBreakNode(node)) {
550 return offset + Math.min(pointOffset, 1);
551 }
552
553 if ($isElementNode(node)) {
554 const children = node.getChildren();
555 const clampedOffset = Math.max(0, Math.min(pointOffset, children.length));
556 for (let i = 0; i < clampedOffset; i += 1) {
557 const child = children[i];
558 if (!child) continue;
559 offset += getComposerNodeTextLength(child);
560 }
561 return offset;
562 }
563
564 return offset;
565}
566
567function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number {
568 let offset = 0;
569 let current: LexicalNode | null = node;
570
571 while (current) {
572 const nextParent = current.getParent() as LexicalNode | null;
573 if (!nextParent || !$isElementNode(nextParent)) {
574 break;
575 }
576 const siblings = nextParent.getChildren();
577 const index = current.getIndexWithinParent();
578 for (let i = 0; i < index; i += 1) {
579 const sibling = siblings[i];
580 if (!sibling) continue;
581 offset += getComposerNodeExpandedTextLength(sibling);
582 }
583 current = nextParent;
584 }
585
586 if ($isTextNode(node)) {
587 return offset + Math.min(pointOffset, node.getTextContentSize());
588 }
589 if (isComposerInlineTokenNode(node)) {
590 return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
591 }
592
593 if ($isLineBreakNode(node)) {
594 return offset + Math.min(pointOffset, 1);
595 }
596
597 if ($isElementNode(node)) {
598 const children = node.getChildren();
599 const clampedOffset = Math.max(0, Math.min(pointOffset, children.length));
600 for (let i = 0; i < clampedOffset; i += 1) {
601 const child = children[i];
602 if (!child) continue;
603 offset += getComposerNodeExpandedTextLength(child);
604 }
605 return offset;
606 }
607
608 return offset;
609}
610
611function findSelectionPointAtOffset(
612 node: LexicalNode,
613 remainingRef: { value: number },
614): { key: string; offset: number; type: "text" | "element" } | null {
615 if (isComposerInlineTokenNode(node)) {
616 return findSelectionPointForInlineToken(node, remainingRef);
617 }
618
619 if ($isTextNode(node)) {
620 const size = node.getTextContentSize();
621 if (remainingRef.value <= size) {
622 return {
623 key: node.getKey(),
624 offset: remainingRef.value,
625 type: "text",
626 };
627 }
628 remainingRef.value -= size;
629 return null;
630 }
631
632 if ($isLineBreakNode(node)) {
633 const parent = node.getParent();
634 if (!parent) return null;
635 const index = node.getIndexWithinParent();
636 if (remainingRef.value === 0) {
637 return {
638 key: parent.getKey(),
639 offset: index,
640 type: "element",
641 };
642 }
643 if (remainingRef.value === 1) {
644 return {
645 key: parent.getKey(),
646 offset: index + 1,
647 type: "element",
648 };
649 }
650 remainingRef.value -= 1;
651 return null;
652 }
653
654 if ($isElementNode(node)) {
655 const children = node.getChildren();
656 for (const child of children) {
657 const point = findSelectionPointAtOffset(child, remainingRef);
658 if (point) {
659 return point;
660 }
661 }
662 if (remainingRef.value === 0) {
663 return {
664 key: node.getKey(),
665 offset: children.length,
666 type: "element",
667 };
668 }
669 }
670
671 return null;
672}
673
674function $getComposerRootLength(): number {
675 const root = $getRoot();
676 const children = root.getChildren();
677 return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0);
678}
679
680function $setSelectionAtComposerOffset(nextOffset: number): void {
681 const root = $getRoot();
682 const composerLength = $getComposerRootLength();
683 const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength));
684 const remainingRef = { value: boundedOffset };
685 const point = findSelectionPointAtOffset(root, remainingRef) ?? {
686 key: root.getKey(),
687 offset: root.getChildren().length,
688 type: "element" as const,
689 };
690 const selection = $createRangeSelection();
691 selection.anchor.set(point.key, point.offset, point.type);
692 selection.focus.set(point.key, point.offset, point.type);
693 $setSelection(selection);
694}
695
696function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: number): void {
697 const root = $getRoot();
698 const composerLength = $getComposerRootLength();
699 const boundedStart = Math.max(0, Math.min(startOffset, composerLength));
700 const boundedEnd = Math.max(0, Math.min(endOffset, composerLength));
701 const anchorRemainingRef = { value: boundedStart };
702 const focusRemainingRef = { value: boundedEnd };
703 const anchorPoint = findSelectionPointAtOffset(root, anchorRemainingRef) ?? {
704 key: root.getKey(),
705 offset: root.getChildren().length,
706 type: "element" as const,
707 };
708 const focusPoint = findSelectionPointAtOffset(root, focusRemainingRef) ?? {
709 key: root.getKey(),
710 offset: root.getChildren().length,
711 type: "element" as const,
712 };
713 const selection = $createRangeSelection();
714 selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type);
715 selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type);
716 $setSelection(selection);
717}
718
719function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType<typeof $getSelection>): {
720 start: number;
721 end: number;
722} | null {
723 if (!$isRangeSelection(selection)) {
724 return null;
725 }
726 const anchorNode = selection.anchor.getNode();
727 const focusNode = selection.focus.getNode();
728 const anchorOffset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset);
729 const focusOffset = getExpandedAbsoluteOffsetForPoint(focusNode, selection.focus.offset);
730 return {
731 start: Math.min(anchorOffset, focusOffset),
732 end: Math.max(anchorOffset, focusOffset),
733 };
734}
735
736function $selectionTouchesInlineToken(selection: ReturnType<typeof $getSelection>): boolean {
737 if (!$isRangeSelection(selection)) {
738 return false;
739 }
740 return selection.getNodes().some((node) => isComposerInlineTokenNode(node));
741}
742
743function $readSelectionOffsetFromEditorState(fallback: number): number {
744 const selection = $getSelection();
745 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
746 return fallback;
747 }
748 const anchorNode = selection.anchor.getNode();
749 const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset);
750 const composerLength = $getComposerRootLength();
751 return Math.max(0, Math.min(offset, composerLength));
752}
753
754function $readExpandedSelectionOffsetFromEditorState(fallback: number): number {
755 const selection = $getSelection();
756 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
757 return fallback;
758 }
759 const anchorNode = selection.anchor.getNode();
760 const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset);
761 const expandedLength = $getRoot().getTextContent().length;
762 return Math.max(0, Math.min(offset, expandedLength));
763}
764
765function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {
766 const lines = text.split("\n");
767 for (let index = 0; index < lines.length; index += 1) {
768 const line = lines[index] ?? "";
769 if (line.length > 0) {
770 parent.append($createTextNode(line));
771 }
772 if (index < lines.length - 1) {
773 parent.append($createLineBreakNode());
774 }
775 }
776}
777
778function $setComposerEditorPrompt(
779 prompt: string,
780 skillMetadata: ReadonlyMap<string, ComposerSkillMetadata>,
781): void {
782 const root = $getRoot();
783 root.clear();
784 const paragraph = $createParagraphNode();
785 root.append(paragraph);
786
787 const segments = splitPromptIntoComposerSegments(prompt);
788 for (const segment of segments) {
789 if (segment.type === "citation") {
790 paragraph.append($createComposerCitationNode(segment.citation, segment.source));
791 continue;
792 }
793 if (segment.type === "mention") {
794 paragraph.append($createComposerMentionNode(segment.path, segment.source));
795 continue;
796 }
797 if (segment.type === "skill") {
798 const metadata = skillMetadata.get(segment.name);
799 paragraph.append(
800 $createComposerSkillNode(
801 segment.name,
802 metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }),
803 metadata?.description ?? null,
804 ),
805 );
806 continue;
807 }
808 if (segment.type === "context-reference") {
809 paragraph.append(
810 $createComposerContextReferenceNode({
811 kind: segment.kind,
812 contextId: segment.contextId,
813 label: segment.label,
814 }),
815 );
816 continue;
817 }
818 $appendTextWithLineBreaks(paragraph, segment.text);
819 }
820}
821
822function collectContextIdOccurrences(node: LexicalNode): string[] {
823 if (node instanceof ComposerContextReferenceNode) {
824 return [node.__contextId];
825 }
826 if ($isElementNode(node)) {
827 return node.getChildren().flatMap((child) => collectContextIdOccurrences(child));
828 }
829 return [];
830}
831
832/** Payload ids referenced by the document, once each in first-occurrence order. */
833function collectContextIds(node: LexicalNode): string[] {
834 return Array.from(new Set(collectContextIdOccurrences(node)));
835}
836
837export interface ComposerPromptEditorHandle {
838 focus: () => void;
839 focusAt: (cursor: number) => void;
840 focusAtEnd: () => void;
841 readSelectionRange: () => { start: number; end: number };
842 requestCitationComment: (request: ComposerCitationCommentRequest) => void;
843 readSnapshot: () => {
844 value: string;
845 cursor: number;
846 expandedCursor: number;
847 contextIds: string[];
848 };
849 /**
850 * True when a collapsed caret sits on the first ("start") or last ("end")
851 * visual line, counting soft wraps. Prompt history only claims ArrowUp and
852 * ArrowDown at these edges so arrows still move the caret inside multiline
853 * text.
854 */
855 isCaretOnVisualEdge: (edge: "start" | "end") => boolean;
856}
857
858interface ComposerPromptEditorProps {
859 value: string;
860 cursor: number;
861 /** Draft records behind the prompt's context references, keyed by context id. */
862 contextRecords: ComposerDraftContextRecords;
863 /** Structured clipboard payload for the given referenced ids, or null to skip. */
864 buildContextClipboardFragment?:
865 | ((contextIds: ReadonlyArray<string>) => string | null)
866 | undefined;
867 /** Imports a structured paste's records; returns ids that changed. */
868 importContextFragment?:
869 | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap<string, string>)
870 | undefined;
871 skills: ReadonlyArray<ServerProviderSkill>;
872 disabled: boolean;
873 placeholder: string;
874 containerClassName?: string;
875 className?: string;
876 placeholderClassName?: string;
877 onChange: (
878 nextValue: string,
879 nextCursor: number,
880 expandedCursor: number,
881 cursorAdjacentToMention: boolean,
882 contextIds: string[],
883 ) => void;
884 onVisibleSelectionChange?: () => void;
885 onCommandKeyDown?: (
886 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
887 event: KeyboardEvent,
888 ) => boolean;
889 onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void;
890 onPageScrollKeyUp?: (key: string) => void;
891 onPageScrollRelease?: () => void;
892 onCitationSubmitAndSend?: () => void;
893 onPaste: React.ClipboardEventHandler<HTMLElement>;
894 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
895}
4export type {
5 ComposerCitationCommentRequest,
6 ComposerPromptEditorHandle,
7 ComposerPromptEditorProps,
8} from "./ComposerPromptEditorTiptap";
8969
89710/**
898 * Client rect of the line the collapsed caret is on, as seen from `edge`.
899 * A caret at a soft-wrap boundary belongs to two visual lines and the
900 * range reports a rect for each, so take the one farthest from the edge
901 * under test: an ambiguous caret then never claims the key and the arrow
902 * moves the caret as usual. A collapsed range reports zero-height rects at
903 * some positions, so probe the adjacent character on the same side. When
904 * the range container is the paragraph itself (an empty line, or a caret
905 * beside an inline chip) measure the child next to the caret before
906 * falling back to the paragraph.
11 * The composer editor. Tiptap in both modes: the `richTextEnabled` setting
12 * toggles Markdown styling, never the engine. Plain mode renders every
13 * marker as a literal character and serializes byte-identically.
90714 */
908function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null {
909 const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0);
910 const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0];
911 if (collapsedRect) return collapsedRect;
912
913 const container = range.startContainer;
914 if (container.nodeType === Node.TEXT_NODE) {
915 const textNode = container as Text;
916 if (textNode.data.length === 0) return null;
917 const probeStart = Math.max(
918 0,
919 Math.min(
920 edge === "start" ? range.startOffset : range.startOffset - 1,
921 textNode.data.length - 1,
922 ),
923 );
924 const probeRange = document.createRange();
925 probeRange.setStart(textNode, probeStart);
926 probeRange.setEnd(textNode, probeStart + 1);
927 const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0);
928 if (probeRect) return probeRect;
929 const boundingRect = probeRange.getBoundingClientRect();
930 return boundingRect.height > 0 ? boundingRect : null;
931 }
932
933 if (!(container instanceof HTMLElement)) return null;
934 // The caret sits between the paragraph's children, which is where Lexical
935 // puts it next to an inline chip. Measure the neighbouring child.
936 const neighbour =
937 container.childNodes[Math.max(0, range.startOffset - 1)] ??
938 container.childNodes[range.startOffset];
939 if (neighbour instanceof HTMLElement) {
940 const neighbourRect = neighbour.getBoundingClientRect();
941 if (neighbourRect.height > 0) return neighbourRect;
942 } else if (neighbour instanceof Text && neighbour.data.length > 0) {
943 // Probe the character on the caret's side. A soft-wrapped text node's
944 // first rect is its first visual line, which may not be the caret's.
945 const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1];
946 const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0;
947 const probeRange = document.createRange();
948 probeRange.setStart(neighbour, probeStart);
949 probeRange.setEnd(neighbour, probeStart + 1);
950 const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0);
951 if (probeRect) return probeRect;
952 }
953 const containerRect = container.getBoundingClientRect();
954 return containerRect.height > 0 ? containerRect : null;
955}
956
957function ComposerCommandKeyPlugin(props: {
958 onCommandKeyDown?: (
959 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
960 event: KeyboardEvent,
961 ) => boolean;
962}) {
963 const [editor] = useLexicalComposerContext();
964
965 useEffect(() => {
966 const handleCommand = (
967 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
968 event: KeyboardEvent | null,
969 ): boolean => {
970 if (!props.onCommandKeyDown || !event) {
971 return false;
972 }
973
974 if (key === "Enter" && (event.isComposing || event.keyCode === 229)) {
975 event.stopPropagation();
976 return true;
977 }
978
979 const handled = props.onCommandKeyDown(key, event);
980 if (handled) {
981 event.preventDefault();
982 event.stopPropagation();
983 }
984 return handled;
985 };
986
987 const unregisterArrowDown = editor.registerCommand(
988 KEY_ARROW_DOWN_COMMAND,
989 (event) => handleCommand("ArrowDown", event),
990 COMMAND_PRIORITY_HIGH,
991 );
992 const unregisterArrowUp = editor.registerCommand(
993 KEY_ARROW_UP_COMMAND,
994 (event) => handleCommand("ArrowUp", event),
995 COMMAND_PRIORITY_HIGH,
996 );
997 const unregisterEnter = editor.registerCommand(
998 KEY_ENTER_COMMAND,
999 (event) => handleCommand("Enter", event),
1000 COMMAND_PRIORITY_HIGH,
1001 );
1002 const unregisterTab = editor.registerCommand(
1003 KEY_TAB_COMMAND,
1004 (event) => handleCommand("Tab", event),
1005 COMMAND_PRIORITY_HIGH,
1006 );
1007
1008 return () => {
1009 unregisterArrowDown();
1010 unregisterArrowUp();
1011 unregisterEnter();
1012 unregisterTab();
1013 };
1014 }, [editor, props]);
1015
1016 return null;
1017}
1018
1019function ComposerInlineTokenArrowPlugin() {
1020 const [editor] = useLexicalComposerContext();
1021
1022 useEffect(() => {
1023 const unregisterLeft = editor.registerCommand(
1024 KEY_ARROW_LEFT_COMMAND,
1025 (event) => {
1026 let nextOffset: number | null = null;
1027 editor.getEditorState().read(() => {
1028 const selection = $getSelection();
1029 if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
1030 const currentOffset = $readSelectionOffsetFromEditorState(0);
1031 if (currentOffset <= 0) return;
1032 const promptValue = $getRoot().getTextContent();
1033 if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) {
1034 return;
1035 }
1036 nextOffset = currentOffset - 1;
1037 });
1038 if (nextOffset === null) return false;
1039 const selectionOffset = nextOffset;
1040 event?.preventDefault();
1041 event?.stopPropagation();
1042 editor.update(() => {
1043 $setSelectionAtComposerOffset(selectionOffset);
1044 });
1045 return true;
1046 },
1047 COMMAND_PRIORITY_HIGH,
1048 );
1049 const unregisterRight = editor.registerCommand(
1050 KEY_ARROW_RIGHT_COMMAND,
1051 (event) => {
1052 let nextOffset: number | null = null;
1053 editor.getEditorState().read(() => {
1054 const selection = $getSelection();
1055 if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
1056 const currentOffset = $readSelectionOffsetFromEditorState(0);
1057 const composerLength = $getComposerRootLength();
1058 if (currentOffset >= composerLength) return;
1059 const promptValue = $getRoot().getTextContent();
1060 if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) {
1061 return;
1062 }
1063 nextOffset = currentOffset + 1;
1064 });
1065 if (nextOffset === null) return false;
1066 const selectionOffset = nextOffset;
1067 event?.preventDefault();
1068 event?.stopPropagation();
1069 editor.update(() => {
1070 $setSelectionAtComposerOffset(selectionOffset);
1071 });
1072 return true;
1073 },
1074 COMMAND_PRIORITY_HIGH,
1075 );
1076 return () => {
1077 unregisterLeft();
1078 unregisterRight();
1079 };
1080 }, [editor]);
1081
1082 return null;
1083}
1084
1085function ComposerHomeEndKeyPlugin() {
1086 const [editor] = useLexicalComposerContext();
1087
1088 useEffect(() => {
1089 return editor.registerCommand(
1090 KEY_DOWN_COMMAND,
1091 (event) => {
1092 if (!isMacPlatform(navigator.platform)) {
1093 return false;
1094 }
1095 if (event.key !== "Home" && event.key !== "End") {
1096 return false;
1097 }
1098 if (event.altKey || event.metaKey || event.ctrlKey || event.isComposing) {
1099 return false;
1100 }
1101
1102 const rootElement = editor.getRootElement();
1103 const selection = window.getSelection();
1104 const anchorNode = selection?.anchorNode;
1105 if (!rootElement || !selection || !anchorNode || !rootElement.contains(anchorNode)) {
1106 return false;
1107 }
1108 if (selection.rangeCount === 0 || typeof selection.modify !== "function") {
1109 return false;
1110 }
1111
1112 event.preventDefault();
1113 event.stopPropagation();
1114
1115 selection.modify(
1116 event.shiftKey ? "extend" : "move",
1117 event.key === "Home" ? "backward" : "forward",
1118 "lineboundary",
1119 );
1120 editor.update(() => {
1121 $setSelection($createRangeSelectionFromDom(selection, editor));
1122 });
1123 return true;
1124 },
1125 COMMAND_PRIORITY_HIGH,
1126 );
1127 }, [editor]);
1128
1129 return null;
1130}
1131
1132function ComposerInlineTokenSelectionNormalizePlugin() {
1133 const [editor] = useLexicalComposerContext();
1134
1135 useEffect(() => {
1136 return editor.registerUpdateListener(({ editorState }) => {
1137 let afterOffset: number | null = null;
1138 editorState.read(() => {
1139 const selection = $getSelection();
1140 if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
1141 const anchorNode = selection.anchor.getNode();
1142 if (!isComposerInlineTokenNode(anchorNode)) return;
1143 if (selection.anchor.offset === 0) return;
1144 const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0);
1145 afterOffset = beforeOffset + 1;
1146 });
1147 if (afterOffset !== null) {
1148 queueMicrotask(() => {
1149 editor.update(() => {
1150 $setSelectionAtComposerOffset(afterOffset!);
1151 });
1152 });
1153 }
1154 });
1155 }, [editor]);
1156
1157 return null;
1158}
1159
1160function ComposerInlineTokenBackspacePlugin() {
1161 const [editor] = useLexicalComposerContext();
1162
1163 useEffect(() => {
1164 return editor.registerCommand(
1165 KEY_BACKSPACE_COMMAND,
1166 (event) => {
1167 const selection = $getSelection();
1168 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
1169 return false;
1170 }
1171
1172 const anchorNode = selection.anchor.getNode();
1173 const removeInlineTokenNode = (candidate: unknown): boolean => {
1174 if (!isComposerInlineTokenNode(candidate)) {
1175 return false;
1176 }
1177 const tokenStart = getAbsoluteOffsetForPoint(candidate, 0);
1178 candidate.remove();
1179 $setSelectionAtComposerOffset(tokenStart);
1180 event?.preventDefault();
1181 return true;
1182 };
1183 if (removeInlineTokenNode(anchorNode)) {
1184 return true;
1185 }
1186
1187 if ($isTextNode(anchorNode)) {
1188 if (selection.anchor.offset > 0) {
1189 return false;
1190 }
1191 if (removeInlineTokenNode(anchorNode.getPreviousSibling())) {
1192 return true;
1193 }
1194 const parent = anchorNode.getParent();
1195 if ($isElementNode(parent)) {
1196 const index = anchorNode.getIndexWithinParent();
1197 if (index > 0 && removeInlineTokenNode(parent.getChildAtIndex(index - 1))) {
1198 return true;
1199 }
1200 }
1201 return false;
1202 }
1203
1204 if ($isElementNode(anchorNode)) {
1205 const childIndex = selection.anchor.offset - 1;
1206 if (childIndex >= 0 && removeInlineTokenNode(anchorNode.getChildAtIndex(childIndex))) {
1207 return true;
1208 }
1209 }
1210
1211 return false;
1212 },
1213 COMMAND_PRIORITY_HIGH,
1214 );
1215 }, [editor]);
1216
1217 return null;
1218}
1219
1220/**
1221 * Chips render as non-editable decorators, so the browser never paints the
1222 * native text selection over them; without help, a selection spanning chips
1223 * is only visible in the slivers between them. Mirror the selection onto the
1224 * chips with a data attribute the stylesheet turns into a highlight overlay.
1225 */
1226function ComposerChipSelectionPlugin() {
1227 const [editor] = useLexicalComposerContext();
1228
1229 useEffect(() => {
1230 let selectedKeys = new Set<string>();
1231 // Lexical keeps the range selection on blur without emitting an update,
1232 // so focus is tracked separately; while blurred the native highlight is
1233 // gone and the mirrored one has to go with it.
1234 let hasFocus = editor.getRootElement() === document.activeElement;
1235
1236 const applyKeys = (nextKeys: Set<string>) => {
1237 for (const key of selectedKeys) {
1238 if (!nextKeys.has(key)) {
1239 editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected");
1240 }
1241 }
1242 for (const key of nextKeys) {
1243 editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true");
1244 }
1245 selectedKeys = nextKeys;
1246 };
1247
1248 const readSelectedKeys = () => {
1249 const nextKeys = new Set<string>();
1250 editor.getEditorState().read(() => {
1251 const selection = $getSelection();
1252 if ($isRangeSelection(selection) && !selection.isCollapsed()) {
1253 for (const node of selection.getNodes()) {
1254 if (node instanceof DecoratorNode) {
1255 nextKeys.add(node.getKey());
1256 }
1257 }
1258 }
1259 });
1260 return nextKeys;
1261 };
1262
1263 const unregisterUpdate = editor.registerUpdateListener(() => {
1264 applyKeys(hasFocus ? readSelectedKeys() : new Set());
1265 });
1266 const unregisterFocus = editor.registerCommand(
1267 FOCUS_COMMAND,
1268 () => {
1269 hasFocus = true;
1270 applyKeys(readSelectedKeys());
1271 return false;
1272 },
1273 COMMAND_PRIORITY_LOW,
1274 );
1275 const unregisterBlur = editor.registerCommand(
1276 BLUR_COMMAND,
1277 () => {
1278 hasFocus = false;
1279 applyKeys(new Set());
1280 return false;
1281 },
1282 COMMAND_PRIORITY_LOW,
1283 );
1284 return () => {
1285 unregisterUpdate();
1286 unregisterFocus();
1287 unregisterBlur();
1288 };
1289 }, [editor]);
1290
1291 return null;
1292}
1293
1294function ComposerInlineTokenPastePlugin(props: {
1295 importContextFragment?: ComposerPromptEditorProps["importContextFragment"];
1296}) {
1297 const [editor] = useLexicalComposerContext();
1298 const importContextFragment = props.importContextFragment;
1299
1300 useEffect(
1301 () =>
1302 registerComposerInlineTokenPaste(editor, {
1303 createMentionNode: $createComposerMentionNode,
1304 createCitationNode: $createComposerCitationNode,
1305 createContextReferenceNode: $createComposerContextReferenceNode,
1306 getExpandedAbsoluteOffsetForPoint,
1307 ...(importContextFragment ? { importContextFragment } : {}),
1308 }),
1309 [editor, importContextFragment],
1310 );
1311
1312 return null;
1313}
1314
1315/**
1316 * Copying chips must carry their payloads: the default copy writes the canonical links as
1317 * text, and this adds the structured fragment for the referenced records beside it.
1318 */
1319function ComposerContextClipboardPlugin(props: {
1320 buildContextClipboardFragment?: ComposerPromptEditorProps["buildContextClipboardFragment"];
1321}) {
1322 const [editor] = useLexicalComposerContext();
1323 const build = props.buildContextClipboardFragment;
1324
1325 useEffect(() => {
1326 if (!build) return;
1327 const listener = (event: ClipboardEvent | KeyboardEvent | null, cut: boolean) => {
1328 if (!event || !("clipboardData" in event) || !event.clipboardData) return false;
1329 const selection = $getSelection();
1330 if (!$isRangeSelection(selection) || selection.isCollapsed()) return false;
1331 const text = selection.getTextContent();
1332 const contextIds = collectInlineContextIds(text);
1333 if (contextIds.length === 0) return false;
1334 const fragment = build(contextIds);
1335 if (!fragment) return false;
1336 event.preventDefault();
1337 event.clipboardData.setData("text/plain", text);
1338 event.clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment);
1339 event.clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment));
1340 if (cut) selection.removeText();
1341 return true;
1342 };
1343 const unregisterCopy = editor.registerCommand(
1344 COPY_COMMAND,
1345 (event) => listener(event, false),
1346 COMMAND_PRIORITY_HIGH,
1347 );
1348 const unregisterCut = editor.registerCommand(
1349 CUT_COMMAND,
1350 (event) => listener(event, true),
1351 COMMAND_PRIORITY_HIGH,
1352 );
1353 return () => {
1354 unregisterCopy();
1355 unregisterCut();
1356 };
1357 }, [build, editor]);
1358
1359 return null;
1360}
1361
1362function ComposerSurroundSelectionPlugin(props: { skills: ReadonlyArray<ServerProviderSkill> }) {
1363 const [editor] = useLexicalComposerContext();
1364 const skillMetadataRef = useRef(skillMetadataByName(props.skills));
1365 const pendingSurroundSelectionRef = useRef<{
1366 value: string;
1367 expandedStart: number;
1368 expandedEnd: number;
1369 } | null>(null);
1370 const pendingDeadKeySelectionRef = useRef<{
1371 value: string;
1372 expandedStart: number;
1373 expandedEnd: number;
1374 } | null>(null);
1375
1376 useEffect(() => {
1377 skillMetadataRef.current = skillMetadataByName(props.skills);
1378 }, [props.skills]);
1379
1380 const applySurroundInsertion = useEffectEvent((inputData: string): boolean => {
1381 const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData);
1382 const pendingSurroundSelection = pendingSurroundSelectionRef.current;
1383 if (!surroundCloseSymbol) {
1384 pendingSurroundSelectionRef.current = null;
1385 return false;
1386 }
1387
1388 let handled = false;
1389 editor.update(() => {
1390 const selectionSnapshot =
1391 pendingSurroundSelection ??
1392 (() => {
1393 const selection = $getSelection();
1394 if (!$isRangeSelection(selection) || selection.isCollapsed()) {
1395 return null;
1396 }
1397 if ($selectionTouchesInlineToken(selection)) {
1398 return null;
1399 }
1400 const range = getSelectionRangeForExpandedComposerOffsets(selection);
1401 if (!range || range.start === range.end) {
1402 return null;
1403 }
1404 const value = $getRoot().getTextContent();
1405 if (selectionTouchesMentionBoundary(value, range.start, range.end)) {
1406 return null;
1407 }
1408 return {
1409 value,
1410 expandedStart: range.start,
1411 expandedEnd: range.end,
1412 };
1413 })();
1414
1415 if (!selectionSnapshot || !surroundCloseSymbol) {
1416 return;
1417 }
1418
1419 const selectedText = selectionSnapshot.value.slice(
1420 selectionSnapshot.expandedStart,
1421 selectionSnapshot.expandedEnd,
1422 );
1423 const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`;
1424 $setComposerEditorPrompt(nextValue, skillMetadataRef.current);
1425 const selectionStart = collapseExpandedComposerCursor(
1426 nextValue,
1427 selectionSnapshot.expandedStart,
1428 );
1429 $setSelectionRangeAtComposerOffsets(
1430 selectionStart + inputData.length,
1431 selectionStart + inputData.length + selectedText.length,
1432 );
1433 handled = true;
1434 pendingSurroundSelectionRef.current = null;
1435 });
1436
1437 return handled;
1438 });
1439
1440 useEffect(() => {
1441 const onKeyDown = (event: KeyboardEvent) => {
1442 if (pendingDeadKeySelectionRef.current) {
1443 if (event.key === "Dead" || event.key === " " || event.code === "Space") {
1444 return;
1445 }
1446 pendingDeadKeySelectionRef.current = null;
1447 }
1448
1449 if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) {
1450 pendingSurroundSelectionRef.current = null;
1451 pendingDeadKeySelectionRef.current = null;
1452 return;
1453 }
1454
1455 editor.getEditorState().read(() => {
1456 const selection = $getSelection();
1457 if (!$isRangeSelection(selection) || selection.isCollapsed()) {
1458 pendingSurroundSelectionRef.current = null;
1459 pendingDeadKeySelectionRef.current = null;
1460 return;
1461 }
1462 if ($selectionTouchesInlineToken(selection)) {
1463 pendingSurroundSelectionRef.current = null;
1464 pendingDeadKeySelectionRef.current = null;
1465 return;
1466 }
1467 const range = getSelectionRangeForExpandedComposerOffsets(selection);
1468 if (!range || range.start === range.end) {
1469 pendingSurroundSelectionRef.current = null;
1470 pendingDeadKeySelectionRef.current = null;
1471 return;
1472 }
1473 const value = $getRoot().getTextContent();
1474 if (selectionTouchesMentionBoundary(value, range.start, range.end)) {
1475 pendingSurroundSelectionRef.current = null;
1476 pendingDeadKeySelectionRef.current = null;
1477 return;
1478 }
1479 const snapshot = {
1480 value,
1481 expandedStart: range.start,
1482 expandedEnd: range.end,
1483 };
1484 pendingSurroundSelectionRef.current = snapshot;
1485 pendingDeadKeySelectionRef.current = null;
1486 });
1487 };
1488
1489 const onBeforeInput = (event: InputEvent) => {
1490 if (
1491 event.inputType === "insertCompositionText" &&
1492 event.data === "`" &&
1493 BACKTICK_SURROUND_CLOSE_SYMBOL !== null &&
1494 pendingSurroundSelectionRef.current
1495 ) {
1496 pendingDeadKeySelectionRef.current = pendingSurroundSelectionRef.current;
1497 return;
1498 }
1499
1500 if (pendingDeadKeySelectionRef.current) {
1501 return;
1502 }
1503
1504 if (event.inputType === "insertCompositionText") {
1505 return;
1506 }
1507
1508 if (typeof event.data !== "string") {
1509 pendingSurroundSelectionRef.current = null;
1510 return;
1511 }
1512 const inputData = event.inputType === "insertText" ? event.data : null;
1513 if (!inputData || inputData.length !== 1) {
1514 pendingSurroundSelectionRef.current = null;
1515 return;
1516 }
1517 if (!applySurroundInsertion(inputData)) {
1518 return;
1519 }
1520
1521 event.preventDefault();
1522 event.stopPropagation();
1523 event.stopImmediatePropagation();
1524 };
1525
1526 const tryApplyDeadKeyBacktickSurround = (options?: { finalAttempt?: boolean }) => {
1527 queueMicrotask(() => {
1528 editor.update(
1529 () => {
1530 const pendingDeadKeySelection = pendingDeadKeySelectionRef.current;
1531 if (!pendingDeadKeySelection) {
1532 return;
1533 }
1534
1535 const currentValue = $getRoot().getTextContent();
1536 const backtickCloseSymbol = BACKTICK_SURROUND_CLOSE_SYMBOL;
1537 if (backtickCloseSymbol === null) {
1538 pendingDeadKeySelectionRef.current = null;
1539 return;
1540 }
1541
1542 const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.expandedStart)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.expandedEnd)}`;
1543 if (currentValue !== expectedResolvedValue) {
1544 if (options?.finalAttempt) {
1545 pendingSurroundSelectionRef.current = null;
1546 pendingDeadKeySelectionRef.current = null;
1547 }
1548 return;
1549 }
1550
1551 const selectedText = pendingDeadKeySelection.value.slice(
1552 pendingDeadKeySelection.expandedStart,
1553 pendingDeadKeySelection.expandedEnd,
1554 );
1555 const replacementStart = collapseExpandedComposerCursor(
1556 currentValue,
1557 pendingDeadKeySelection.expandedStart,
1558 );
1559 $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1);
1560 const replacementSelection = $getSelection();
1561 if (!$isRangeSelection(replacementSelection)) {
1562 pendingSurroundSelectionRef.current = null;
1563 pendingDeadKeySelectionRef.current = null;
1564 return;
1565 }
1566 replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`);
1567 $setSelectionRangeAtComposerOffsets(
1568 replacementStart + 1,
1569 replacementStart + 1 + selectedText.length,
1570 );
1571 pendingSurroundSelectionRef.current = null;
1572 pendingDeadKeySelectionRef.current = null;
1573 },
1574 { tag: HISTORY_MERGE_TAG },
1575 );
1576 });
1577 };
1578
1579 const onInput = (event: Event) => {
1580 const inputEvent = event as InputEvent;
1581 if (
1582 inputEvent.inputType === "insertText" ||
1583 inputEvent.inputType === "insertCompositionText"
1584 ) {
1585 tryApplyDeadKeyBacktickSurround();
1586 }
1587 };
1588
1589 const onCompositionEnd = () => {
1590 tryApplyDeadKeyBacktickSurround({ finalAttempt: true });
1591 };
1592
1593 let activeRootElement: HTMLElement | null = null;
1594 const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => {
1595 prevRootElement?.removeEventListener("keydown", onKeyDown);
1596 prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true);
1597 prevRootElement?.removeEventListener("input", onInput);
1598 prevRootElement?.removeEventListener("compositionend", onCompositionEnd);
1599 rootElement?.addEventListener("keydown", onKeyDown);
1600 rootElement?.addEventListener("beforeinput", onBeforeInput, true);
1601 rootElement?.addEventListener("input", onInput);
1602 rootElement?.addEventListener("compositionend", onCompositionEnd);
1603 activeRootElement = rootElement;
1604 });
1605
1606 return () => {
1607 if (activeRootElement) {
1608 activeRootElement.removeEventListener("keydown", onKeyDown);
1609 activeRootElement.removeEventListener("beforeinput", onBeforeInput, true);
1610 activeRootElement.removeEventListener("input", onInput);
1611 activeRootElement.removeEventListener("compositionend", onCompositionEnd);
1612 }
1613 unregisterRootListener();
1614 };
1615 }, [editor]);
1616
1617 return null;
1618}
1619
1620function ComposerPromptEditorInner({
1621 value,
1622 cursor,
1623 contextRecords,
1624 buildContextClipboardFragment,
1625 importContextFragment,
1626 skills,
1627 disabled,
1628 placeholder,
1629 containerClassName,
1630 className,
1631 placeholderClassName,
1632 onChange,
1633 onVisibleSelectionChange,
1634 onCommandKeyDown,
1635 onPageScrollKeyDown,
1636 onPageScrollKeyUp,
1637 onPageScrollRelease,
1638 onCitationSubmitAndSend,
1639 onPaste,
1640 editorRef,
1641}: ComposerPromptEditorProps) {
1642 const [editor] = useLexicalComposerContext();
1643 const onChangeRef = useRef(onChange);
1644 const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange);
1645 const initialCursor = clampCollapsedComposerCursor(value, cursor);
1646 const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor);
1647 const skillsSignature = skillSignature(skills);
1648 const skillsSignatureRef = useRef(skillsSignature);
1649 const skillMetadataRef = useRef(skillMetadataByName(skills));
1650 const snapshotRef = useRef({
1651 value,
1652 cursor: initialCursor,
1653 expandedCursor: initialExpandedCursor,
1654 contextIds: collectInlineContextIds(value),
1655 });
1656 const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor });
1657 const isApplyingControlledUpdateRef = useRef(false);
1658 // Latest controlled value, readable from editor listeners that fire before the layout
1659 // effect has rewritten the editor to match it.
1660 const latestValueRef = useRef(value);
1661 useLayoutEffect(() => {
1662 latestValueRef.current = value;
1663 }, [value]);
1664 const citationCommentRequestRef = useRef<ComposerCitationCommentRequest | null>(null);
1665 const [openCitationComment, setOpenCitationComment] =
1666 useState<ComposerCitationCommentTarget | null>(null);
1667 const citationCommentActions = useMemo(
1668 () => ({
1669 openComment: openCitationComment,
1670 onOpenChange: (nodeKey: NodeKey, open: boolean) => {
1671 setOpenCitationComment((current) =>
1672 open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current,
1673 );
1674 },
1675 onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}),
1676 }),
1677 [onCitationSubmitAndSend, openCitationComment],
1678 );
1679
1680 useEffect(() => {
1681 onChangeRef.current = onChange;
1682 }, [onChange]);
1683
1684 useEffect(() => {
1685 onVisibleSelectionChangeRef.current = onVisibleSelectionChange;
1686 }, [onVisibleSelectionChange]);
1687
1688 useLayoutEffect(() => {
1689 skillMetadataRef.current = skillMetadataByName(skills);
1690 }, [skills]);
1691
1692 useEffect(() => {
1693 editor.setEditable(!disabled);
1694 }, [disabled, editor]);
1695
1696 useEffect(() => {
1697 const openCitationNodeKey = openCitationComment?.nodeKey;
1698 if (!openCitationNodeKey) return;
1699 return editor.registerUpdateListener(({ editorState }) => {
1700 const isAttached = editorState.read(() => {
1701 const node = $getNodeByKey(openCitationNodeKey);
1702 return node instanceof ComposerCitationNode && node.isAttached();
1703 });
1704 if (!isAttached) {
1705 setOpenCitationComment((current) =>
1706 current?.nodeKey === openCitationNodeKey ? null : current,
1707 );
1708 }
1709 });
1710 }, [editor, openCitationComment?.nodeKey]);
1711
1712 useLayoutEffect(() => {
1713 const normalizedCursor = clampCollapsedComposerCursor(value, cursor);
1714 const previousSnapshot = snapshotRef.current;
1715 const skillsChanged = skillsSignatureRef.current !== skillsSignature;
1716 if (
1717 previousSnapshot.value === value &&
1718 previousSnapshot.cursor === normalizedCursor &&
1719 !skillsChanged
1720 ) {
1721 return;
1722 }
1723
1724 const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor);
1725 snapshotRef.current = {
1726 value,
1727 cursor: normalizedCursor,
1728 expandedCursor: normalizedExpandedCursor,
1729 contextIds: collectInlineContextIds(value),
1730 };
1731 selectionRangeRef.current = {
1732 start: normalizedExpandedCursor,
1733 end: normalizedExpandedCursor,
1734 };
1735 skillsSignatureRef.current = skillsSignature;
1736
1737 const rootElement = editor.getRootElement();
1738 const isFocused = Boolean(rootElement && document.activeElement === rootElement);
1739 if (previousSnapshot.value === value && !skillsChanged && !isFocused) {
1740 return;
1741 }
1742
1743 isApplyingControlledUpdateRef.current = true;
1744 const isCiteInsertion = citationCommentRequestRef.current?.value === value;
1745 let citationToOpen: ComposerCitationCommentTarget | null = null;
1746 editor.update(
1747 () => {
1748 const shouldRewriteEditorState = previousSnapshot.value !== value || skillsChanged;
1749 if (shouldRewriteEditorState) {
1750 $setComposerEditorPrompt(value, skillMetadataRef.current);
1751 }
1752 if (shouldRewriteEditorState || isFocused) {
1753 $setSelectionAtComposerOffset(normalizedCursor);
1754 }
1755 citationToOpen = $consumeComposerCitationCommentRequest(citationCommentRequestRef);
1756 },
1757 {
1758 ...(isCiteInsertion ? { tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] } : {}),
1759 onUpdate: () => {
1760 if (citationToOpen) setOpenCitationComment(citationToOpen);
1761 },
1762 },
1763 );
1764 queueMicrotask(() => {
1765 isApplyingControlledUpdateRef.current = false;
1766 });
1767 }, [cursor, editor, skillsSignature, value]);
1768
1769 const focusAt = useCallback(
1770 (nextCursor: number) => {
1771 const rootElement = editor.getRootElement();
1772 if (!rootElement) return;
1773 rootElement.focus({ preventScroll: true });
1774 // A newer prompt is waiting to be applied (a chip was just inserted through the store).
1775 // Reporting the editor's stale text now would overwrite that prompt; the pending rewrite
1776 // places the caret from the store's cursor instead.
1777 if (snapshotRef.current.value !== latestValueRef.current) return;
1778 const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor);
1779 editor.update(() => {
1780 $setSelectionAtComposerOffset(boundedCursor);
1781 });
1782 if (boundedCursor === snapshotRef.current.cursor) return;
1783 snapshotRef.current = {
1784 value: snapshotRef.current.value,
1785 cursor: boundedCursor,
1786 expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),
1787 contextIds: snapshotRef.current.contextIds,
1788 };
1789 selectionRangeRef.current = {
1790 start: snapshotRef.current.expandedCursor,
1791 end: snapshotRef.current.expandedCursor,
1792 };
1793 onChangeRef.current(
1794 snapshotRef.current.value,
1795 boundedCursor,
1796 snapshotRef.current.expandedCursor,
1797 false,
1798 snapshotRef.current.contextIds,
1799 );
1800 },
1801 [editor],
1802 );
1803
1804 const readSnapshot = useCallback((): {
1805 value: string;
1806 cursor: number;
1807 expandedCursor: number;
1808 contextIds: string[];
1809 } => {
1810 let snapshot = snapshotRef.current;
1811 editor.getEditorState().read(() => {
1812 const nextValue = $getRoot().getTextContent();
1813 const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor);
1814 const nextCursor = clampCollapsedComposerCursor(
1815 nextValue,
1816 $readSelectionOffsetFromEditorState(fallbackCursor),
1817 );
1818 const fallbackExpandedCursor = clampExpandedCursor(
1819 nextValue,
1820 snapshotRef.current.expandedCursor,
1821 );
1822 const nextExpandedCursor = clampExpandedCursor(
1823 nextValue,
1824 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
1825 );
1826 const selectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection());
1827 const contextIds = collectContextIds($getRoot());
1828 snapshot = {
1829 value: nextValue,
1830 cursor: nextCursor,
1831 expandedCursor: nextExpandedCursor,
1832 contextIds,
1833 };
1834 selectionRangeRef.current = selectionRange ?? {
1835 start: nextExpandedCursor,
1836 end: nextExpandedCursor,
1837 };
1838 });
1839 snapshotRef.current = snapshot;
1840 return snapshot;
1841 }, [editor]);
1842
1843 useImperativeHandle(
1844 editorRef,
1845 () => ({
1846 focus: () => {
1847 focusAt(snapshotRef.current.cursor);
1848 },
1849 focusAt,
1850 focusAtEnd: () => {
1851 focusAt(
1852 collapseExpandedComposerCursor(
1853 snapshotRef.current.value,
1854 snapshotRef.current.value.length,
1855 ),
1856 );
1857 },
1858 readSelectionRange: () => {
1859 readSnapshot();
1860 return selectionRangeRef.current;
1861 },
1862 requestCitationComment: (request) => {
1863 citationCommentRequestRef.current = request;
1864 const target = editor
1865 .getEditorState()
1866 .read(() => $consumeComposerCitationCommentRequest(citationCommentRequestRef));
1867 if (target) setOpenCitationComment(target);
1868 },
1869 readSnapshot,
1870 isCaretOnVisualEdge: (edge) => {
1871 const snapshot = readSnapshot();
1872 if (snapshot.value.length === 0) return true;
1873 const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor);
1874 const afterCaret = snapshot.value.slice(snapshot.expandedCursor);
1875 if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) {
1876 return false;
1877 }
1878 const rootElement = editor.getRootElement();
1879 const selection = window.getSelection();
1880 if (
1881 !rootElement ||
1882 !selection ||
1883 !selection.isCollapsed ||
1884 selection.rangeCount === 0 ||
1885 !selection.anchorNode ||
1886 !rootElement.contains(selection.anchorNode)
1887 ) {
1888 return false;
1889 }
1890 const caretRect = caretLineRect(selection.getRangeAt(0), edge);
1891 if (!caretRect) return false;
1892 const edgeElement =
1893 edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild;
1894 const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect();
1895 const threshold = caretRect.height / 2;
1896 return edge === "start"
1897 ? caretRect.top - edgeRect.top < threshold
1898 : edgeRect.bottom - caretRect.bottom < threshold;
1899 },
1900 }),
1901 [editor, focusAt, readSnapshot],
1902 );
1903
1904 const handleEditorChange = useCallback((editorState: EditorState) => {
1905 editorState.read(() => {
1906 const nextValue = $getRoot().getTextContent();
1907 const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor);
1908 const nextCursor = clampCollapsedComposerCursor(
1909 nextValue,
1910 $readSelectionOffsetFromEditorState(fallbackCursor),
1911 );
1912 const fallbackExpandedCursor = clampExpandedCursor(
1913 nextValue,
1914 snapshotRef.current.expandedCursor,
1915 );
1916 const nextExpandedCursor = clampExpandedCursor(
1917 nextValue,
1918 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
1919 );
1920 const nextSelectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection());
1921 const previousSelectionRange = selectionRangeRef.current;
1922 selectionRangeRef.current = nextSelectionRange ?? {
1923 start: nextExpandedCursor,
1924 end: nextExpandedCursor,
1925 };
1926 const contextIds = collectContextIds($getRoot());
1927 const previousSnapshot = snapshotRef.current;
1928 const snapshotChanged = !(
1929 previousSnapshot.value === nextValue &&
1930 previousSnapshot.cursor === nextCursor &&
1931 previousSnapshot.expandedCursor === nextExpandedCursor &&
1932 previousSnapshot.contextIds.length === contextIds.length &&
1933 previousSnapshot.contextIds.every((id, index) => id === contextIds[index])
1934 );
1935 if (isApplyingControlledUpdateRef.current) {
1936 return;
1937 }
1938 if (!snapshotChanged) {
1939 if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) {
1940 onVisibleSelectionChangeRef.current?.();
1941 }
1942 return;
1943 }
1944 // A selection-only update while a newer prompt waits to be applied (an attachment
1945 // chip was just inserted through the store) would report stale text and stale
1946 // context ids, clobbering the prompt and dropping the record. Let the rewrite land.
1947 if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) {
1948 return;
1949 }
1950 snapshotRef.current = {
1951 value: nextValue,
1952 cursor: nextCursor,
1953 expandedCursor: nextExpandedCursor,
1954 contextIds,
1955 };
1956 const cursorAdjacentToMention =
1957 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") ||
1958 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right");
1959 onChangeRef.current(
1960 nextValue,
1961 nextCursor,
1962 nextExpandedCursor,
1963 cursorAdjacentToMention,
1964 contextIds,
1965 );
1966 });
1967 }, []);
1968
1969 return (
1970 <ComposerContextRecordsContext value={contextRecords}>
1971 <ComposerCitationCommentContext value={citationCommentActions}>
1972 <div
1973 className={cn(
1974 "relative [font-family:var(--font-composer,var(--font-sans))] [font-size:var(--font-size-prompt,0.875rem)] [@media(max-width:39.999rem)_and_(pointer:coarse)]:[font-size:max(var(--font-size-prompt,1rem),16px)]",
1975 containerClassName,
1976 )}
1977 >
1978 <PlainTextPlugin
1979 contentEditable={
1980 <ContentEditable
1981 className={cn(
1982 // The wrapper owns the appearance preference; keep everything else here.
1983 "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none",
1984 className,
1985 )}
1986 data-testid="composer-editor"
1987 aria-placeholder={placeholder}
1988 placeholder={<span />}
1989 onKeyDown={(event) => {
1990 if (
1991 event.key === "Control" ||
1992 event.key === "Meta" ||
1993 event.key === "Alt" ||
1994 event.key === "Shift"
1995 ) {
1996 onPageScrollRelease?.();
1997 }
1998
1999 if (event.key !== "PageUp" && event.key !== "PageDown") {
2000 return;
2001 }
2002
2003 const pageScrollKey = getTimelinePageScrollKey({
2004 altKey: event.altKey,
2005 clientHeight: event.currentTarget.clientHeight,
2006 ctrlKey: event.ctrlKey,
2007 defaultPrevented: event.defaultPrevented,
2008 isComposing: event.nativeEvent.isComposing,
2009 key: event.key,
2010 keyCode: event.keyCode,
2011 metaKey: event.metaKey,
2012 scrollHeight: event.currentTarget.scrollHeight,
2013 scrollTop: event.currentTarget.scrollTop,
2014 shiftKey: event.shiftKey,
2015 });
2016 if (!pageScrollKey) {
2017 onPageScrollRelease?.();
2018 return;
2019 }
2020 if (!onPageScrollKeyDown) {
2021 return;
2022 }
2023
2024 event.preventDefault();
2025 onPageScrollKeyDown(pageScrollKey);
2026 }}
2027 onKeyUp={(event) => onPageScrollKeyUp?.(event.key)}
2028 onBlur={onPageScrollRelease}
2029 onPasteCapture={onPaste}
2030 />
2031 }
2032 placeholder={
2033 contextRecords.size > 0 ? null : (
2034 <div
2035 className={cn(
2036 "pointer-events-none absolute inset-0 leading-relaxed text-placeholder/75",
2037 placeholderClassName,
2038 )}
2039 >
2040 {placeholder}
2041 </div>
2042 )
2043 }
2044 ErrorBoundary={LexicalErrorBoundary}
2045 />
2046 <OnChangePlugin onChange={handleEditorChange} />
2047 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />
2048 <ComposerSurroundSelectionPlugin skills={skills} />
2049 <ComposerHomeEndKeyPlugin />
2050 <ComposerInlineTokenArrowPlugin />
2051 <ComposerInlineTokenSelectionNormalizePlugin />
2052 <ComposerInlineTokenBackspacePlugin />
2053 <ComposerInlineTokenPastePlugin importContextFragment={importContextFragment} />
2054 <ComposerContextClipboardPlugin
2055 buildContextClipboardFragment={buildContextClipboardFragment}
2056 />
2057 <ComposerChipSelectionPlugin />
2058 <HistoryPlugin />
2059 </div>
2060 </ComposerCitationCommentContext>
2061 </ComposerContextRecordsContext>
2062 );
2063}
2064
2065export function ComposerPromptEditor({
2066 value,
2067 cursor,
2068 contextRecords,
2069 buildContextClipboardFragment,
2070 importContextFragment,
2071 skills,
2072 disabled,
2073 placeholder,
2074 containerClassName,
2075 className,
2076 placeholderClassName,
2077 onChange,
2078 onVisibleSelectionChange,
2079 onCommandKeyDown,
2080 onPageScrollKeyDown,
2081 onPageScrollKeyUp,
2082 onPageScrollRelease,
2083 onCitationSubmitAndSend,
2084 onPaste,
2085 editorRef,
2086}: ComposerPromptEditorProps) {
2087 const initialValueRef = useRef(value);
2088 const initialSkillMetadataRef = useRef(skillMetadataByName(skills));
2089 const initialConfig = useMemo<InitialConfigType>(
2090 () => ({
2091 namespace: "t3tools-composer-editor",
2092 editable: true,
2093 nodes: [
2094 ComposerMentionNode,
2095 ComposerSkillNode,
2096 ComposerCitationNode,
2097 ComposerContextReferenceNode,
2098 ],
2099 editorState: () => {
2100 $setComposerEditorPrompt(initialValueRef.current, initialSkillMetadataRef.current);
2101 },
2102 onError: (error) => {
2103 throw error;
2104 },
2105 }),
2106 [],
2107 );
2108
2109 return (
2110 <ComposerSkillsContext value={skills}>
2111 <LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}>
2112 <ComposerPromptEditorInner
2113 value={value}
2114 cursor={cursor}
2115 contextRecords={contextRecords}
2116 buildContextClipboardFragment={buildContextClipboardFragment}
2117 importContextFragment={importContextFragment}
2118 skills={skills}
2119 disabled={disabled}
2120 placeholder={placeholder}
2121 {...(containerClassName ? { containerClassName } : {})}
2122 onChange={onChange}
2123 {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})}
2124 onPaste={onPaste}
2125 {...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})}
2126 editorRef={editorRef}
2127 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
2128 {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})}
2129 {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})}
2130 {...(onPageScrollRelease ? { onPageScrollRelease } : {})}
2131 {...(className ? { className } : {})}
2132 {...(placeholderClassName ? { placeholderClassName } : {})}
2133 />
2134 </LexicalComposer>
2135 </ComposerSkillsContext>
2136 );
15export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
16 return <ComposerPromptEditorTiptap {...props} />;
213717}
apps/web/src/components/ComposerPromptEditorTiptap.tsx created+1379
......@@ -0,0 +1,1379 @@
1import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core";
2import { TaskList } from "@tiptap/extension-task-list";
3import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
4import StarterKit from "@tiptap/starter-kit";
5import { type Node as ProseMirrorNode } from "@tiptap/pm/model";
6import { splitBlockKeepMarks } from "@tiptap/pm/commands";
7import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
8import { Decoration, DecorationSet } from "@tiptap/pm/view";
9import type {
10 AssistantCitation,
11 ComposerContextClipboardFragment,
12 ServerProviderSkill,
13} from "@t3tools/contracts";
14import {
15 serializeAssistantCitation,
16 withAssistantCitationComment,
17} from "@t3tools/shared/assistantCitations";
18import {
19 COMPOSER_CONTEXT_CLIPBOARD_MIME,
20 encodeComposerContextClipboardHtml,
21} from "@t3tools/shared/composerContextClipboard";
22import {
23 createContext,
24 use,
25 useCallback,
26 useEffect,
27 useImperativeHandle,
28 useLayoutEffect,
29 useMemo,
30 useRef,
31 useState,
32} from "react";
33import { EditorContent, useEditor } from "@tiptap/react";
34
35import {
36 clampCollapsedComposerCursor,
37 collapseExpandedComposerCursor,
38 expandCollapsedComposerCursor,
39 isCollapsedCursorAdjacentToInlineToken,
40} from "~/composer-logic";
41import {
42 collectComposerPromptInlineTokens,
43 selectionTouchesMentionBoundary,
44} from "~/composer-editor-mentions";
45import {
46 buildDocJson,
47 buildTiptapContent,
48 collapsedToFlat,
49 ComposerTaskItemExtension,
50 flatToCollapsed,
51 flatToMarkdown,
52 flatToPm,
53 pmToFlat,
54 serializeEditorDoc,
55 type SkillMeta,
56} from "~/composer-rich-text-doc";
57import { collectInlineContextIds } from "~/lib/composerContextReferences";
58import { cn, isMacPlatform } from "~/lib/utils";
59import { basenameOfPath } from "~/pierre-icons";
60import {
61 COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME,
62 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
63 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
64 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
65 SKILL_CHIP_ICON_SVG,
66} from "./composerInlineChip";
67import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
68import { AssistantCitationChip } from "./chat/AssistantCitationChip";
69import { getTimelinePageScrollKey } from "./chat/pageScrollController";
70import { ContextChipPopover } from "./contextChipParts";
71import { Button } from "./ui/button";
72import {
73 ComposerContextActionsContext,
74 ComposerContextReferenceChip,
75 ComposerContextRecordsContext,
76} from "./composerContextPresentation";
77import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
78import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
79import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
80import { importPastedComposerText } from "./composerInlineTokenPaste";
81import { didComposerSelectionChangeVisibly } from "./composerSelection";
82import type { ComposerDraftContextRecords } from "./composerContextPresentation";
83
84export interface ComposerPromptEditorHandle {
85 focus: () => void;
86 focusAt: (cursor: number) => void;
87 focusAtEnd: () => void;
88 readSelectionRange: () => { start: number; end: number };
89 requestCitationComment: (request: ComposerCitationCommentRequest) => void;
90 readSnapshot: () => {
91 value: string;
92 cursor: number;
93 expandedCursor: number;
94 contextIds: string[];
95 };
96 /**
97 * True when a collapsed caret sits on the first ("start") or last ("end")
98 * visual line, counting soft wraps. Prompt history only claims ArrowUp and
99 * ArrowDown at these edges so arrows still move the caret inside multiline
100 * text.
101 */
102 isCaretOnVisualEdge: (edge: "start" | "end") => boolean;
103}
104
105export interface ComposerPromptEditorProps {
106 value: string;
107 cursor: number;
108 /**
109 * Render Markdown styling (bold, italic, code, strike, task checkboxes).
110 * Off renders the same Tiptap engine as plain text: every marker stays a
111 * literal character.
112 */
113 richTextEnabled?: boolean;
114 /** Draft records behind the prompt's context references, keyed by context id. */
115 contextRecords: ComposerDraftContextRecords;
116 /** Structured clipboard payload for the given referenced ids, or null to skip. */
117 buildContextClipboardFragment?:
118 | ((contextIds: ReadonlyArray<string>) => string | null)
119 | undefined;
120 /** Imports a structured paste's records; returns ids that changed. */
121 importContextFragment?:
122 | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap<string, string>)
123 | undefined;
124 skills: ReadonlyArray<ServerProviderSkill>;
125 disabled: boolean;
126 placeholder: string;
127 containerClassName?: string;
128 className?: string;
129 placeholderClassName?: string;
130 onChange: (
131 nextValue: string,
132 nextCursor: number,
133 expandedCursor: number,
134 cursorAdjacentToMention: boolean,
135 contextIds: string[],
136 ) => void;
137 onVisibleSelectionChange?: () => void;
138 onCommandKeyDown?: (
139 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
140 event: KeyboardEvent,
141 isTaskItem?: boolean,
142 ) => boolean;
143 onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void;
144 onPageScrollKeyUp?: (key: string) => void;
145 onPageScrollRelease?: () => void;
146 onCitationSubmitAndSend?: () => void;
147 onPaste: React.ClipboardEventHandler<HTMLElement>;
148 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
149}
150
151export type ComposerCitationCommentRequest = {
152 previousValue: string;
153 value: string;
154 citationStart: number;
155 sourceAnchor: AssistantCitationSourceAnchor;
156};
157
158type OpenCitationComment = {
159 key: string;
160 sourceAnchor?: AssistantCitationSourceAnchor;
161 removeOnCancel?: boolean;
162};
163
164const ComposerCitationCommentContext = createContext<{
165 openComment: OpenCitationComment | null;
166 onOpenChange: (citeKey: string, open: boolean) => void;
167 onSubmitAndSend: () => void;
168}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} });
169
170const RichComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]);
171
172const SURROUND_CLOSE: Record<string, string> = {
173 "(": ")",
174 "[": "]",
175 "{": "}",
176 "'": "'",
177 '"': '"',
178 "“": "”",
179 "`": "`",
180 "<": ">",
181 "«": "»",
182 "*": "*",
183 _: "_",
184};
185
186function resolvedThemeFromDocument(): "light" | "dark" {
187 return document.documentElement.classList.contains("dark") ? "dark" : "light";
188}
189
190// ── Inline atom nodes (chips) ─────────────────────────────────────────────
191
192const ComposerMentionExtension = Node.create({
193 name: "composer-mention",
194 group: "inline",
195 inline: true,
196 atom: true,
197 selectable: true,
198 addAttributes() {
199 return {
200 path: { default: "" },
201 source: { default: "" },
202 };
203 },
204 parseHTML() {
205 return [{ tag: "span[data-composer-mention]" }];
206 },
207 renderHTML({ HTMLAttributes }) {
208 return ["span", { "data-composer-mention": "", ...HTMLAttributes }];
209 },
210 addNodeView() {
211 return ReactNodeViewRenderer(ComposerMentionNodeView);
212 },
213});
214
215function ComposerMentionNodeView({ node }: NodeViewProps) {
216 const actions = use(ComposerContextActionsContext);
217 const path = (node.attrs.path as string) ?? "";
218 const chip = (
219 <Button
220 variant="chip"
221 onClick={() => actions.openMention(path)}
222 aria-label={`Preview ${path}`}
223 className={`${FILE_TAG_CHIP_CLASS_NAME} cursor-pointer focus-visible:outline-2`}
224 contentEditable={false}
225 spellCheck={false}
226 data-composer-mention-chip="true"
227 >
228 <FileTagChipContent
229 path={path}
230 label={basenameOfPath(path)}
231 theme={resolvedThemeFromDocument()}
232 />
233 </Button>
234 );
235 return (
236 <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}>
237 <Tooltip>
238 <TooltipTrigger render={chip} />
239 <TooltipPopup
240 side="top"
241 className="max-w-120 whitespace-normal leading-tight wrap-anywhere"
242 >
243 {path}
244 </TooltipPopup>
245 </Tooltip>
246 </NodeViewWrapper>
247 );
248}
249
250const ComposerSkillExtension = Node.create({
251 name: "composer-skill",
252 group: "inline",
253 inline: true,
254 atom: true,
255 selectable: true,
256 addAttributes() {
257 return {
258 skillName: { default: "" },
259 skillLabel: { default: "" },
260 skillDescription: { default: null },
261 };
262 },
263 parseHTML() {
264 return [{ tag: "span[data-composer-skill]" }];
265 },
266 renderHTML({ HTMLAttributes }) {
267 return ["span", { "data-composer-skill": "", ...HTMLAttributes }];
268 },
269 addNodeView() {
270 return ReactNodeViewRenderer(ComposerSkillNodeView);
271 },
272});
273
274function ComposerSkillNodeView({ node }: NodeViewProps) {
275 const actions = use(ComposerContextActionsContext);
276 const skills = use(RichComposerSkillsContext);
277 const skillName = (node.attrs.skillName as string) ?? "";
278 const skillLabel = (node.attrs.skillLabel as string) || skillName;
279 const skillDescription = (node.attrs.skillDescription as string | null) ?? null;
280 const skill = skills.find((candidate) => candidate.name === skillName);
281 return (
282 <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}>
283 <ContextChipPopover
284 accessibleLabel={`Skill ${skillLabel}`}
285 triggerClassName={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}
286 chip={
287 <>
288 <span
289 aria-hidden="true"
290 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
291 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
292 />
293 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{skillLabel}</span>
294 </>
295 }
296 >
297 <div className="space-y-3 p-2 text-sm">
298 <p className="font-medium">{skillLabel}</p>
299 <p>
300 {skill?.description ??
301 skillDescription ??
302 "No description is available for this skill."}
303 </p>
304 {skill?.path ? (
305 <Button variant="outline" size="sm" onClick={() => actions.openMention(skill.path)}>
306 View instructions
307 </Button>
308 ) : null}
309 </div>
310 </ContextChipPopover>
311 </NodeViewWrapper>
312 );
313}
314
315const ComposerCitationExtension = Node.create({
316 name: "composer-citation",
317 group: "inline",
318 inline: true,
319 atom: true,
320 selectable: true,
321 addAttributes() {
322 return {
323 citation: { default: null },
324 source: { default: "" },
325 citeKey: { default: "" },
326 };
327 },
328 parseHTML() {
329 return [{ tag: "span[data-composer-citation]" }];
330 },
331 renderHTML({ HTMLAttributes }) {
332 return ["span", { "data-composer-citation": "", ...HTMLAttributes }];
333 },
334 addNodeView() {
335 return ReactNodeViewRenderer(ComposerCitationNodeView);
336 },
337});
338
339function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
340 const commentContext = use(ComposerCitationCommentContext);
341 const citation = node.attrs.citation as AssistantCitation;
342 const citeKey = node.attrs.citeKey as string;
343 const commentTarget =
344 commentContext.openComment?.key === citeKey ? commentContext.openComment : null;
345
346 const nodePos = useCallback(() => {
347 const pos = typeof getPos === "function" ? getPos() : null;
348 return typeof pos === "number" ? pos : null;
349 }, [getPos]);
350
351 const onSaveComment = useCallback(
352 (comment: string): boolean => {
353 if (!editor.isEditable) return false;
354 const pos = nodePos();
355 if (pos === null) return false;
356 const current = editor.state.doc.nodeAt(pos);
357 if (!current || current.type.name !== "composer-citation") return false;
358 const currentCitation = current.attrs.citation as AssistantCitation;
359 const next = withAssistantCitationComment(currentCitation, comment);
360 const tr = editor.state.tr.setNodeMarkup(pos, undefined, {
361 ...current.attrs,
362 citation: next,
363 source: serializeAssistantCitation(next),
364 });
365 editor.view.dispatch(tr);
366 return true;
367 },
368 [editor, nodePos],
369 );
370
371 const onRemove = useCallback(() => {
372 if (!editor.isEditable) return;
373 const pos = nodePos();
374 if (pos === null) return;
375 const current = editor.state.doc.nodeAt(pos);
376 if (!current) return;
377 editor
378 .chain()
379 .focus()
380 .deleteRange({ from: pos, to: pos + current.nodeSize })
381 .run();
382 }, [editor, nodePos]);
383
384 return (
385 <NodeViewWrapper
386 as="span"
387 className="inline-flex min-w-0 max-w-full"
388 contentEditable={false}
389 spellCheck={false}
390 data-composer-citation-chip="true"
391 >
392 <AssistantCitationChip
393 citation={citation}
394 composer
395 commentEditor={{
396 open: commentTarget !== null,
397 sourceAnchor: commentTarget?.sourceAnchor,
398 onOpenChange: (open) => {
399 if (open && !editor.isEditable) return;
400 commentContext.onOpenChange(citeKey, open);
401 },
402 ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}),
403 onSave: onSaveComment,
404 onSaveAndSend: (comment) => {
405 if (!onSaveComment(comment)) return false;
406 commentContext.onSubmitAndSend();
407 return true;
408 },
409 }}
410 />
411 </NodeViewWrapper>
412 );
413}
414
415const ComposerContextReferenceExtension = Node.create({
416 name: "composer-context-reference",
417 group: "inline",
418 inline: true,
419 atom: true,
420 selectable: true,
421 addAttributes() {
422 return {
423 kind: { default: "" },
424 contextId: { default: "" },
425 label: { default: "" },
426 source: { default: "" },
427 };
428 },
429 parseHTML() {
430 return [{ tag: "span[data-composer-context-reference]" }];
431 },
432 renderHTML({ HTMLAttributes }) {
433 return ["span", { "data-composer-context-reference": "", ...HTMLAttributes }];
434 },
435 addNodeView() {
436 return ReactNodeViewRenderer(ComposerContextReferenceNodeView);
437 },
438});
439
440function ComposerContextReferenceNodeView({ node }: NodeViewProps) {
441 return (
442 <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}>
443 <ComposerContextReferenceChip
444 kind={(node.attrs.kind as string) ?? ""}
445 contextId={(node.attrs.contextId as string) ?? ""}
446 label={(node.attrs.label as string) ?? ""}
447 />
448 </NodeViewWrapper>
449 );
450}
451
452// ── Marker reveal (show ** when the cursor is on styled text) ──────────────
453
454type StyledRange = {
455 from: number;
456 to: number;
457 markers: { at: number; side: number; text: string }[];
458};
459
460function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] {
461 const ranges: StyledRange[] = [];
462 const map = serializeEditorDoc(doc);
463 let range: StyledRange | null = null;
464 let openLength = 0;
465 for (const run of map.runs) {
466 if (run.openLen > 0) {
467 range ??= { from: run.pmPos, to: run.pmPos, markers: [] };
468 range.markers.push({
469 at: run.pmPos,
470 side: -1,
471 text: map.value.slice(run.mdStart, run.mdStart + run.openLen),
472 });
473 }
474 if (range === null) continue;
475 range.to = run.pmPos + run.docLen;
476 if (run.closeLen > 0) {
477 const end = run.mdStart + run.mdLen;
478 range.markers.push({
479 at: range.to,
480 side: -2,
481 text: map.value.slice(end - run.closeLen, end),
482 });
483 }
484 openLength += run.openLen - run.closeLen;
485 if (openLength === 0) {
486 ranges.push(range);
487 range = null;
488 }
489 }
490 return ranges;
491}
492
493const MarkerPluginKey = new PluginKey("composer-rich-markers");
494
495const ComposerMarkerPlugin = new Plugin({
496 key: MarkerPluginKey,
497 state: {
498 init: (_, state) => decorationsForSelection(state.doc, state.selection),
499 apply: (tr, old) =>
500 tr.docChanged || tr.selectionSet ? decorationsForSelection(tr.doc, tr.selection) : old,
501 },
502 props: {
503 decorations(state) {
504 return MarkerPluginKey.getState(state);
505 },
506 },
507});
508
509function decorationsForSelection(
510 doc: ProseMirrorNode,
511 selection: { from: number; to: number; empty: boolean },
512): DecorationSet {
513 const decorations: Decoration[] = [];
514 if (!selection.empty) {
515 doc.nodesBetween(selection.from, selection.to, (node, pos) => {
516 if (node.type.name.startsWith("composer-")) {
517 decorations.push(
518 Decoration.node(pos, pos + node.nodeSize, { class: "composer-chip-range-selected" }),
519 );
520 return false;
521 }
522 return true;
523 });
524 }
525 for (const range of collectStyledRanges(doc)) {
526 const active = selection.empty
527 ? selection.from >= range.from && selection.from <= range.to
528 : selection.from < range.to && selection.to > range.from;
529 if (!active) continue;
530 for (const { at, side, text } of range.markers) {
531 const marker = document.createElement("span");
532 marker.className = "composer-rich-marker";
533 marker.textContent = text;
534 marker.setAttribute("aria-hidden", "true");
535 decorations.push(
536 Decoration.widget(at, marker, { side, key: `marker-${at}-${side}-${marker.textContent}` }),
537 );
538 }
539 }
540 return DecorationSet.create(doc, decorations);
541}
542
543const ComposerMarkersExtension = Extension.create({
544 name: "composer-rich-markers",
545 addProseMirrorPlugins() {
546 return [ComposerMarkerPlugin];
547 },
548});
549
550// Document model (markdown ⇄ ProseMirror) lives in ~/composer-rich-text-doc so
551// unit tests can round-trip it without a browser.
552// ── Editor component ───────────────────────────────────────────────────────
553
554type TiptapEditor = NonNullable<ReturnType<typeof useEditor>>;
555
556export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) {
557 // Extensions are creation-time: flipping the setting remounts the editor.
558 // Both halves initialize from the controlled Markdown value, so the draft
559 // survives the flip.
560 return (
561 <ComposerPromptEditorTiptapInner key={props.richTextEnabled ? "rich" : "plain"} {...props} />
562 );
563}
564
565function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
566 const {
567 value,
568 cursor,
569 richTextEnabled,
570 contextRecords,
571 buildContextClipboardFragment,
572 importContextFragment,
573 skills,
574 disabled,
575 placeholder,
576 containerClassName,
577 className,
578 placeholderClassName,
579 onChange,
580 onVisibleSelectionChange,
581 onCommandKeyDown,
582 onPageScrollKeyDown,
583 onPageScrollKeyUp,
584 onPageScrollRelease,
585 onCitationSubmitAndSend,
586 onPaste,
587 editorRef,
588 } = props;
589 // The setting toggles styling, not the engine: both modes are Tiptap.
590 // Plain mode disables the mark extensions, so markers stay literal text.
591 const richText = richTextEnabled ?? false;
592
593 const onChangeRef = useRef(onChange);
594 const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange);
595 const onCommandKeyDownRef = useRef(onCommandKeyDown);
596 const buildFragmentRef = useRef(buildContextClipboardFragment);
597 const importFragmentRef = useRef(importContextFragment);
598 const skillsRef = useRef(skills);
599 const latestValueRef = useRef(value);
600 // The editor instance for callbacks created before it exists (paste).
601 // Effects flush before any user interaction, so this is always set.
602 const editorHolder = useRef<TiptapEditor | null>(null);
603
604 useEffect(() => {
605 onChangeRef.current = onChange;
606 }, [onChange]);
607 useEffect(() => {
608 onVisibleSelectionChangeRef.current = onVisibleSelectionChange;
609 }, [onVisibleSelectionChange]);
610 useEffect(() => {
611 onCommandKeyDownRef.current = onCommandKeyDown;
612 }, [onCommandKeyDown]);
613 useEffect(() => {
614 buildFragmentRef.current = buildContextClipboardFragment;
615 }, [buildContextClipboardFragment]);
616 useEffect(() => {
617 importFragmentRef.current = importContextFragment;
618 }, [importContextFragment]);
619 useEffect(() => {
620 skillsRef.current = skills;
621 }, [skills]);
622 useLayoutEffect(() => {
623 latestValueRef.current = value;
624 }, [value]);
625
626 const skillLabelFor = useCallback((name: string): SkillMeta => {
627 const normalized = name.startsWith("$") ? name.slice(1) : name;
628 const skill = skillsRef.current.find((candidate) => candidate.name === normalized);
629 if (!skill) {
630 return { label: formatProviderSkillDisplayName({ name: normalized }), description: null };
631 }
632 const shortDescription = skill.shortDescription?.trim();
633 return {
634 label: formatProviderSkillDisplayName(skill),
635 description: shortDescription || skill.description?.trim() || null,
636 };
637 }, []);
638
639 const initialCursor = clampCollapsedComposerCursor(value, cursor);
640 const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor);
641 const snapshotRef = useRef({
642 value,
643 cursor: initialCursor,
644 expandedCursor: initialExpandedCursor,
645 contextIds: collectInlineContextIds(value),
646 });
647 const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor });
648 const isApplyingControlledUpdateRef = useRef(false);
649 const hasAppliedControlledSelectionRef = useRef(false);
650 const citationRequestRef = useRef<ComposerCitationCommentRequest | null>(null);
651 const [openCitation, setOpenCitation] = useState<OpenCitationComment | null>(null);
652 const [isEmpty, setIsEmpty] = useState(value.length === 0);
653
654 const citationCommentActions = useMemo(
655 () => ({
656 openComment: openCitation,
657 onOpenChange: (nodeKey: string, open: boolean) => {
658 setOpenCitation((current) => {
659 if (open) {
660 if (current?.key === nodeKey) return current;
661 return { key: nodeKey };
662 }
663 return current?.key === nodeKey ? null : current;
664 });
665 },
666 onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}),
667 }),
668 [onCitationSubmitAndSend, openCitation],
669 );
670
671 const handleEditorChange = useCallback((updated: TiptapEditor) => {
672 const map = serializeEditorDoc(updated.state.doc);
673 const { from, to } = updated.state.selection;
674 const fromFlat = pmToFlat(map, from);
675 const toFlat = pmToFlat(map, to);
676 const nextValue = map.value;
677 const nextCursor = clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat));
678 const nextExpandedCursor = Math.max(
679 0,
680 Math.min(map.value.length, flatToMarkdown(map, fromFlat)),
681 );
682 const nextSelectionRange = {
683 start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)),
684 end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)),
685 };
686 const previousSelectionRange = selectionRangeRef.current;
687 selectionRangeRef.current = nextSelectionRange;
688 setIsEmpty(nextValue.length === 0);
689 const previousSnapshot = snapshotRef.current;
690 const snapshotChanged = !(
691 previousSnapshot.value === nextValue &&
692 previousSnapshot.cursor === nextCursor &&
693 previousSnapshot.expandedCursor === nextExpandedCursor &&
694 previousSnapshot.contextIds.length === map.contextIds.length &&
695 previousSnapshot.contextIds.every((id, index) => id === map.contextIds[index])
696 );
697 if (isApplyingControlledUpdateRef.current) return;
698 if (!snapshotChanged) {
699 if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) {
700 onVisibleSelectionChangeRef.current?.();
701 }
702 return;
703 }
704 // A selection-only update while a newer prompt waits to be applied (a chip
705 // was just inserted through the store) would report stale text and clobber
706 // the prompt. Let the controlled rewrite land instead.
707 if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) {
708 return;
709 }
710 snapshotRef.current = {
711 value: nextValue,
712 cursor: nextCursor,
713 expandedCursor: nextExpandedCursor,
714 contextIds: map.contextIds,
715 };
716 const cursorAdjacentToMention =
717 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") ||
718 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right");
719 onChangeRef.current(
720 nextValue,
721 nextCursor,
722 nextExpandedCursor,
723 cursorAdjacentToMention,
724 map.contextIds,
725 );
726 }, []);
727
728 const editor = useEditor(
729 {
730 extensions: [
731 StarterKit.configure({
732 blockquote: false,
733 bulletList: false,
734 codeBlock: false,
735 heading: false,
736 horizontalRule: false,
737 listItem: false,
738 link: false,
739 orderedList: false,
740 underline: false,
741 dropcursor: false,
742 gapcursor: false,
743 trailingNode: false,
744 // Plain mode has no marks: typed markers stay literal characters.
745 ...(richText ? {} : { bold: false, italic: false, strike: false, code: false }),
746 }),
747 ComposerMentionExtension,
748 ComposerSkillExtension,
749 ComposerCitationExtension,
750 ComposerContextReferenceExtension,
751 ComposerMarkersExtension,
752 ...(richText
753 ? [
754 TaskList,
755 ComposerTaskItemExtension.extend({
756 addInputRules() {
757 return [
758 wrappingInputRule({
759 find: /^- \[([ xX])\] $/,
760 type: this.type,
761 getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }),
762 }),
763 ];
764 },
765 }),
766 ]
767 : []),
768 ],
769 content: buildDocJson(
770 value,
771 (name) => {
772 const normalized = name.startsWith("$") ? name.slice(1) : name;
773 const found = skills.find((candidate) => candidate.name === normalized);
774 if (!found) {
775 return {
776 label: formatProviderSkillDisplayName({ name: normalized }),
777 description: null,
778 };
779 }
780 const shortDescription = found.shortDescription?.trim();
781 return {
782 label: formatProviderSkillDisplayName(found),
783 description: shortDescription || found.description?.trim() || null,
784 };
785 },
786 { styling: richText },
787 ),
788 editable: !disabled,
789 editorProps: {
790 attributes: {
791 class: cn(
792 "composer-tiptap block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none",
793 className,
794 ),
795 "data-testid": "composer-editor",
796 "data-composer-rich-text": richText ? "true" : "false",
797 "aria-placeholder": placeholder,
798 },
799 handleKeyDown: (view, event) => {
800 if (
801 isMacPlatform(navigator.platform) &&
802 (event.key === "Home" || event.key === "End") &&
803 !event.altKey &&
804 !event.metaKey &&
805 !event.ctrlKey &&
806 !event.isComposing
807 ) {
808 const selection = window.getSelection();
809 if (
810 selection?.anchorNode &&
811 view.dom.contains(selection.anchorNode) &&
812 typeof selection.modify === "function"
813 ) {
814 event.preventDefault();
815 event.stopPropagation();
816 selection.modify(
817 event.shiftKey ? "extend" : "move",
818 event.key === "Home" ? "backward" : "forward",
819 "lineboundary",
820 );
821 if (selection.anchorNode && selection.focusNode) {
822 view.dispatch(
823 view.state.tr
824 .setSelection(
825 TextSelection.create(
826 view.state.doc,
827 view.posAtDOM(selection.anchorNode, selection.anchorOffset),
828 view.posAtDOM(selection.focusNode, selection.focusOffset),
829 ),
830 )
831 .scrollIntoView(),
832 );
833 }
834 return true;
835 }
836 }
837 if (
838 (event.key === "ArrowLeft" || event.key === "ArrowRight") &&
839 !event.shiftKey &&
840 !event.altKey &&
841 !event.metaKey &&
842 !event.ctrlKey &&
843 !event.isComposing &&
844 view.state.selection.empty
845 ) {
846 const { $from } = view.state.selection;
847 const direction = event.key === "ArrowLeft" ? -1 : 1;
848 const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter;
849 if (adjacent?.type.name.startsWith("composer-")) {
850 event.preventDefault();
851 event.stopPropagation();
852 view.dispatch(
853 view.state.tr
854 .setSelection(
855 TextSelection.create(view.state.doc, $from.pos + direction * adjacent.nodeSize),
856 )
857 .scrollIntoView(),
858 );
859 return true;
860 }
861 }
862 if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) {
863 event.stopPropagation();
864 return true;
865 }
866 // Enter on a focused task checkbox must not send the prompt (or
867 // split anything): Space toggles it, Enter does nothing.
868 if (event.key === "Enter" && event.target instanceof HTMLInputElement) {
869 event.preventDefault();
870 return true;
871 }
872 const handler = onCommandKeyDownRef.current;
873 if (event.key === "Enter") {
874 const instance = editorHolder.current;
875 const isTaskItem = richText && (instance?.isActive("taskItem") ?? false);
876 const handled = handler?.("Enter", event, isTaskItem) ?? false;
877 if (handled) {
878 event.preventDefault();
879 event.stopPropagation();
880 return true;
881 }
882 event.preventDefault();
883 if (
884 isTaskItem &&
885 instance &&
886 (instance.commands.splitListItem("taskItem", { checked: false }) ||
887 (view.state.selection.$from.parent.content.size === 0 &&
888 instance.commands.liftListItem("taskItem")))
889 ) {
890 return true;
891 }
892 // Split the paragraph so a single newline visibly advances the caret.
893 return splitBlockKeepMarks(view.state, (tr) => {
894 // The split is programmatic, so the browser won't follow the
895 // caret into view on its own.
896 view.dispatch(tr.scrollIntoView());
897 });
898 }
899 if (!handler) return false;
900 const key =
901 event.key === "Tab"
902 ? ("Tab" as const)
903 : event.key === "ArrowDown"
904 ? ("ArrowDown" as const)
905 : event.key === "ArrowUp"
906 ? ("ArrowUp" as const)
907 : null;
908 if (!key) return false;
909 const handled = handler(key, event);
910 if (handled) {
911 event.preventDefault();
912 event.stopPropagation();
913 }
914 return handled;
915 },
916 handleTextInput: (view, from, to, text) => {
917 if (text.length !== 1) return false;
918 const closer = SURROUND_CLOSE[text];
919 if (!closer || from === to) return false;
920 // Never wrap chips or other atoms, and never wrap styled text: the
921 // default replace keeps marks intact, wrapping would drop them.
922 let touchesSpecial = false;
923 view.state.doc.nodesBetween(from, to, (node) => {
924 if (
925 (node.isAtom && node.isInline && !node.isText) ||
926 (node.isText && node.marks.length > 0)
927 ) {
928 touchesSpecial = true;
929 return false;
930 }
931 return true;
932 });
933 if (touchesSpecial) return false;
934 const map = serializeEditorDoc(view.state.doc);
935 const startMd = flatToMarkdown(map, pmToFlat(map, from));
936 const endMd = flatToMarkdown(map, pmToFlat(map, to));
937 if (selectionTouchesMentionBoundary(map.value, startMd, endMd)) return false;
938 const tr = view.state.tr.insertText(closer, to).insertText(text, from);
939 tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length));
940 view.dispatch(tr);
941 return true;
942 },
943 handlePaste: (view, event) => {
944 const clipboardData = event.clipboardData;
945 if (!clipboardData || clipboardData.files.length > 0) return false;
946 const pastedText = clipboardData.getData("text/plain");
947 if (!pastedText) return false;
948 event.preventDefault();
949 const importFragment = importFragmentRef.current;
950 let text = importFragment
951 ? importPastedComposerText(clipboardData, importFragment)
952 : pastedText;
953 // Complete chips at paste boundaries just as autocomplete does.
954 const tokens = collectComposerPromptInlineTokens(`${text}\n`);
955 const lastToken = tokens.at(-1);
956 if (
957 (lastToken?.type === "mention" || lastToken?.type === "skill") &&
958 lastToken.end === text.length
959 ) {
960 text += " ";
961 }
962 if (
963 (tokens[0]?.type === "mention" || tokens[0]?.type === "skill") &&
964 tokens[0].start === 0
965 ) {
966 const map = serializeEditorDoc(view.state.doc);
967 const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from));
968 if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`;
969 }
970 const editorInstance = editorHolder.current;
971 if (editorInstance) {
972 insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => {
973 editorInstance.commands.insertContent(content);
974 });
975 scrollTiptapCaretIntoView(editorInstance);
976 }
977 return true;
978 },
979 },
980 onUpdate: ({ editor: updated }) => {
981 handleEditorChange(updated);
982 },
983 onSelectionUpdate: ({ editor: updated }) => {
984 handleEditorChange(updated);
985 },
986 },
987 [],
988 );
989
990 useEffect(() => {
991 editor?.setEditable(!disabled);
992 }, [disabled, editor]);
993
994 useEffect(() => {
995 editorHolder.current = editor;
996 }, [editor]);
997
998 const readSnapshot = useCallback(() => {
999 const snapshot = snapshotRef.current;
1000 if (!editor) return snapshot;
1001 const map = serializeEditorDoc(editor.state.doc);
1002 const { from, to } = editor.state.selection;
1003 const fromFlat = pmToFlat(map, from);
1004 const next: typeof snapshot = {
1005 value: map.value,
1006 cursor: clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)),
1007 expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))),
1008 contextIds: map.contextIds,
1009 };
1010 const toFlat = pmToFlat(map, to);
1011 selectionRangeRef.current = {
1012 start: Math.min(next.expandedCursor, flatToMarkdown(map, toFlat)),
1013 end: Math.max(next.expandedCursor, flatToMarkdown(map, toFlat)),
1014 };
1015 snapshotRef.current = next;
1016 return next;
1017 }, [editor]);
1018
1019 // Controlled value/cursor from the store (history recall, chip insertion…).
1020 useLayoutEffect(() => {
1021 if (!editor) return;
1022 const initialSelection = !hasAppliedControlledSelectionRef.current;
1023 hasAppliedControlledSelectionRef.current = true;
1024 const normalizedCursor = clampCollapsedComposerCursor(value, cursor);
1025 const previousSnapshot = snapshotRef.current;
1026 if (
1027 !initialSelection &&
1028 previousSnapshot.value === value &&
1029 previousSnapshot.cursor === normalizedCursor
1030 ) {
1031 return;
1032 }
1033 const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor);
1034 snapshotRef.current = {
1035 value,
1036 cursor: normalizedCursor,
1037 expandedCursor: normalizedExpandedCursor,
1038 contextIds: collectInlineContextIds(value),
1039 };
1040 selectionRangeRef.current = {
1041 start: normalizedExpandedCursor,
1042 end: normalizedExpandedCursor,
1043 };
1044 setIsEmpty(value.length === 0);
1045 const rootElement = editor.view.dom;
1046 const isFocused = Boolean(rootElement && document.activeElement === rootElement);
1047 if (!initialSelection && previousSnapshot.value === value && !isFocused) return;
1048
1049 isApplyingControlledUpdateRef.current = true;
1050 const pendingCitation =
1051 citationRequestRef.current?.value === value ? citationRequestRef.current : null;
1052 if (previousSnapshot.value !== value) {
1053 editor.commands.setContent(buildDocJson(value, skillLabelFor, { styling: richText }), {
1054 emitUpdate: false,
1055 });
1056 }
1057 const map = serializeEditorDoc(editor.state.doc);
1058 const flat = collapsedToFlat(map, normalizedCursor);
1059 editor.commands.setTextSelection(flatToPm(map, flat));
1060 if (isFocused) scrollTiptapCaretIntoView(editor);
1061 if (pendingCitation) {
1062 citationRequestRef.current = null;
1063 const target = map.runs.find(
1064 (run) =>
1065 run.kind === "token" &&
1066 run.nodeName === "composer-citation" &&
1067 run.mdStart === pendingCitation.citationStart,
1068 );
1069 if (target) {
1070 const node = editor.state.doc.nodeAt(target.pmPos);
1071 const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey;
1072 if (citeKey) {
1073 setOpenCitation({
1074 key: citeKey,
1075 sourceAnchor: pendingCitation.sourceAnchor,
1076 removeOnCancel: true,
1077 });
1078 }
1079 }
1080 }
1081 queueMicrotask(() => {
1082 isApplyingControlledUpdateRef.current = false;
1083 });
1084 }, [cursor, editor, richText, skillLabelFor, value]);
1085
1086 const focusAt = useCallback(
1087 (nextCursor: number) => {
1088 if (!editor) return;
1089 editor.view.dom.focus({ preventScroll: true });
1090 // A newer prompt is waiting to be applied (a chip was just inserted
1091 // through the store). Reporting the editor's stale text now would
1092 // overwrite that prompt; the pending rewrite places the caret instead.
1093 if (snapshotRef.current.value !== latestValueRef.current) return;
1094 const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor);
1095 const map = serializeEditorDoc(editor.state.doc);
1096 const flat = collapsedToFlat(map, boundedCursor);
1097 editor.commands.setTextSelection(flatToPm(map, flat));
1098 scrollTiptapCaretIntoView(editor);
1099 if (boundedCursor === snapshotRef.current.cursor) return;
1100 snapshotRef.current = {
1101 value: snapshotRef.current.value,
1102 cursor: boundedCursor,
1103 expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),
1104 contextIds: snapshotRef.current.contextIds,
1105 };
1106 selectionRangeRef.current = {
1107 start: snapshotRef.current.expandedCursor,
1108 end: snapshotRef.current.expandedCursor,
1109 };
1110 onChangeRef.current(
1111 snapshotRef.current.value,
1112 boundedCursor,
1113 snapshotRef.current.expandedCursor,
1114 false,
1115 snapshotRef.current.contextIds,
1116 );
1117 },
1118 [editor],
1119 );
1120
1121 useImperativeHandle(
1122 editorRef,
1123 () => ({
1124 focus: () => {
1125 focusAt(snapshotRef.current.cursor);
1126 },
1127 focusAt,
1128 focusAtEnd: () => {
1129 focusAt(
1130 collapseExpandedComposerCursor(
1131 snapshotRef.current.value,
1132 snapshotRef.current.value.length,
1133 ),
1134 );
1135 },
1136 readSelectionRange: () => {
1137 readSnapshot();
1138 return selectionRangeRef.current;
1139 },
1140 requestCitationComment: (request) => {
1141 citationRequestRef.current = request;
1142 if (!editor) return;
1143 const map = serializeEditorDoc(editor.state.doc);
1144 if (map.value !== request.value) return;
1145 const target = map.runs.find(
1146 (run) =>
1147 run.kind === "token" &&
1148 run.nodeName === "composer-citation" &&
1149 run.mdStart === request.citationStart,
1150 );
1151 if (!target) return;
1152 const node = editor.state.doc.nodeAt(target.pmPos);
1153 const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey;
1154 if (citeKey) {
1155 citationRequestRef.current = null;
1156 setOpenCitation({
1157 key: citeKey,
1158 sourceAnchor: request.sourceAnchor,
1159 removeOnCancel: true,
1160 });
1161 }
1162 },
1163 readSnapshot,
1164 isCaretOnVisualEdge: (edge) => {
1165 const snapshot = readSnapshot();
1166 if (snapshot.value.length === 0) return true;
1167 const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor);
1168 const afterCaret = snapshot.value.slice(snapshot.expandedCursor);
1169 if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) {
1170 return false;
1171 }
1172 const rootElement = editor?.view.dom;
1173 const selection = window.getSelection();
1174 if (
1175 !rootElement ||
1176 !selection ||
1177 !selection.isCollapsed ||
1178 selection.rangeCount === 0 ||
1179 !selection.anchorNode ||
1180 !rootElement.contains(selection.anchorNode)
1181 ) {
1182 return false;
1183 }
1184 const caretRect = caretLineRect(selection.getRangeAt(0), edge);
1185 if (!caretRect) return false;
1186 const edgeElement =
1187 edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild;
1188 const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect();
1189 const threshold = caretRect.height / 2;
1190 return edge === "start"
1191 ? caretRect.top - edgeRect.top < threshold
1192 : edgeRect.bottom - caretRect.bottom < threshold;
1193 },
1194 }),
1195 [editor, focusAt, readSnapshot],
1196 );
1197
1198 const handleCopyCut = useCallback(
1199 (event: React.ClipboardEvent, cut: boolean) => {
1200 const build = buildFragmentRef.current;
1201 if (!editor || (cut && !editor.isEditable)) return;
1202 const clipboardData = event.clipboardData;
1203 const { from, to } = editor.state.selection;
1204 if (from === to) return;
1205 const { doc, schema } = editor.state;
1206 const slice = doc.slice(from, to);
1207 const first = slice.content.firstChild;
1208 const content = first?.isInline
1209 ? schema.nodes.paragraph!.create(null, slice.content)
1210 : first?.type.name === "taskItem"
1211 ? schema.nodes.taskList!.create(null, slice.content)
1212 : slice.content;
1213 const text = serializeEditorDoc(doc.type.create(null, content)).value;
1214 const contextIds = Array.from(new Set(collectInlineContextIds(text)));
1215 const fragment = contextIds.length > 0 ? build?.(contextIds) : null;
1216 event.preventDefault();
1217 clipboardData.setData("text/plain", text);
1218 if (fragment) {
1219 clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment);
1220 clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment));
1221 }
1222 if (cut) {
1223 editor.chain().focus().deleteSelection().run();
1224 }
1225 },
1226 [editor],
1227 );
1228
1229 return (
1230 <RichComposerSkillsContext value={skills}>
1231 <ComposerContextRecordsContext value={contextRecords}>
1232 <ComposerCitationCommentContext value={citationCommentActions}>
1233 <div
1234 className={cn(
1235 "relative [font-family:var(--font-composer,var(--font-sans))] [font-size:var(--font-size-prompt,0.875rem)] [@media(max-width:39.999rem)_and_(pointer:coarse)]:[font-size:max(var(--font-size-prompt,1rem),16px)]",
1236 containerClassName,
1237 )}
1238 >
1239 <EditorContent
1240 editor={editor}
1241 onKeyDown={(event) => {
1242 if (
1243 event.key === "Control" ||
1244 event.key === "Meta" ||
1245 event.key === "Alt" ||
1246 event.key === "Shift"
1247 ) {
1248 onPageScrollRelease?.();
1249 }
1250 if (event.key !== "PageUp" && event.key !== "PageDown") return;
1251 const target = event.currentTarget.querySelector(
1252 '[data-testid="composer-editor"]',
1253 ) as HTMLElement | null;
1254 if (!target) return;
1255 const pageScrollKey = getTimelinePageScrollKey({
1256 altKey: event.altKey,
1257 clientHeight: target.clientHeight,
1258 ctrlKey: event.ctrlKey,
1259 defaultPrevented: event.defaultPrevented,
1260 isComposing: event.nativeEvent.isComposing,
1261 key: event.key,
1262 keyCode: event.keyCode,
1263 metaKey: event.metaKey,
1264 scrollHeight: target.scrollHeight,
1265 scrollTop: target.scrollTop,
1266 shiftKey: event.shiftKey,
1267 });
1268 if (!pageScrollKey) {
1269 onPageScrollRelease?.();
1270 return;
1271 }
1272 if (!onPageScrollKeyDown) return;
1273 event.preventDefault();
1274 onPageScrollKeyDown(pageScrollKey);
1275 }}
1276 onKeyUp={(event) => onPageScrollKeyUp?.(event.key)}
1277 onBlur={onPageScrollRelease}
1278 onPasteCapture={onPaste}
1279 onCopyCapture={(event) => handleCopyCut(event, false)}
1280 onCutCapture={(event) => handleCopyCut(event, true)}
1281 />
1282 {isEmpty && contextRecords.size === 0 && placeholder ? (
1283 <div
1284 className={cn(
1285 "pointer-events-none absolute inset-0 leading-relaxed text-placeholder/75",
1286 placeholderClassName,
1287 )}
1288 >
1289 {placeholder}
1290 </div>
1291 ) : null}
1292 </div>
1293 </ComposerCitationCommentContext>
1294 </ComposerContextRecordsContext>
1295 </RichComposerSkillsContext>
1296 );
1297}
1298
1299/**
1300 * Insert pasted markdown at the selection, rebuilding inline tokens as chips
1301 * and styled spans as marks.
1302 *
1303 * Newlines always become paragraph splits — never trailing hard breaks, which
1304 * render no visible line — so pasted text lands exactly as typed.
1305 */
1306function insertMarkdownParagraphs(
1307 value: string,
1308 skillLabelFor: (name: string) => SkillMeta,
1309 options: { styling: boolean },
1310 insertContent: (content: JSONContent[] | JSONContent) => void,
1311): void {
1312 const blocks = buildTiptapContent(value, skillLabelFor, options);
1313 if (blocks.length === 1 && blocks[0]?.type === "paragraph") {
1314 const inline = (blocks[0]?.content ?? []) as JSONContent[];
1315 if (inline.length === 0) return;
1316 insertContent(inline);
1317 return;
1318 }
1319 insertContent(blocks as JSONContent[]);
1320}
1321
1322/**
1323 * Follow a programmatically placed caret: native scrolling only happens for
1324 * real input, so controlled rewrites, pastes, and focus restores scroll the
1325 * composer to the caret explicitly.
1326 */
1327function scrollTiptapCaretIntoView(editor: TiptapEditor): void {
1328 editor.view.dispatch(editor.state.tr.scrollIntoView());
1329}
1330
1331/**
1332 * Client rect of the caret's visual line, so prompt history keeps claiming
1333 * ArrowUp/Down only at the first and last soft-wrapped lines.
1334 */
1335function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null {
1336 const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0);
1337 const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0];
1338 if (collapsedRect) return collapsedRect;
1339
1340 const container = range.startContainer;
1341 // TEXT_NODE without importing the DOM lib's Node (shadowed by Tiptap's).
1342 if (container.nodeType === 3) {
1343 const textNode = container as Text;
1344 if (textNode.data.length === 0) return null;
1345 const probeStart = Math.max(
1346 0,
1347 Math.min(
1348 edge === "start" ? range.startOffset : range.startOffset - 1,
1349 textNode.data.length - 1,
1350 ),
1351 );
1352 const probeRange = document.createRange();
1353 probeRange.setStart(textNode, probeStart);
1354 probeRange.setEnd(textNode, probeStart + 1);
1355 const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0);
1356 if (probeRect) return probeRect;
1357 const boundingRect = probeRange.getBoundingClientRect();
1358 return boundingRect.height > 0 ? boundingRect : null;
1359 }
1360
1361 if (!(container instanceof HTMLElement)) return null;
1362 const neighbour =
1363 container.childNodes[Math.max(0, range.startOffset - 1)] ??
1364 container.childNodes[range.startOffset];
1365 if (neighbour instanceof HTMLElement) {
1366 const neighbourRect = neighbour.getBoundingClientRect();
1367 if (neighbourRect.height > 0) return neighbourRect;
1368 } else if (neighbour instanceof Text && neighbour.data.length > 0) {
1369 const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1];
1370 const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0;
1371 const probeRange = document.createRange();
1372 probeRange.setStart(neighbour, probeStart);
1373 probeRange.setEnd(neighbour, probeStart + 1);
1374 const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0);
1375 if (probeRect) return probeRect;
1376 }
1377 const containerRect = container.getBoundingClientRect();
1378 return containerRect.height > 0 ? containerRect : null;
1379}
apps/web/src/components/chat/ChatComposer.tsx+34-2
......@@ -79,6 +79,7 @@ import {
7979 replaceTextRange,
8080} from "../../composer-logic";
8181import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
82import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation";
8283import {
8384 deriveComposerSendState,
8485 getAntigravitySendBlockReason,
......@@ -3264,7 +3265,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
32643265 }, [setIsComposerScrollCollapsed]);
32653266
32663267 /**
3267 * Payloads for chips the prompt no longer references. Lexical's history restores the
3268 * Payloads for chips the prompt no longer references. History undo restores the
32683269 * reference text but knows nothing about the draft records behind it, so a delete keeps its
32693270 * payload here and an undo puts it back rather than leaving a dangling chip.
32703271 */
......@@ -3420,6 +3421,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34203421 replacement: string,
34213422 options?: {
34223423 expectedText?: string;
3424 expandedCursorAfterReplace?: number;
34233425 focusEditorAfterReplace?: boolean;
34243426 citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor };
34253427 },
......@@ -3440,7 +3442,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34403442 return false;
34413443 }
34423444 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
3443 const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
3445 const nextCursor = collapseExpandedComposerCursor(
3446 next.text,
3447 options?.expandedCursorAfterReplace ?? next.cursor,
3448 );
34443449 const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor);
34453450 if (options?.citationComment) {
34463451 composerEditorRef.current?.requestCitationComment({
......@@ -3927,6 +3932,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39273932 const onComposerCommandKey = (
39283933 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
39293934 event: KeyboardEvent,
3935 isTaskItem = false,
39303936 ) => {
39313937 if (key === "Tab" && event.shiftKey) {
39323938 if (!planModeUiEnabled) return false;
......@@ -3970,6 +3976,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39703976 submitComposer(undefined, submissionIntent);
39713977 return true;
39723978 }
3979 // Native task splitting preserves marks and chips on both sides of the caret.
3980 if (key === "Enter" && isTaskItem) return false;
3981 if (!event.isComposing && (key === "Enter" || (key === "Tab" && !event.shiftKey))) {
3982 const selection = composerEditorRef.current?.readSelectionRange();
3983 const snapshot = readComposerSnapshot();
3984 if (selection && selection.start === selection.end && snapshot.value === promptRef.current) {
3985 const edit =
3986 key === "Enter"
3987 ? listContinuationForEnter(snapshot.value, selection.start)
3988 : listIndentForTab(snapshot.value, selection.start, selection.end);
3989 if (
3990 edit &&
3991 applyPromptReplacement(
3992 edit.start,
3993 edit.end,
3994 edit.replacement,
3995 key === "Tab"
3996 ? { expandedCursorAfterReplace: selection.start + edit.replacement.length }
3997 : undefined,
3998 )
3999 ) {
4000 return true;
4001 }
4002 }
4003 }
39734004 return false;
39744005 };
39754006
......@@ -6699,6 +6730,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
66996730 <ComposerContextActionsContext value={composerContextActions}>
67006731 <ComposerPromptEditor
67016732 editorRef={composerEditorRef}
6733 richTextEnabled={settings.composerRichTextEnabled}
67026734 value={
67036735 isComposerApprovalState
67046736 ? ""
apps/web/src/components/chat/ComposerStashMenu.tsx+1-1
......@@ -33,7 +33,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string {
3333 * Attached banner listing the stashed prompts. Opened by the stash badge or ⌘S
3434 * when the empty composer cannot restore a single entry. Navigated with arrows,
3535 * restored with Enter, dismissed with Escape. The listener runs capture-phase
36 * on window so it wins over the Lexical editor's handlers while the menu is open.
36 * on window so it wins over the composer's handlers while the menu is open.
3737 */
3838export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
3939 entries: ReadonlyArray<PromptStashEntry>;
apps/web/src/components/composerInlineTokenPaste.ts+4-136
......@@ -1,5 +1,5 @@
11import { ComposerContextId } from "@t3tools/contracts";
2import type { AssistantCitation, ComposerContextClipboardFragment } from "@t3tools/contracts";
2import type { ComposerContextClipboardFragment } from "@t3tools/contracts";
33import {
44 COMPOSER_CONTEXT_CLIPBOARD_MIME,
55 decodeComposerContextFragment,
......@@ -10,140 +10,6 @@ import {
1010 formatComposerContextReference,
1111 replaceComposerContextReferences,
1212} from "@t3tools/shared/composerContextReferences";
13import {
14 $createLineBreakNode,
15 $createTextNode,
16 $getRoot,
17 $getSelection,
18 $isRangeSelection,
19 COMMAND_PRIORITY_HIGH,
20 PASTE_COMMAND,
21 type LexicalEditor,
22 type LexicalNode,
23} from "lexical";
24
25import { collectComposerPromptInlineTokens } from "../composer-editor-mentions";
26
27interface ComposerInlineTokenPasteOptions {
28 createMentionNode: (path: string) => LexicalNode;
29 createCitationNode: (citation: AssistantCitation, source: string) => LexicalNode;
30 createContextReferenceNode: (reference: {
31 kind: string;
32 contextId: string;
33 label: string;
34 }) => LexicalNode;
35 getExpandedAbsoluteOffsetForPoint: (node: LexicalNode, pointOffset: number) => number;
36 /**
37 * Imports the records behind a structured paste into the draft. Returns the ids that had
38 * to change (a re-attached binary gets a fresh local id) so the pasted links follow.
39 */
40 importContextFragment?: (
41 fragment: ComposerContextClipboardFragment,
42 ) => ReadonlyMap<string, string>;
43}
44
45export function registerComposerInlineTokenPaste(
46 editor: LexicalEditor,
47 options: ComposerInlineTokenPasteOptions,
48): () => void {
49 return editor.registerCommand(
50 PASTE_COMMAND,
51 (event) => {
52 if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
53 return false;
54 }
55 if (event.clipboardData.files.length > 0) {
56 return false;
57 }
58 const pastedText = event.clipboardData.getData("text/plain");
59 if (pastedText.length === 0) {
60 return false;
61 }
62 const text = importPastedComposerText(event.clipboardData, options.importContextFragment);
63 // Token grammar requires trailing whitespace; a virtual newline lets a
64 // mention at the very end of the pasted text still parse.
65 const tokens = collectComposerPromptInlineTokens(`${text}\n`).filter(
66 (token) =>
67 (token.type === "mention" ||
68 token.type === "citation" ||
69 token.type === "context-reference") &&
70 token.end <= text.length,
71 );
72 if (tokens.length === 0) {
73 return false;
74 }
75
76 // Lexical command listeners already run inside an editor update. Starting
77 // a nested update here queues the token insertion until after this
78 // listener returns, which lets the plain-text paste handler run as well.
79 const selection = $getSelection();
80 if (!$isRangeSelection(selection)) {
81 return false;
82 }
83 const nodes: LexicalNode[] = [];
84 const appendText = (value: string) => {
85 const lines = value.split("\n");
86 for (let index = 0; index < lines.length; index += 1) {
87 const line = lines[index] ?? "";
88 if (line.length > 0) {
89 nodes.push($createTextNode(line));
90 }
91 if (index < lines.length - 1) {
92 nodes.push($createLineBreakNode());
93 }
94 }
95 };
96 const firstToken = tokens[0];
97 if (firstToken?.type === "mention" && firstToken.start === 0) {
98 const startPoint = selection.isBackward() ? selection.focus : selection.anchor;
99 const insertionOffset = options.getExpandedAbsoluteOffsetForPoint(
100 startPoint.getNode(),
101 startPoint.offset,
102 );
103 const precedingChar = $getRoot()
104 .getTextContent()
105 .slice(insertionOffset - 1, insertionOffset);
106 if (precedingChar.length > 0 && !/\s/.test(precedingChar)) {
107 nodes.push($createTextNode(" "));
108 }
109 }
110 let cursor = 0;
111 for (const token of tokens) {
112 if (token.start < cursor) {
113 continue;
114 }
115 if (token.start > cursor) {
116 appendText(text.slice(cursor, token.start));
117 }
118 nodes.push(
119 token.type === "citation"
120 ? options.createCitationNode(token.citation, token.source)
121 : token.type === "context-reference"
122 ? options.createContextReferenceNode({
123 kind: token.kind,
124 contextId: token.contextId,
125 label: token.label,
126 })
127 : options.createMentionNode(token.value),
128 );
129 cursor = token.end;
130 }
131 if (cursor < text.length) {
132 appendText(text.slice(cursor));
133 } else if (tokens.at(-1)?.type === "mention") {
134 // Keep the serialized prompt valid: mention tokens need trailing
135 // whitespace, so a paste ending in a mention gets the same
136 // trailing space the autocomplete inserts.
137 nodes.push($createTextNode(" "));
138 }
139 selection.insertNodes(nodes);
140 event.preventDefault();
141 return true;
142 },
143 COMMAND_PRIORITY_HIGH,
144 );
145}
146
14713/** Clipboard records referenced by the copied text, including dependent screenshots. */
14814export function readPastedComposerContext(
14915 clipboardData: Pick<DataTransfer, "getData">,
......@@ -177,7 +43,9 @@ export function readPastedComposerContext(
17743/** Imports the same structured clipboard payload for focused paste and paste-to-focus. */
17844export function importPastedComposerText(
17945 clipboardData: Pick<DataTransfer, "getData">,
180 importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"],
46 importContextFragment?: (
47 fragment: ComposerContextClipboardFragment,
48 ) => ReadonlyMap<string, string>,
18149): string {
18250 const pastedText = clipboardData.getData("text/plain");
18351 const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null;
apps/web/src/components/settings/SettingsFontPreviews.tsx+1-1
......@@ -10,7 +10,7 @@ import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
1010import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface";
1111
1212// The font previews are the real surfaces, not lookalikes: the composer's
13// Lexical editor, the diff panel's file diff, and the Ghostty canvas
13// Tiptap editor, the diff panel's file diff, and the Ghostty canvas
1414// renderer. Each already consumes the appearance font tokens (or, for the
1515// terminal, the settings passed down as props), so what the row shows is
1616// exactly what the app renders.
apps/web/src/components/settings/SettingsPanels.tsx+32
......@@ -579,6 +579,9 @@ export function useSettingsRestore(onRestored?: () => void) {
579579 ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll
580580 ? ["Collapse composer on scroll"]
581581 : []),
582 ...(settings.composerRichTextEnabled !== DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled
583 ? ["Rich text composer"]
584 : []),
582585 ...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []),
583586 ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior
584587 ? ["Follow-up behavior"]
......@@ -641,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) {
641644 settings.confirmThreadDelete,
642645 settings.confirmThreadUnpin,
643646 settings.composerCollapseOnScroll,
647 settings.composerRichTextEnabled,
644648 settings.sendShortcut,
645649 settings.followUpBehavior,
646650 settings.addProjectBaseDirectory,
......@@ -755,6 +759,7 @@ export function useSettingsRestore(onRestored?: () => void) {
755759 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
756760 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
757761 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
762 composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled,
758763 sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut,
759764 followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior,
760765 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
......@@ -2577,6 +2582,33 @@ export function GeneralSettingsPanel() {
25772582 }
25782583 />
25792584
2585 <SettingsRow
2586 {...searchableSetting("composer-rich-text")}
2587 description="Show formatted Markdown as you type."
2588 resetAction={
2589 settings.composerRichTextEnabled !==
2590 DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled ? (
2591 <SettingResetButton
2592 label="rich text composer"
2593 onClick={() =>
2594 updateSettings({
2595 composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled,
2596 })
2597 }
2598 />
2599 ) : null
2600 }
2601 control={
2602 <Switch
2603 checked={settings.composerRichTextEnabled}
2604 onCheckedChange={(checked) =>
2605 updateSettings({ composerRichTextEnabled: Boolean(checked) })
2606 }
2607 aria-label="Rich text composer"
2608 />
2609 }
2610 />
2611
25802612 <SettingsRow
25812613 {...searchableSetting("composer-collapse")}
25822614 description="Rest the composer of an existing thread into a single line when you scroll the conversation. Focus the composer or start typing to expand it again."
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -292,6 +292,12 @@ export const SETTINGS_SEARCH_ITEMS = [
292292 to: "/settings/general",
293293 searchTerms: ["command menu dollar $ slash /"],
294294 },
295 {
296 id: "composer-rich-text",
297 title: "Rich text composer",
298 to: "/settings/general",
299 searchTerms: ["composer rich text tiptap bold italic markdown styled wysiwyg"],
300 },
295301 {
296302 id: "composer-collapse",
297303 title: "Collapse composer on scroll",
apps/web/src/composer-list-continuation.test.ts created+62
......@@ -0,0 +1,62 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { listContinuationForEnter, listIndentForTab } from "./composer-list-continuation";
4
5function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) {
6 return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end);
7}
8
9describe("composer list continuation", () => {
10 it("continues ordered lists with the next number", () => {
11 const value = "1. foo";
12 const edit = listContinuationForEnter(value, value.length);
13 expect(edit).not.toBeNull();
14 expect(applyEdit(value, edit!)).toBe("1. foo\n2. ");
15 });
16
17 it("increments multi-digit and paren markers", () => {
18 expect(applyEdit("12. foo", listContinuationForEnter("12. foo", 7)!)).toBe("12. foo\n13. ");
19 expect(applyEdit("3) foo", listContinuationForEnter("3) foo", 6)!)).toBe("3) foo\n4) ");
20 });
21
22 it("continues bullets and keeps indentation", () => {
23 expect(applyEdit("- foo", listContinuationForEnter("- foo", 5)!)).toBe("- foo\n- ");
24 expect(applyEdit(" * foo", listContinuationForEnter(" * foo", 7)!)).toBe(" * foo\n * ");
25 });
26
27 it("continues tasks unchecked", () => {
28 expect(applyEdit("- [x] done", listContinuationForEnter("- [x] done", 10)!)).toBe(
29 "- [x] done\n- [ ] ",
30 );
31 });
32
33 it("splits mid-line items", () => {
34 expect(applyEdit("1. foobar", listContinuationForEnter("1. foobar", 5)!)).toBe(
35 "1. fo\n2. obar",
36 );
37 });
38
39 it("exits the list on an empty item", () => {
40 expect(applyEdit("1. foo\n2. ", listContinuationForEnter("1. foo\n2. ", 10)!)).toBe("1. foo\n");
41 expect(applyEdit("- ", listContinuationForEnter("- ", 2)!)).toBe("");
42 });
43
44 it("ignores non-list lines and carets inside the marker", () => {
45 expect(listContinuationForEnter("plain text", 5)).toBeNull();
46 expect(listContinuationForEnter("1.foo no space", 3)).toBeNull();
47 expect(listContinuationForEnter("-foo", 2)).toBeNull();
48 expect(listContinuationForEnter("1. foo", 1)).toBeNull();
49 });
50
51 it("refuses to split an inline chip", () => {
52 const value = "1. @README.md go";
53 const cursor = value.indexOf("README") + 2;
54 expect(listContinuationForEnter(value, cursor)).toBeNull();
55 });
56
57 it("indents list items on Tab", () => {
58 expect(applyEdit("- foo", listIndentForTab("- foo", 2, 2)!)).toBe(" - foo");
59 expect(listIndentForTab("plain", 2, 2)).toBeNull();
60 expect(listIndentForTab("- foo", 1, 3)).toBeNull();
61 });
62});
apps/web/src/composer-list-continuation.ts created+137
......@@ -0,0 +1,137 @@
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
2
3/**
4 * List continuation and indentation for the composer.
5 *
6 * Implemented once at the ChatComposer level (store replacement), so both
7 * composer modes behave identically and serialize identically:
8 * Shift+Enter on a list item continues it, Enter on an empty item exits the
9 * list, and Tab indents the item. Plain Markdown markers only — no real list
10 * nodes anywhere.
11 */
12
13export interface ComposerListEdit {
14 /** Expanded (plain string) offsets into the prompt. */
15 start: number;
16 end: number;
17 replacement: string;
18}
19
20type ListMarker =
21 | { kind: "ordered"; indent: string; numberText: string; delimiter: "." | ")"; space: string }
22 | { kind: "task"; indent: string; space: string }
23 | { kind: "bullet"; indent: string; bullet: string; space: string };
24
25function parseListMarker(line: string): { marker: ListMarker; markerEnd: number } | null {
26 const indent = line.match(/^[ \t]*/)?.[0] ?? "";
27 const rest = line.slice(indent.length);
28 const ordered = rest.match(/^(\d+)([.)])((?:[ \t]+)|\s*$)/);
29 if (ordered && ordered[1] !== undefined && ordered[2] !== undefined) {
30 return {
31 marker: {
32 kind: "ordered",
33 indent,
34 numberText: ordered[1],
35 delimiter: ordered[2] === ")" ? ")" : ".",
36 space: ordered[3] ?? "",
37 },
38 markerEnd: indent.length + ordered[0].length,
39 };
40 }
41 const task = rest.match(/^-\s\[[ xX]\]((?:[ \t]+)|\s*$)/);
42 if (task) {
43 return {
44 marker: { kind: "task", indent, space: task[1] ?? "" },
45 markerEnd: indent.length + task[0].length,
46 };
47 }
48 const bullet = rest.match(/^([-*+])((?:[ \t]+)|\s*$)/);
49 if (bullet && bullet[1] !== undefined) {
50 return {
51 marker: { kind: "bullet", indent, bullet: bullet[1], space: bullet[2] ?? "" },
52 markerEnd: indent.length + bullet[0].length,
53 };
54 }
55 return null;
56}
57
58function nextMarkerText(marker: ListMarker): string {
59 if (marker.kind === "ordered") {
60 const number = Number.parseInt(marker.numberText, 10);
61 const next = Number.isSafeInteger(number)
62 ? String(number + 1).padStart(marker.numberText.length, "0")
63 : marker.numberText;
64 return `${marker.indent}${next}${marker.delimiter} `;
65 }
66 if (marker.kind === "task") {
67 return `${marker.indent}- [ ] `;
68 }
69 return `${marker.indent}${marker.bullet} `;
70}
71
72function segmentSource(
73 segment: ReturnType<typeof splitPromptIntoComposerSegments>[number],
74): string {
75 if (segment.type === "text") return segment.text;
76 if (segment.type === "skill") return `$${segment.name}`;
77 return segment.source;
78}
79
80/** True when splitting at the caret would cut an inline chip in two. */
81function isInsideInlineToken(value: string, cursor: number): boolean {
82 let offset = 0;
83 for (const segment of splitPromptIntoComposerSegments(value)) {
84 const end = offset + segmentSource(segment).length;
85 if (segment.type !== "text" && cursor > offset && cursor < end) return true;
86 offset = end;
87 }
88 return false;
89}
90
91function currentLine(value: string, cursor: number): { start: number; end: number; text: string } {
92 const start = value.lastIndexOf("\n", cursor - 1) + 1;
93 const endIndex = value.indexOf("\n", cursor);
94 const end = endIndex === -1 ? value.length : endIndex;
95 return { start, end, text: value.slice(start, end) };
96}
97
98/**
99 * Enter on a list item line: continue the list, or exit it when the item is
100 * empty. Returns null for non-list lines, carets inside the marker, and
101 * carets inside an inline chip — all fall through to a plain newline.
102 */
103export function listContinuationForEnter(value: string, cursor: number): ComposerListEdit | null {
104 if (!Number.isInteger(cursor) || cursor < 0 || cursor > value.length) return null;
105 const line = currentLine(value, cursor);
106 const parsed = parseListMarker(line.text);
107 if (!parsed) return null;
108 const markerEnd = line.start + parsed.markerEnd;
109 if (cursor < markerEnd) return null;
110 if (isInsideInlineToken(value, cursor)) return null;
111 if (value.slice(markerEnd, line.end).trim() === "") {
112 // Empty item: Enter exits the list by removing the marker.
113 return { start: line.start, end: Math.max(cursor, markerEnd), replacement: "" };
114 }
115 return {
116 start: cursor,
117 end: cursor,
118 replacement: `\n${nextMarkerText(parsed.marker)}`,
119 };
120}
121
122/**
123 * Tab on a list item line: indent by two spaces. Ranged selections, non-list
124 * lines, and carets inside an inline chip fall through (Shift+Tab stays the
125 * plan-mode toggle and is handled before this is consulted).
126 */
127export function listIndentForTab(
128 value: string,
129 start: number,
130 end: number,
131): ComposerListEdit | null {
132 if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null;
133 const line = currentLine(value, start);
134 if (!parseListMarker(line.text)) return null;
135 if (isInsideInlineToken(value, start)) return null;
136 return { start: line.start, end: line.start, replacement: " " };
137}
apps/web/src/composer-rich-text-doc.test.ts created+344
......@@ -0,0 +1,344 @@
1import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/core";
2import StarterKit from "@tiptap/starter-kit";
3import { TaskList } from "@tiptap/extension-task-list";
4import { Node as ProseMirrorNode } from "@tiptap/pm/model";
5import { describe, expect, it } from "vite-plus/test";
6
7import {
8 buildDocJson,
9 collapsedToFlat,
10 ComposerTaskItemExtension,
11 flatToCollapsed,
12 flatToMarkdown,
13 flatToPm,
14 pmToFlat,
15 serializeEditorDoc,
16} from "./composer-rich-text-doc";
17
18function stubAtom(name: string, attrs: Record<string, { default: unknown }>) {
19 return Node.create({
20 name,
21 group: "inline",
22 inline: true,
23 atom: true,
24 addAttributes: () => attrs,
25 });
26}
27
28const schema = getSchemaByResolvedExtensions(
29 resolveExtensions([
30 StarterKit.configure({
31 blockquote: false,
32 bulletList: false,
33 codeBlock: false,
34 heading: false,
35 horizontalRule: false,
36 listItem: false,
37 orderedList: false,
38 dropcursor: false,
39 gapcursor: false,
40 trailingNode: false,
41 }),
42 stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }),
43 stubAtom("composer-skill", {
44 skillName: { default: "" },
45 skillLabel: { default: "" },
46 skillDescription: { default: null },
47 }),
48 stubAtom("composer-citation", {
49 citation: { default: null },
50 source: { default: "" },
51 citeKey: { default: "" },
52 }),
53 stubAtom("composer-context-reference", {
54 kind: { default: "" },
55 contextId: { default: "" },
56 label: { default: "" },
57 source: { default: "" },
58 }),
59 TaskList,
60 ComposerTaskItemExtension,
61 ]),
62);
63
64function roundTrip(value: string) {
65 const json = buildDocJson(value, (name) => ({ label: name, description: null }));
66 const doc = ProseMirrorNode.fromJSON(schema, json);
67 return serializeEditorDoc(doc);
68}
69
70// Plain mode: the same engine with the mark extensions off. Markers stay
71// literal characters and task lines stay paragraphs.
72const plainSchema = getSchemaByResolvedExtensions(
73 resolveExtensions([
74 StarterKit.configure({
75 blockquote: false,
76 bulletList: false,
77 codeBlock: false,
78 heading: false,
79 horizontalRule: false,
80 listItem: false,
81 orderedList: false,
82 dropcursor: false,
83 gapcursor: false,
84 trailingNode: false,
85 bold: false,
86 italic: false,
87 strike: false,
88 code: false,
89 }),
90 stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }),
91 stubAtom("composer-skill", {
92 skillName: { default: "" },
93 skillLabel: { default: "" },
94 skillDescription: { default: null },
95 }),
96 stubAtom("composer-citation", {
97 citation: { default: null },
98 source: { default: "" },
99 citeKey: { default: "" },
100 }),
101 stubAtom("composer-context-reference", {
102 kind: { default: "" },
103 contextId: { default: "" },
104 label: { default: "" },
105 source: { default: "" },
106 }),
107 TaskList,
108 ComposerTaskItemExtension,
109 ]),
110);
111
112function roundTripPlain(value: string) {
113 const json = buildDocJson(value, (name) => ({ label: name, description: null }), {
114 styling: false,
115 });
116 const doc = ProseMirrorNode.fromJSON(plainSchema, json);
117 return serializeEditorDoc(doc);
118}
119
120describe("composer rich text document model", () => {
121 it.each([
122 "",
123 "\n\n",
124 "plain text",
125 "hello **bold** world",
126 "a *italic* word and `code` here",
127 "struck ~~out~~ now",
128 "***bold italic*** keeps nesting",
129 "line one\nline two",
130 "trailing newline\n",
131 "1. foo\n2. asdf\n",
132 "- [ ] buy milk",
133 "- [ ] buy milk",
134 "\t-\t[x]\t\titem",
135 "- [ ] ",
136 "- [ ]\n - [ ] child",
137 " - [ ] first\n - [ ] second\n - [ ] child",
138 "**before @README.md after**",
139 "*a **b** c*",
140 "*a**b***",
141 "**a*b***",
142 "**a *b* c**",
143 "literal \uFFFC **before @README.md after**",
144 "- [x] done\n- [ ] next",
145 "- [ ] parent\n - [ ] child\n - [ ] sibling\n- [ ] uncle",
146 "- [ ] empty task follows\n- [ ]",
147 "- [ ] **bold** task with @README.md",
148 "para\n- [ ] task\npara",
149 "- [ ]No space stays literal",
150 "-[ ] also literal",
151 "@README.md explain this",
152 '@"docs/My File.md" and $my-skill please',
153 "snake_case stays literal",
154 "unmatched ** stays literal",
155 "**bold** then @README.md then *italic*",
156 ])("round-trips %s through a real ProseMirror document", (value) => {
157 expect(roundTrip(value).value).toBe(value);
158 });
159
160 it.each([
161 "",
162 "\n",
163 "text\n\n",
164 "- [ ]\n- [ ] next\n",
165 "- [ ] parent\n - [ ] child\n- [ ]",
166 "para\n- [ ] task\npara",
167 "**before @README.md after**",
168 ])("maps editable positions in %s", (value) => {
169 const doc = ProseMirrorNode.fromJSON(
170 schema,
171 buildDocJson(value, (name) => ({ label: name, description: null })),
172 );
173 const map = serializeEditorDoc(doc);
174 for (let flat = 0; flat <= map.docLength; flat += 1) {
175 const position = flatToPm(map, flat);
176 expect(doc.resolve(position).parent.isTextblock).toBe(true);
177 expect(pmToFlat(map, position)).toBe(flat);
178 expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat);
179 }
180 });
181
182 it("keeps the caret after a trailing hard break inside the same paragraph", () => {
183 const doc = schema.node("doc", null, [
184 schema.node("paragraph", null, [schema.text("a"), schema.node("hardBreak")]),
185 ]);
186 const map = serializeEditorDoc(doc);
187 expect(flatToPm(map, map.docLength)).toBe(3);
188 expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true);
189 });
190
191 it("renders a leading task dedent as siblings and nests under the new indent", () => {
192 const doc = ProseMirrorNode.fromJSON(
193 schema,
194 buildDocJson(" - [ ] first\n - [ ] second\n - [ ] child", (name) => ({
195 label: name,
196 description: null,
197 })),
198 );
199 const list = doc.firstChild!;
200 expect(list.childCount).toBe(2);
201 expect(list.child(0).childCount).toBe(1);
202 expect(list.child(1).child(1).firstChild!.textContent).toBe("child");
203 });
204
205 it("applies a shared mark to text on both sides of a chip", () => {
206 const doc = ProseMirrorNode.fromJSON(
207 schema,
208 buildDocJson("**before @README.md after**", (name) => ({ label: name, description: null })),
209 );
210 expect(doc.firstChild!.childCount).toBe(3);
211 doc.firstChild!.forEach((child) =>
212 expect(child.marks.map((mark) => mark.type.name)).toContain("bold"),
213 );
214 expect(serializeEditorDoc(doc).value).toBe("**before @README.md after**");
215 });
216
217 it.each([
218 [["bold"], ["bold", "italic"], ["italic"]],
219 [["italic"], ["bold", "italic"], ["bold"]],
220 [["bold"], ["bold", "strike"], ["strike"]],
221 [["strike"], ["bold", "strike"], ["bold"]],
222 ])("preserves crossing mark ranges %j through controlled rebuilds", (...marks) => {
223 const doc = schema.node("doc", null, [
224 schema.node(
225 "paragraph",
226 null,
227 marks.map((names, index) =>
228 schema.text(
229 String.fromCharCode(97 + index),
230 names.map((name) => schema.mark(name)),
231 ),
232 ),
233 ),
234 ]);
235 const serialized = serializeEditorDoc(doc).value;
236 const rebuilt = ProseMirrorNode.fromJSON(
237 schema,
238 buildDocJson(serialized, (name) => ({ label: name, description: null })),
239 );
240 expect(rebuilt.eq(doc)).toBe(true);
241 expect(serializeEditorDoc(rebuilt).value).toBe(serialized);
242 });
243
244 it.each([
245 { parts: [{ text: "hello ", marks: ["bold"] }], expected: "**hello** " },
246 { parts: [{ text: " ", marks: ["bold"] }], expected: " " },
247 { parts: [{ text: " left ", marks: ["bold", "italic"] }], expected: " ***left*** " },
248 {
249 parts: [
250 { text: "one ", marks: ["bold"] },
251 { text: " two ", marks: ["bold", "italic"] },
252 { text: " three", marks: ["bold"] },
253 ],
254 expected: "**one *two* three**",
255 },
256 {
257 parts: [
258 { text: "hello ", marks: ["bold"] },
259 { text: "world ", marks: ["bold", "italic"] },
260 ],
261 expected: "**hello *world*** ",
262 },
263 { parts: [{ text: " hello ", marks: ["code"] }], expected: "` hello `" },
264 { parts: [{ text: " hello ", marks: ["bold", "code"] }], expected: "**` hello `**" },
265 { parts: [{ text: " ", marks: ["bold", "code"] }], expected: "**` `**" },
266 ])("keeps boundary whitespace outside emphasis in $expected", ({ parts, expected }) => {
267 const doc = schema.node("doc", null, [
268 schema.node(
269 "paragraph",
270 null,
271 parts.map(({ text, marks }) =>
272 schema.text(
273 text,
274 marks.map((name) => schema.mark(name)),
275 ),
276 ),
277 ),
278 ]);
279 const map = serializeEditorDoc(doc);
280 expect(map.value).toBe(expected);
281 const rebuilt = ProseMirrorNode.fromJSON(
282 schema,
283 buildDocJson(map.value, (name) => ({ label: name, description: null })),
284 );
285 expect(rebuilt.textContent).toBe(doc.textContent);
286 expect(serializeEditorDoc(rebuilt).value).toBe(map.value);
287 for (let flat = 0; flat <= map.docLength; flat += 1) {
288 expect(pmToFlat(map, flatToPm(map, flat))).toBe(flat);
289 expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat);
290 if (flat < map.docLength && !/\s/.test(doc.textContent[flat]!)) {
291 expect(rebuilt.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name)).toEqual(
292 doc.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name),
293 );
294 }
295 }
296 });
297
298 it("keeps chip sources canonical through the document", () => {
299 const map = roundTrip("explain @README.md with **care**\nsecond line *here*");
300 expect(map.value).toBe("explain @README.md with **care**\nsecond line *here*");
301 expect(
302 map.runs.some((run) => run.kind === "token" && run.nodeName === "composer-mention"),
303 ).toBe(true);
304 });
305
306 it("normalizes uppercase checkboxes to lowercase", () => {
307 expect(roundTrip("- [X] done").value).toBe("- [x] done");
308 });
309
310 it.each([
311 "plain text",
312 "hello **bold** stays literal",
313 "a *italic* stays literal",
314 "some `code` stays literal",
315 "struck ~~out~~ stays literal",
316 "- [ ] stays a paragraph",
317 "- [x] stays a paragraph",
318 "line one\nline two",
319 "@README.md explain this",
320 "**bold** then @README.md then *italic*",
321 ])("round-trips %s byte-identically in plain mode", (value) => {
322 expect(roundTripPlain(value).value).toBe(value);
323 });
324
325 it("maps every document offset through collapsed coordinates and back", () => {
326 const value = "hi **bold** @README.md bye";
327 const map = roundTrip(value);
328 expect(map.value).toBe(value);
329 for (let flat = 0; flat <= map.docLength; flat += 1) {
330 expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat);
331 }
332 });
333
334 it("maps markdown offsets at styled edges onto document text", () => {
335 const value = "a **bold** c";
336 const map = roundTrip(value);
337 expect(map.value).toBe(value);
338 // document text is "a bold c" (flat), markdown has the markers.
339 expect(flatToMarkdown(map, 2)).toBe(4);
340 expect(flatToMarkdown(map, 6)).toBe(10);
341 expect(collapsedToFlat(map, 3)).toBe(2);
342 expect(collapsedToFlat(map, 9)).toBe(6);
343 });
344});
apps/web/src/composer-rich-text-doc.ts created+644
......@@ -0,0 +1,644 @@
1import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
2import { TaskItem } from "@tiptap/extension-task-item";
3
4import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
5import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text";
6import { collectInlineContextIds } from "~/lib/composerContextReferences";
7
8/**
9 * Pure document model for the rich text (Tiptap) composer.
10 *
11 * The stored prompt stays markdown (`**bold**`, `@file` chips as canonical
12 * links). The Tiptap document holds styled text plus inline atom chips, so
13 * this module translates both ways and maps cursor offsets between the three
14 * coordinate spaces the composer speaks:
15 *
16 * - flat document offsets (styled markers excluded, chips count 1),
17 * - collapsed cursor offsets (markers literal, chips count 1 — the coordinate
18 * the draft store and mention detection use),
19 * - markdown offsets (markers literal, chips expand to their source).
20 *
21 * DOM-free on purpose: unit tests build a real ProseMirror document from the
22 * JSON this produces and assert the round trip without a browser.
23 */
24
25export type SkillMeta = { label: string; description: string | null };
26
27/** Outermost mark first, so closers mirror openers when nested. */
28const MARK_NESTING_ORDER: RichTextMark[] = ["strike", "bold", "italic", "code"];
29
30const MARK_TO_TIPTAP: Record<RichTextMark, string> = {
31 bold: "bold",
32 italic: "italic",
33 strike: "strike",
34 code: "code",
35};
36
37const TIPTAP_TO_MARK: Record<string, RichTextMark> = {
38 bold: "bold",
39 italic: "italic",
40 strike: "strike",
41 code: "code",
42};
43
44/**
45 * Task list items keep their exact source indent in an attribute so nesting
46 * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` —
47 * the same fixed-point deal as `__bold__` becoming `**bold**`.
48 */
49export const ComposerTaskItemExtension = TaskItem.extend({
50 addAttributes() {
51 return {
52 ...this.parent?.(),
53 indent: { default: "" },
54 markerSpace: { default: " " },
55 contentSpace: { default: null },
56 };
57 },
58}).configure({ nested: true });
59
60function randomNodeKey(): string {
61 return `tiptap-${Math.random().toString(36).slice(2)}`;
62}
63
64interface TaskLinePrefix {
65 indent: string;
66 checked: boolean;
67 markerSpace: string;
68 contentSpace: string;
69}
70
71function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength: number } | null {
72 const match = head.match(/^([ \t]*)-([ \t]+)\[([ xX])\]([ \t]*)/);
73 if (!match) return null;
74 const after = head.slice(match[0].length);
75 if (after.length > 0 && !match[4]) return null;
76 return {
77 prefix: {
78 indent: match[1] ?? "",
79 checked: (match[3] ?? " ").toLowerCase() === "x",
80 markerSpace: match[2]!,
81 contentSpace: match[4]!,
82 },
83 markerLength: match[0].length,
84 };
85}
86
87type InlineJson = Record<string, unknown>;
88
89interface DocLine {
90 task: TaskLinePrefix | null;
91 inline: InlineJson[];
92}
93
94function atomJsonForSegment(
95 segment: Exclude<ReturnType<typeof splitPromptIntoComposerSegments>[number], { type: "text" }>,
96 skillLabelFor: (name: string) => SkillMeta,
97): InlineJson {
98 if (segment.type === "mention") {
99 return {
100 type: "composer-mention",
101 attrs: { path: segment.path, source: segment.source },
102 };
103 }
104 if (segment.type === "skill") {
105 const meta = skillLabelFor(segment.name);
106 return {
107 type: "composer-skill",
108 attrs: {
109 skillName: segment.name,
110 skillLabel: meta.label,
111 skillDescription: meta.description,
112 },
113 };
114 }
115 if (segment.type === "citation") {
116 return {
117 type: "composer-citation",
118 attrs: { citation: segment.citation, source: segment.source, citeKey: randomNodeKey() },
119 };
120 }
121 return {
122 type: "composer-context-reference",
123 attrs: {
124 kind: segment.kind,
125 contextId: segment.contextId,
126 label: segment.label,
127 source: segment.source,
128 },
129 };
130}
131
132interface PendingTaskItem extends TaskLinePrefix {
133 content: InlineJson[];
134 children: PendingTaskItem[];
135}
136
137function taskListJson(items: PendingTaskItem[]): InlineJson {
138 return {
139 type: "taskList",
140 content: items.map((item) => ({
141 type: "taskItem",
142 attrs: {
143 checked: item.checked,
144 indent: item.indent,
145 markerSpace: item.markerSpace,
146 contentSpace: item.contentSpace,
147 },
148 content: [
149 { type: "paragraph", content: item.content },
150 ...(item.children.length > 0 ? [taskListJson(item.children)] : []),
151 ],
152 })),
153 };
154}
155
156function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, unknown> {
157 const json: Record<string, unknown> = { type: "text", text };
158 if (marks.length > 0) {
159 json.marks = [...marks]
160 .sort((a, b) => MARK_NESTING_ORDER.indexOf(a) - MARK_NESTING_ORDER.indexOf(b))
161 .map((mark) => ({ type: MARK_TO_TIPTAP[mark] }));
162 }
163 return json;
164}
165
166export function buildTiptapContent(
167 value: string,
168 skillLabelFor: (name: string) => SkillMeta,
169 options?: { styling?: boolean },
170): Record<string, unknown>[] {
171 const styling = options?.styling ?? true;
172 // Hide token source from the markdown parser, then restore the atoms with
173 // the marks of their surrounding text. Choose a sentinel absent from input.
174 let sentinel = "\uFFFC";
175 for (let codePoint = 0xe000; value.includes(sentinel); codePoint += 1) {
176 sentinel = String.fromCodePoint(codePoint);
177 }
178 const atoms: InlineJson[] = [];
179 const text = splitPromptIntoComposerSegments(value)
180 .map((segment) => {
181 if (segment.type === "text") return segment.text;
182 atoms.push(atomJsonForSegment(segment, skillLabelFor));
183 return sentinel;
184 })
185 .join("");
186 let atomIndex = 0;
187 const lines: DocLine[] = text.split("\n").map((line) => {
188 const parsed = styling ? parseTaskPrefix(line) : null;
189 const content = parsed ? line.slice(parsed.markerLength) : line;
190 const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }];
191 const inline: InlineJson[] = [];
192 for (const span of spans) {
193 span.text.split(sentinel).forEach((piece, index) => {
194 if (index > 0) {
195 const atom = atoms[atomIndex++]!;
196 inline.push({ ...atom, marks: textJsonForSpan("", span.marks).marks });
197 }
198 if (piece) inline.push(textJsonForSpan(piece, span.marks));
199 });
200 }
201 return { task: parsed?.prefix ?? null, inline };
202 });
203
204 // Pass 2: consecutive task lines group into (possibly nested) task lists
205 // by indent prefix; everything else stays a paragraph.
206 const blocks: Record<string, unknown>[] = [];
207 let stack: { indent: string; items: PendingTaskItem[] }[] = [];
208 const flushTasks = () => {
209 if (stack.length > 0) {
210 blocks.push(taskListJson(stack[0]!.items));
211 stack = [];
212 }
213 };
214 for (const line of lines) {
215 if (!line.task) {
216 flushTasks();
217 blocks.push({ type: "paragraph", content: line.inline });
218 continue;
219 }
220 const item: PendingTaskItem = {
221 ...line.task,
222 content: line.inline,
223 children: [],
224 };
225 for (;;) {
226 const top = stack[stack.length - 1];
227 if (!top) {
228 // A leading indented item with no parent flattens but keeps indent.
229 stack.push({ indent: item.indent, items: [] });
230 continue;
231 }
232 if (top.indent === item.indent) {
233 top.items.push(item);
234 break;
235 }
236 if (top.indent !== "" && !item.indent.startsWith(top.indent)) {
237 if (stack.length > 1) {
238 stack.pop();
239 continue;
240 }
241 top.indent = item.indent;
242 top.items.push(item);
243 break;
244 }
245 const parent = top.items[top.items.length - 1];
246 if (!parent) {
247 top.items.push(item);
248 break;
249 }
250 parent.children.push(item);
251 stack.push({ indent: item.indent, items: parent.children });
252 break;
253 }
254 }
255 flushTasks();
256 return blocks;
257}
258
259export function buildDocJson(
260 value: string,
261 skillLabelFor: (name: string) => SkillMeta,
262 options?: { styling?: boolean },
263) {
264 return { type: "doc", content: buildTiptapContent(value, skillLabelFor, options) };
265}
266
267export interface RichRun {
268 kind: "text" | "token" | "break" | "prefix";
269 /** Flat document offset (atoms count 1, markers excluded). */
270 flatStart: number;
271 docLen: number;
272 /** Collapsed cursor length (markers literal, tokens count 1). */
273 collapsedLen: number;
274 /** Markdown length (tokens expand to their source). */
275 mdLen: number;
276 /** Marker layout inside text runs. */
277 openLen: number;
278 closeLen: number;
279 /** ProseMirror position of the run start. */
280 pmPos: number;
281 mdStart: number;
282 collapsedStart: number;
283 nodeName?: string;
284}
285
286export interface RichDocMap {
287 value: string;
288 runs: RichRun[];
289 docLength: number;
290 contextIds: string[];
291}
292
293function readAtomSource(node: ProseMirrorNode): string {
294 const attrs = node.attrs as Record<string, unknown>;
295 switch (node.type.name) {
296 case "composer-mention":
297 case "composer-citation":
298 case "composer-context-reference":
299 return typeof attrs.source === "string" ? attrs.source : "";
300 case "composer-skill": {
301 const name = typeof attrs.skillName === "string" ? attrs.skillName : "";
302 return name ? `$${name}` : "";
303 }
304 default:
305 return "";
306 }
307}
308
309interface RichAccumulator {
310 runs: RichRun[];
311 value: string;
312 flat: number;
313 collapsed: number;
314 md: number;
315}
316
317function pushBreakRun(acc: RichAccumulator, position?: number): void {
318 const previous = acc.runs[acc.runs.length - 1];
319 const pmPos = position ?? (previous ? previous.pmPos + previous.docLen : 1);
320 // Block boundary: one newline in every coordinate space.
321 acc.runs.push({
322 kind: "break",
323 flatStart: acc.flat,
324 docLen: 1,
325 collapsedLen: 1,
326 mdLen: 1,
327 openLen: 0,
328 closeLen: 0,
329 pmPos,
330 mdStart: acc.md,
331 collapsedStart: acc.collapsed,
332 });
333 acc.value += "\n";
334 acc.flat += 1;
335 acc.collapsed += 1;
336 acc.md += 1;
337}
338
339function appendInlineRuns(
340 container: ProseMirrorNode,
341 contentStart: number,
342 acc: RichAccumulator,
343): void {
344 const children: ProseMirrorNode[] = [];
345 container.forEach((child) => {
346 if (!child.isText || child.marks.some((mark) => mark.type.name === "code")) {
347 children.push(child);
348 return;
349 }
350 // Separate boundary whitespace so delimiters can move past it without
351 // changing the document offsets or marks on the visible text.
352 const text = child.text!;
353 const start = text.length - text.trimStart().length;
354 const end = Math.max(start, text.trimEnd().length);
355 let offset = 0;
356 for (const boundary of [start, end, text.length]) {
357 if (boundary > offset) children.push(child.cut(offset, boundary));
358 offset = boundary;
359 }
360 });
361 // Emphasis cannot open or close next to whitespace. Retain a whitespace
362 // mark only when its range has visible content on both sides.
363 for (const mark of MARK_NESTING_ORDER) {
364 if (mark === "code") continue;
365 for (const direction of [1, -1]) {
366 let hasContent = false;
367 for (
368 let index = direction === 1 ? 0 : children.length - 1;
369 index >= 0 && index < children.length;
370 index += direction
371 ) {
372 const child = children[index]!;
373 if (
374 child.type.name === "hardBreak" ||
375 !child.marks.some((item) => item.type.name === mark)
376 ) {
377 hasContent = false;
378 } else if (
379 child.isText &&
380 !child.marks.some((item) => item.type.name === "code") &&
381 /^\s+$/.test(child.text!)
382 ) {
383 if (!hasContent)
384 children[index] = child.mark(child.marks.filter((item) => item.type.name !== mark));
385 } else {
386 hasContent = true;
387 }
388 }
389 }
390 }
391 // Longer shared marks surround shorter ones. This keeps both nested
392 // formatting and formatting across chips inside a single delimiter pair.
393 const markEnds = new Map<RichTextMark, number>();
394 const orderedMarks: RichTextMark[][] = [];
395 for (let index = children.length - 1; index >= 0; index -= 1) {
396 const child = children[index]!;
397 const marks =
398 child.type.name === "hardBreak"
399 ? []
400 : child.marks
401 .map((mark) => TIPTAP_TO_MARK[mark.type.name])
402 .filter((mark): mark is RichTextMark => Boolean(mark));
403 for (const mark of MARK_NESTING_ORDER) {
404 if (!marks.includes(mark)) markEnds.delete(mark);
405 else if (!markEnds.has(mark)) markEnds.set(mark, index);
406 }
407 orderedMarks[index] = marks.sort(
408 (a, b) =>
409 markEnds.get(b)! - markEnds.get(a)! ||
410 MARK_NESTING_ORDER.indexOf(a) - MARK_NESTING_ORDER.indexOf(b),
411 );
412 }
413 for (let index = 1; index < orderedMarks.length; index += 1) {
414 const marks = orderedMarks[index]!;
415 const retained: RichTextMark[] = [];
416 for (const mark of orderedMarks[index - 1]!) {
417 if (!marks.includes(mark)) break;
418 retained.push(mark);
419 }
420 orderedMarks[index] = [...retained, ...marks.filter((mark) => !retained.includes(mark))];
421 }
422 const commonLength = (left: RichTextMark[], right: RichTextMark[]) => {
423 let index = 0;
424 while (index < left.length && left[index] === right[index]) index += 1;
425 return index;
426 };
427 let inlineOffset = 0;
428 children.forEach((child, index) => {
429 const pmPos = contentStart + inlineOffset;
430 inlineOffset += child.nodeSize;
431 if (child.type.name === "hardBreak") {
432 pushBreakRun(acc, pmPos);
433 return;
434 }
435 const marks = orderedMarks[index]!;
436 const open = marks
437 .slice(commonLength(marks, orderedMarks[index - 1] ?? []))
438 .map((mark) => RICH_TEXT_DELIMITERS[mark])
439 .join("");
440 const close = marks
441 .slice(commonLength(marks, orderedMarks[index + 1] ?? []))
442 .toReversed()
443 .map((mark) => RICH_TEXT_DELIMITERS[mark])
444 .join("");
445 const source = child.isText ? child.text! : readAtomSource(child);
446 const docLen = child.isText ? source.length : 1;
447 const mdText = open + source + close;
448 const collapsedLen = open.length + docLen + close.length;
449 acc.runs.push({
450 kind: child.isText ? "text" : "token",
451 flatStart: acc.flat,
452 docLen,
453 collapsedLen,
454 mdLen: mdText.length,
455 openLen: open.length,
456 closeLen: close.length,
457 pmPos,
458 mdStart: acc.md,
459 collapsedStart: acc.collapsed,
460 ...(child.isText ? {} : { nodeName: child.type.name }),
461 });
462 acc.value += mdText;
463 acc.flat += docLen;
464 acc.collapsed += collapsedLen;
465 acc.md += mdText.length;
466 });
467 // Empty paragraphs have an editable position even though they emit no text.
468 if (children.length === 0) {
469 acc.runs.push({
470 kind: "text",
471 flatStart: acc.flat,
472 docLen: 0,
473 collapsedLen: 0,
474 mdLen: 0,
475 openLen: 0,
476 closeLen: 0,
477 pmPos: contentStart,
478 mdStart: acc.md,
479 collapsedStart: acc.collapsed,
480 });
481 }
482}
483
484function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void {
485 let itemPos = listStart + 1;
486 let firstItem = true;
487 list.content.forEach((item) => {
488 // Sibling items are separated by one newline in every coordinate space.
489 if (!firstItem) pushBreakRun(acc);
490 firstItem = false;
491 const itemContentStart = itemPos + 1;
492 const first = item.firstChild;
493 const empty = first?.type.name === "paragraph" && first.content.childCount === 0;
494 const attrs = item.attrs as Record<string, unknown>;
495 const indent = typeof attrs.indent === "string" ? attrs.indent : "";
496 const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " ";
497 const contentSpace =
498 typeof attrs.contentSpace === "string"
499 ? attrs.contentSpace || (empty ? "" : " ")
500 : empty
501 ? ""
502 : " ";
503 const prefix = `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`;
504 // The checkbox owns no document characters; every prefix offset clamps
505 // to the start of the item text, exactly like style markers.
506 acc.runs.push({
507 kind: "prefix",
508 flatStart: acc.flat,
509 docLen: 0,
510 collapsedLen: prefix.length,
511 mdLen: prefix.length,
512 openLen: 0,
513 closeLen: 0,
514 pmPos: itemContentStart + 1,
515 mdStart: acc.md,
516 collapsedStart: acc.collapsed,
517 });
518 acc.value += prefix;
519 acc.collapsed += prefix.length;
520 acc.md += prefix.length;
521 let childPos = itemContentStart;
522 let firstBlock = true;
523 item.content.forEach((child) => {
524 if (!firstBlock) pushBreakRun(acc);
525 firstBlock = false;
526 if (child.type.name === "taskList") {
527 walkTaskList(child, childPos, acc);
528 } else if (child.type.name === "paragraph") {
529 appendInlineRuns(child, childPos + 1, acc);
530 }
531 childPos += child.nodeSize;
532 });
533 itemPos += item.nodeSize;
534 });
535}
536
537export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap {
538 const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 };
539 const blocks: ProseMirrorNode[] = [];
540 doc.content.forEach((node) => {
541 blocks.push(node);
542 });
543
544 let pmBlockStart = 0;
545 blocks.forEach((block, blockIndex) => {
546 if (blockIndex > 0) pushBreakRun(acc);
547 if (block.type.name === "taskList") {
548 walkTaskList(block, pmBlockStart, acc);
549 } else if (block.type.name === "paragraph") {
550 appendInlineRuns(block, pmBlockStart + 1, acc);
551 }
552 pmBlockStart += block.nodeSize;
553 });
554
555 return {
556 value: acc.value,
557 runs: acc.runs,
558 docLength: acc.flat,
559 contextIds: Array.from(new Set(collectInlineContextIds(acc.value))),
560 };
561}
562
563function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number {
564 const last = map.runs[map.runs.length - 1];
565 if (!last) return 0;
566 return space === "collapsed"
567 ? last.collapsedStart + last.collapsedLen
568 : last.mdStart + last.mdLen;
569}
570
571export function flatToCollapsed(map: RichDocMap, flatOffset: number): number {
572 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));
573 for (const run of map.runs) {
574 if (bounded < run.flatStart + run.docLen) {
575 if (run.kind === "text" || run.kind === "token") {
576 return run.collapsedStart + run.openLen + (bounded - run.flatStart);
577 }
578 return run.collapsedStart + (bounded - run.flatStart);
579 }
580 }
581 return lastRunEnd(map, "collapsed");
582}
583
584export function flatToMarkdown(map: RichDocMap, flatOffset: number): number {
585 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));
586 for (const run of map.runs) {
587 if (bounded < run.flatStart + run.docLen) {
588 if (run.kind === "text" || run.kind === "token") {
589 return run.mdStart + run.openLen + (bounded - run.flatStart);
590 }
591 return run.mdStart + (bounded - run.flatStart);
592 }
593 }
594 return lastRunEnd(map, "md");
595}
596
597export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): number {
598 for (const run of map.runs) {
599 if (collapsedOffset < run.collapsedStart + run.collapsedLen) {
600 // Checkbox prefixes and style markers are shown, never edited: every
601 // offset inside them clamps to the adjacent document position.
602 if (run.kind === "prefix") return run.flatStart;
603 if (run.kind === "text" || run.kind === "token") {
604 const within = collapsedOffset - run.collapsedStart;
605 // Marker characters clamp to the styled edge: they are shown, never edited.
606 if (within <= run.openLen) return run.flatStart;
607 if (within >= run.openLen + run.docLen) return run.flatStart + run.docLen;
608 return run.flatStart + (within - run.openLen);
609 }
610 return run.flatStart + (collapsedOffset - run.collapsedStart);
611 }
612 }
613 return map.docLength;
614}
615
616export function flatToPm(map: RichDocMap, flatOffset: number): number {
617 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));
618 for (const run of map.runs) {
619 if (bounded < run.flatStart + run.docLen) {
620 return run.pmPos + (bounded - run.flatStart);
621 }
622 }
623 const last = map.runs[map.runs.length - 1];
624 if (!last) return 1;
625 return last.pmPos + last.docLen;
626}
627
628export function pmToFlat(map: RichDocMap, pmPos: number): number {
629 for (const run of map.runs) {
630 if (pmPos >= run.pmPos && pmPos <= run.pmPos + run.docLen) {
631 // A position on a chip's trailing edge belongs after the chip.
632 if (run.kind === "token" && pmPos === run.pmPos + run.docLen) {
633 return run.flatStart + run.docLen;
634 }
635 return run.flatStart + Math.min(pmPos - run.pmPos, run.docLen);
636 }
637 }
638 // A paragraph boundary position belongs to the newline between paragraphs.
639 let best = 0;
640 for (const run of map.runs) {
641 if (run.pmPos <= pmPos) best = run.flatStart + run.docLen;
642 }
643 return Math.max(0, Math.min(best, map.docLength));
644}
apps/web/src/composer-rich-text.test.ts created+67
......@@ -0,0 +1,67 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { parseInlineMarkdown } from "./composer-rich-text";
4
5describe("composer rich text markdown", () => {
6 it("parses bold markers into styled spans", () => {
7 expect(parseInlineMarkdown("hello **bold** world")).toEqual([
8 { text: "hello ", marks: [] },
9 { text: "bold", marks: ["bold"] },
10 { text: " world", marks: [] },
11 ]);
12 });
13
14 it("preserves unmatched markers, escaped markers, and identifiers", () => {
15 for (const text of [
16 "plain text",
17 "snake_case",
18 "unmatched **",
19 "** spaced **",
20 "\\*literal\\*",
21 ]) {
22 expect(parseInlineMarkdown(text)).toEqual([{ text, marks: [] }]);
23 }
24 });
25
26 it("renders triple markers and nested styles", () => {
27 expect(parseInlineMarkdown("***both***")).toEqual([
28 { text: "both", marks: ["bold", "italic"] },
29 ]);
30 expect(parseInlineMarkdown("*a **b** c*")).toEqual([
31 { text: "a ", marks: ["italic"] },
32 { text: "b", marks: ["italic", "bold"] },
33 { text: " c", marks: ["italic"] },
34 ]);
35 expect(parseInlineMarkdown("**a `code` c**")).toEqual([
36 { text: "a ", marks: ["bold"] },
37 { text: "code", marks: ["bold", "code"] },
38 { text: " c", marks: ["bold"] },
39 ]);
40 });
41
42 it("preserves crossing italic and bold spans", () => {
43 expect(parseInlineMarkdown("*a**b*****c**")).toEqual([
44 { text: "a", marks: ["italic"] },
45 { text: "b", marks: ["italic", "bold"] },
46 { text: "c", marks: ["bold"] },
47 ]);
48 });
49
50 it("keeps repeated and alternate delimiters literal inside an active mark", () => {
51 const repeated = "*".repeat(10_000);
52 expect(parseInlineMarkdown(repeated + "text" + repeated)).toEqual([
53 { text: repeated.slice(2) + "text", marks: ["bold"] },
54 { text: repeated.slice(2), marks: [] },
55 ]);
56 const alternate = "__".repeat(5_000);
57 expect(parseInlineMarkdown("**~~" + alternate + "text" + alternate + "~~**")).toEqual([
58 { text: alternate + "text" + alternate, marks: ["bold", "strike"] },
59 ]);
60 });
61
62 it("keeps code span contents literal", () => {
63 expect(parseInlineMarkdown("`**not bold**`")).toEqual([
64 { text: "**not bold**", marks: ["code"] },
65 ]);
66 });
67});
apps/web/src/composer-rich-text.ts created+131
......@@ -0,0 +1,131 @@
1/**
2 * Inline markdown styling for the rich text composer.
3 *
4 * The composer's stored prompt stays plain markdown (`**bold**`), while the
5 * Tiptap surface renders styled text. These helpers translate between the two:
6 * parsing markdown into marked spans for the document, serializing marked
7 * spans back to markdown in the document serializer.
8 *
9 * Deliberately small: bold, italic, strikethrough, and inline code only.
10 * Unmatched markers stay literal text so nothing the user typed is ever lost.
11 */
12
13export type RichTextMark = "bold" | "italic" | "strike" | "code";
14
15interface RichTextSpan {
16 text: string;
17 marks: RichTextMark[];
18}
19
20export const RICH_TEXT_DELIMITERS: Record<RichTextMark, string> = {
21 bold: "**",
22 italic: "*",
23 strike: "~~",
24 code: "`",
25};
26
27function pushSpan(spans: RichTextSpan[], text: string, marks: RichTextMark[]): void {
28 if (!text) return;
29 const last = spans[spans.length - 1];
30 if (
31 last &&
32 last.marks.length === marks.length &&
33 last.marks.every((mark, index) => mark === marks[index])
34 ) {
35 last.text += text;
36 } else {
37 spans.push({ text, marks });
38 }
39}
40
41/** Parse the supported inline styles, leaving unmatched and escaped markers literal. */
42export function parseInlineMarkdown(text: string): RichTextSpan[] {
43 const root: RichTextSpan[] = [];
44 const stack: { delimiter: string; mark: RichTextMark; spans: RichTextSpan[] }[] = [];
45 const current = () => stack.at(-1)?.spans ?? root;
46 let index = 0;
47 while (index < text.length) {
48 const char = text[index]!;
49 if (char === "\\") {
50 pushSpan(current(), text.slice(index, index + 2), []);
51 index += 2;
52 continue;
53 }
54 if (char === "`") {
55 // Multi-backtick code stays literal; a single-backtick span owns its contents.
56 const run = text.slice(index).match(/^`+/)![0];
57 const close = text.indexOf(run, index + run.length);
58 if (
59 run.length === 1 &&
60 close > index + 1 &&
61 !text.slice(index, close).includes("\n") &&
62 text[close + 1] !== "`"
63 ) {
64 pushSpan(current(), text.slice(index + 1, close), ["code"]);
65 index = close + 1;
66 } else {
67 pushSpan(current(), run, []);
68 index += run.length;
69 }
70 continue;
71 }
72 if (char !== "*" && char !== "_" && char !== "~") {
73 pushSpan(current(), char, []);
74 index += 1;
75 continue;
76 }
77 let end = index;
78 while (text[end] === char) end += 1;
79 const before = text[index - 1] ?? "";
80 const after = text[end] ?? "";
81 const canClose = before !== "" && !/\s/.test(before) && (char !== "_" || !/\w/.test(after));
82 const canOpen = after !== "" && !/\s/.test(after) && (char !== "_" || !/\w/.test(before));
83 while (index < end) {
84 const top = stack.at(-1);
85 // Inside italic, a double marker opens bold before closing the single
86 // marker. Once bold is active, closing runs unwind both styles.
87 const opensNestedBold =
88 top?.mark === "italic" &&
89 end - index === 2 &&
90 canOpen &&
91 (char === "*" || char === "_") &&
92 !stack.some((frame) => frame.mark === "bold");
93 if (
94 !opensNestedBold &&
95 canClose &&
96 top &&
97 text.startsWith(top.delimiter, index) &&
98 index + top.delimiter.length <= end
99 ) {
100 stack.pop();
101 for (const span of top.spans) pushSpan(current(), span.text, [top.mark, ...span.marks]);
102 index += top.delimiter.length;
103 } else if (canOpen && (char !== "~" || end - index >= 2)) {
104 const length = char === "~" || end - index >= 2 ? 2 : 1;
105 const mark = char === "~" ? "strike" : length === 2 ? "bold" : "italic";
106 // A mark cannot nest inside itself, including alternate delimiters.
107 // This bounds the stack to the supported styles for arbitrary input.
108 if (stack.some((frame) => frame.mark === mark)) {
109 pushSpan(current(), text.slice(index, end), []);
110 index = end;
111 continue;
112 }
113 stack.push({
114 delimiter: char.repeat(length),
115 mark,
116 spans: [],
117 });
118 index += length;
119 } else {
120 pushSpan(current(), text.slice(index, end), []);
121 index = end;
122 }
123 }
124 }
125 while (stack.length > 0) {
126 const frame = stack.pop()!;
127 pushSpan(current(), frame.delimiter, []);
128 for (const span of frame.spans) pushSpan(current(), span.text, span.marks);
129 }
130 return root;
131}
apps/web/src/index.css+102
......@@ -2120,3 +2120,105 @@ code {
21202120 visibility: visible;
21212121 }
21222122}
2123
2124/* Rich text composer (Tiptap). The stored prompt stays markdown; only the
2125 surface renders styled text. Markers (** * ` ~~) appear as non-editable
2126 widgets while the caret is on styled text. */
2127.composer-tiptap {
2128 position: relative;
2129}
2130
2131.composer-tiptap p {
2132 margin: 0;
2133}
2134
2135.composer-tiptap:focus,
2136.composer-tiptap .ProseMirror:focus {
2137 outline: none;
2138}
2139
2140.composer-tiptap code {
2141 font-family: var(--font-mono);
2142 font-size: 0.92em;
2143 background: color-mix(in oklab, var(--muted-foreground) 12%, transparent);
2144 border-radius: 0.3em;
2145 padding: 0.05em 0.25em;
2146}
2147
2148.composer-rich-marker {
2149 color: var(--muted-foreground);
2150 opacity: 0.75;
2151 font-weight: 400;
2152 font-style: normal;
2153 text-decoration: none;
2154 user-select: none;
2155 pointer-events: none;
2156}
2157
2158/* Selected chips (node selection from arrowing into or clicking a chip). */
2159.composer-tiptap .ProseMirror-selectednode {
2160 outline: 2px solid var(--ring);
2161 outline-offset: 1px;
2162 border-radius: 6px;
2163}
2164
2165.composer-tiptap .composer-chip-range-selected {
2166 position: relative;
2167}
2168
2169.composer-tiptap.ProseMirror-focused .composer-chip-range-selected::after {
2170 content: "";
2171 position: absolute;
2172 inset: 0;
2173 border-radius: 6px;
2174 background: Highlight;
2175 opacity: 0.3;
2176 pointer-events: none;
2177}
2178
2179/* Rich-text task lists render as real checkboxes. Tiptap's TaskItem NodeView
2180 builds the <li> imperatively with only a data-checked attribute (no
2181 data-type), so items are targeted that way. Indentation comes from list
2182 nesting (two spaces per level from Tab), matching the stored Markdown. */
2183.composer-tiptap ul[data-type="taskList"] {
2184 list-style: none;
2185 margin: 0;
2186 padding: 0;
2187}
2188
2189.composer-tiptap ul[data-type="taskList"] ul[data-type="taskList"] {
2190 padding-left: 1.375rem;
2191}
2192
2193.composer-tiptap li[data-checked] {
2194 display: flex;
2195 align-items: flex-start;
2196 gap: 0.5rem;
2197 margin: 0;
2198 padding: 0;
2199}
2200
2201.composer-tiptap li[data-checked] > label {
2202 display: flex;
2203 align-items: center;
2204 flex: none;
2205 height: 1lh;
2206 cursor: pointer;
2207}
2208
2209.composer-tiptap li[data-checked] > label input[type="checkbox"] {
2210 display: block;
2211 margin: 0;
2212 width: 0.95rem;
2213 height: 0.95rem;
2214 cursor: pointer;
2215}
2216
2217.composer-tiptap li[data-checked] > div {
2218 flex: 1 1 auto;
2219 min-width: 0;
2220}
2221
2222.composer-tiptap li[data-checked] > div > p {
2223 margin: 0;
2224}
apps/web/src/keybindings.test.ts+16
......@@ -14,6 +14,7 @@ import {
1414import {
1515 formatShortcutLabel,
1616 isDiffToggleShortcut,
17 isRichTextBoldShortcut,
1718 modelPickerJumpCommandForIndex,
1819 modelPickerJumpIndexFromCommand,
1920 isOpenFavoriteEditorShortcut,
......@@ -962,6 +963,21 @@ describe("isTerminalClearShortcut", () => {
962963 });
963964});
964965
966describe("isRichTextBoldShortcut", () => {
967 it("matches Mod+B without extra modifiers", () => {
968 assert.isTrue(isRichTextBoldShortcut(event({ key: "b", metaKey: true })));
969 assert.isTrue(isRichTextBoldShortcut(event({ key: "B", ctrlKey: true })));
970 });
971
972 it("ignores shifted, alted, bare, and non-keydown presses", () => {
973 assert.isFalse(isRichTextBoldShortcut(event({ key: "b", metaKey: true, shiftKey: true })));
974 assert.isFalse(isRichTextBoldShortcut(event({ key: "b", metaKey: true, altKey: true })));
975 assert.isFalse(isRichTextBoldShortcut(event({ key: "b" })));
976 assert.isFalse(isRichTextBoldShortcut(event({ key: "i", metaKey: true })));
977 assert.isFalse(isRichTextBoldShortcut(event({ type: "keyup", key: "b", metaKey: true })));
978 });
979});
980
965981describe("terminalDeleteShortcutData", () => {
966982 it("maps Cmd+Backspace on macOS to delete-to-line-start", () => {
967983 assert.strictEqual(
apps/web/src/keybindings.ts+17
......@@ -406,6 +406,23 @@ export function isOpenFavoriteEditorShortcut(
406406 return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options);
407407}
408408
409/**
410 * Whether the keypress is the rich-text bold chord (Mod+B without extra
411 * modifiers). Tiptap binds the same chord, so app shortcuts captured ahead
412 * of the editor must yield when the rich-text composer is focused.
413 */
414export function isRichTextBoldShortcut(event: ShortcutEventLike): boolean {
415 if (event.type !== undefined && event.type !== "keydown") {
416 return false;
417 }
418 return (
419 event.key.toLowerCase() === "b" &&
420 (event.metaKey || event.ctrlKey) &&
421 !event.altKey &&
422 !event.shiftKey
423 );
424}
425
409426export function isTerminalClearShortcut(
410427 event: ShortcutEventLike,
411428 platform = navigator.platform,
docs/internals/composer-editors.md created+27
......@@ -0,0 +1,27 @@
1# Composer coordinates and serialization
2
3The draft store owns Markdown, while Tiptap owns the editing document. The rich-text
4setting changes the installed extensions, so toggling it remounts the editor from the
5stored draft. Plain mode must leave formatting markers literal. Rich mode preserves
6whitespace and chip sources, but canonicalizes supported delimiters (`__` to `**`,
7`_` to `*`) and checkbox case (`[X]` to `[x]`).
8
9Store cursors are not ProseMirror positions: collapsed coordinates count a chip as one
10character, expanded coordinates count its source text, and ProseMirror also counts
11block boundaries. Keep conversion in [the document model](../../apps/web/src/composer-rich-text-doc.ts).
12Empty paragraphs need real caret positions even though they contain no text. Markers
13shown beside styled text are decorations, so offsets inside them clamp to the text edge.
14
15Only replace editor content when the controlled text changes. Replacing it for a cursor
16move creates undo entries and regenerates citation identities. Pending citation popovers
17must wait until the requested draft has reached the editor before locating their chip.
18
19Rich tasks split through native editor commands so marks and chips survive. Literal
20lists use [store edits](../../apps/web/src/composer-list-continuation.ts). Newlines become
21paragraph splits: trailing hard breaks otherwise appear to require two presses.
22Programmatic moves must explicitly scroll the caret into view.
23
24Clipboard text must come from the Markdown serializer, not DOM text: chip labels omit
25the source and marker decorations are not content. Structured context records accompany
26that text when available. Paste completes trailing chip delimiters and adds a leading
27boundary when inserting a chip directly after text.
packages/contracts/src/settings.test.ts+13
......@@ -20,6 +20,19 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
2020const encodeServerSettings = Schema.encodeSync(ServerSettings);
2121const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
2222
23describe("ClientSettings rich text composer", () => {
24 it("enables rich text for new and existing settings without a saved preference", () => {
25 expect(decodeClientSettings({}).composerRichTextEnabled).toBe(true);
26 expect(decodeClientSettings({ sendShortcut: "mod-enter" }).composerRichTextEnabled).toBe(true);
27 });
28
29 it("preserves an explicit opt-out through patches and persistence", () => {
30 const preference = { composerRichTextEnabled: false };
31 expect(decodeClientSettingsPatch(preference)).toEqual(preference);
32 expect(encodeClientSettings(decodeClientSettings(preference))).toMatchObject(preference);
33 });
34});
35
2336describe("ServerSettings default permissions", () => {
2437 it("keeps full access for settings saved before a default was configured", () => {
2538 expect(decodeServerSettings({}).defaultRuntimeMode).toBe("full-access");
packages/contracts/src/settings.ts+3
......@@ -430,6 +430,8 @@ export const ClientSettingsSchema = Schema.Struct({
430430 // Desktop resting composer: scrolling an existing thread's conversation
431431 // settles the composer into its single-line layout. Losing focus never does.
432432 composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
433 // Rich text is the default; users can opt out for literal Markdown editing.
434 composerRichTextEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
433435 sendShortcut: Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"]).pipe(
434436 Schema.withDecodingDefault(Effect.succeed("enter")),
435437 ),
......@@ -1505,6 +1507,7 @@ export const ClientSettingsPatch = Schema.Struct({
15051507 planModeEnabled: Schema.optionalKey(Schema.Boolean),
15061508 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
15071509 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),
1510 composerRichTextEnabled: Schema.optionalKey(Schema.Boolean),
15081511 sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])),
15091512 followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])),
15101513 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
pnpm-lock.yaml+505-301
......@@ -600,9 +600,6 @@ importers:
600600 '@legendapp/list':
601601 specifier: 'catalog:'
602602 version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
603 '@lexical/react':
604 specifier: ^0.41.0
605 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)
606603 '@noble/hashes':
607604 specifier: 'catalog:'
608605 version: 1.8.0
......@@ -627,6 +624,24 @@ importers:
627624 '@tanstack/react-router':
628625 specifier: ^1.160.2
629626 version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
627 '@tiptap/core':
628 specifier: ^3.31.3
629 version: 3.31.3(@tiptap/pm@3.31.3)
630 '@tiptap/extension-task-item':
631 specifier: ^3.31.3
632 version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
633 '@tiptap/extension-task-list':
634 specifier: ^3.31.3
635 version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
636 '@tiptap/pm':
637 specifier: ^3.31.3
638 version: 3.31.3
639 '@tiptap/react':
640 specifier: ^3.31.3
641 version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
642 '@tiptap/starter-kit':
643 specifier: ^3.31.3
644 version: 3.31.3
630645 class-variance-authority:
631646 specifier: ^0.7.1
632647 version: 0.7.1
......@@ -654,9 +669,6 @@ importers:
654669 jszip:
655670 specifier: 3.10.1
656671 version: 3.10.1
657 lexical:
658 specifier: ^0.41.0
659 version: 0.41.0
660672 lucide-react:
661673 specifier: ^0.564.0
662674 version: 0.564.0(react@19.2.6)
......@@ -777,7 +789,7 @@ importers:
777789 version: link:../../packages/shared
778790 alchemy:
779791 specifier: 2.0.0-beta.76
780 version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f)
792 version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e)
781793 drizzle-orm:
782794 specifier: 1.0.0-rc.5-ab785fc
783795 version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884)
......@@ -2877,12 +2889,6 @@ packages:
28772889 react: '>=16.8.0'
28782890 react-dom: '>=16.8.0'
28792891
2880 '@floating-ui/react@0.27.19':
2881 resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==}
2882 peerDependencies:
2883 react: '>=17.0.0'
2884 react-dom: '>=17.0.0'
2885
28862892 '@floating-ui/utils@0.2.11':
28872893 resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
28882894
......@@ -3304,80 +3310,6 @@ packages:
33043310 react-native:
33053311 optional: true
33063312
3307 '@lexical/clipboard@0.41.0':
3308 resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==}
3309
3310 '@lexical/code@0.41.0':
3311 resolution: {integrity: sha512-0hoNi1KC9/N3SBOGcOcFqnT0OpwmcRRAhfxTKMGqfCtCvAMzULVwZ8RWc9/NV9bKYESgBTW5D9xkDANP2mspHg==}
3312
3313 '@lexical/devtools-core@0.41.0':
3314 resolution: {integrity: sha512-FzJtluBhBc8bKS11TUZe72KoZN/hnzIyiiM0SPJAsPwGpoXuM01jqpXQGybWf/1bWB+bmmhOae7O4Nywi/Csuw==}
3315 peerDependencies:
3316 react: '>=17.x'
3317 react-dom: '>=17.x'
3318
3319 '@lexical/dragon@0.41.0':
3320 resolution: {integrity: sha512-gBEqkk8Q6ZPruvDaRcOdF1EK9suCVBODzOCcR+EnoJTaTjfDkCM7pkPAm4w90Wa1wCZEtFHvCfas+jU9MDSumg==}
3321
3322 '@lexical/extension@0.41.0':
3323 resolution: {integrity: sha512-sF4SPiP72yXvIGchmmIZ7Yg2XZTxNLOpFEIIzdqG7X/1fa1Ham9P/T7VbrblWpF6Ei5LJtK9JgNVB0hb4l3o1g==}
3324
3325 '@lexical/hashtag@0.41.0':
3326 resolution: {integrity: sha512-tFWM74RW4KU0E/sj2aowfWl26vmLUTp331CgVESnhQKcZBfT40KJYd57HEqBDTfQKn4MUhylQCCA0hbpw6EeFQ==}
3327
3328 '@lexical/history@0.41.0':
3329 resolution: {integrity: sha512-kGoVWsiOn62+RMjRolRa+NXZl8jFwxav6GNDiHH8yzivtoaH8n1SwUfLJELXCzeqzs81HySqD4q30VLJVTGoDg==}
3330
3331 '@lexical/html@0.41.0':
3332 resolution: {integrity: sha512-3RyZy+H/IDKz2D66rNN/NqYx87xVFrngfEbyu1OWtbY963RUFnopiVHCQvsge/8kT04QSZ7U/DzjVFqeNS6clg==}
3333
3334 '@lexical/link@0.41.0':
3335 resolution: {integrity: sha512-Rjtx5cGWAkKcnacncbVsZ1TqRnUB2Wm4eEVKpaAEG41+kHgqghzM2P+UGT15yROroxJu8KvAC9ISiYFiU4XE1w==}
3336
3337 '@lexical/list@0.41.0':
3338 resolution: {integrity: sha512-RXvB+xcbzVoQLGRDOBRCacztG7V+bI95tdoTwl8pz5xvgPtAaRnkZWMDP+yMNzMJZsqEChdtpxbf0NgtMkun6g==}
3339
3340 '@lexical/mark@0.41.0':
3341 resolution: {integrity: sha512-UO5WVs9uJAYIKHSlYh4Z1gHrBBchTOi21UCYBIZ7eAs4suK84hPzD+3/LAX5CB7ZltL6ke5Sly3FOwNXv/wfpA==}
3342
3343 '@lexical/markdown@0.41.0':
3344 resolution: {integrity: sha512-bzI73JMXpjGFhqUWNV6KqfjWcgAWzwFT+J3RHtbCF5rysC8HLldBYojOgAAtPfXqfxyv2mDzsY7SoJ75s9uHZA==}
3345
3346 '@lexical/offset@0.41.0':
3347 resolution: {integrity: sha512-2RHBXZqC8gm3X9C0AyRb0M8w7zJu5dKiasrif+jSKzsxPjAUeF1m95OtIOsWs1XLNUgASOSUqGovDZxKJslZfA==}
3348
3349 '@lexical/overflow@0.41.0':
3350 resolution: {integrity: sha512-Iy6ZiJip8X14EBYt1zKPOrXyQ4eG9JLBEoPoSVBTiSbVd+lYicdUvaOThT0k0/qeVTN9nqTaEltBjm56IrVKCQ==}
3351
3352 '@lexical/plain-text@0.41.0':
3353 resolution: {integrity: sha512-HIsGgmFUYRUNNyvckun33UQfU7LRzDlxymHUq67+Bxd5bXqdZOrStEKJXuDX+LuLh/GXZbaWNbDLqwLBObfbQg==}
3354
3355 '@lexical/react@0.41.0':
3356 resolution: {integrity: sha512-7+GUdZUm6sofWm+zdsWAs6cFBwKNsvsHezZTrf6k8jrZxL461ZQmbz/16b4DvjCGL9r5P1fR7md9/LCmk8TiCg==}
3357 peerDependencies:
3358 react: '>=17.x'
3359 react-dom: '>=17.x'
3360
3361 '@lexical/rich-text@0.41.0':
3362 resolution: {integrity: sha512-yUcr7ZaaVTZNi8bow4CK1M8jy2qyyls1Vr+5dVjwBclVShOL/F/nFyzBOSb6RtXXRbd3Ahuk9fEleppX/RNIdw==}
3363
3364 '@lexical/selection@0.41.0':
3365 resolution: {integrity: sha512-1s7/kNyRzcv5uaTwsUL28NpiisqTf5xZ1zNukLsCN1xY+TWbv9RE9OxIv+748wMm4pxNczQe/UbIBODkbeknLw==}
3366
3367 '@lexical/table@0.41.0':
3368 resolution: {integrity: sha512-d3SPThBAr+oZ8O74TXU0iXM3rLbrAVC7/HcOnSAq7/AhWQW8yMutT51JQGN+0fMLP9kqoWSAojNtkdvzXfU/+A==}
3369
3370 '@lexical/text@0.41.0':
3371 resolution: {integrity: sha512-gGA+Anc7ck110EXo4KVKtq6Ui3M7Vz3OpGJ4QE6zJHWW8nV5h273koUGSutAMeoZgRVb6t01Izh3ORoFt/j1CA==}
3372
3373 '@lexical/utils@0.41.0':
3374 resolution: {integrity: sha512-Wlsokr5NQCq83D+7kxZ9qs5yQ3dU3Qaf2M+uXxLRoPoDaXqW8xTWZq1+ZFoEzsHzx06QoPa4Vu/40BZR91uQPg==}
3375
3376 '@lexical/yjs@0.41.0':
3377 resolution: {integrity: sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w==}
3378 peerDependencies:
3379 yjs: '>=13.5.22'
3380
33813313 '@libsql/client@0.17.3':
33823314 resolution: {integrity: sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==}
33833315
......@@ -4273,9 +4205,6 @@ packages:
42734205 '@polka/url@1.0.0-next.29':
42744206 resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
42754207
4276 '@preact/signals-core@1.14.2':
4277 resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==}
4278
42794208 '@prisma/debug@7.2.0':
42804209 resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==}
42814210
......@@ -5334,6 +5263,166 @@ packages:
53345263 peerDependencies:
53355264 '@testing-library/dom': '>=7.21.4'
53365265
5266 '@tiptap/core@3.31.3':
5267 resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==}
5268 peerDependencies:
5269 '@tiptap/pm': 3.31.3
5270
5271 '@tiptap/extension-blockquote@3.31.3':
5272 resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==}
5273 peerDependencies:
5274 '@tiptap/core': 3.31.3
5275 '@tiptap/pm': 3.31.3
5276
5277 '@tiptap/extension-bold@3.31.3':
5278 resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==}
5279 peerDependencies:
5280 '@tiptap/core': 3.31.3
5281
5282 '@tiptap/extension-bubble-menu@3.31.3':
5283 resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==}
5284 peerDependencies:
5285 '@tiptap/core': 3.31.3
5286 '@tiptap/pm': 3.31.3
5287
5288 '@tiptap/extension-bullet-list@3.31.3':
5289 resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==}
5290 peerDependencies:
5291 '@tiptap/extension-list': 3.31.3
5292
5293 '@tiptap/extension-code-block@3.31.3':
5294 resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==}
5295 peerDependencies:
5296 '@tiptap/core': 3.31.3
5297 '@tiptap/pm': 3.31.3
5298
5299 '@tiptap/extension-code@3.31.3':
5300 resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==}
5301 peerDependencies:
5302 '@tiptap/core': 3.31.3
5303
5304 '@tiptap/extension-document@3.31.3':
5305 resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==}
5306 peerDependencies:
5307 '@tiptap/core': 3.31.3
5308
5309 '@tiptap/extension-dropcursor@3.31.3':
5310 resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==}
5311 peerDependencies:
5312 '@tiptap/extensions': 3.31.3
5313
5314 '@tiptap/extension-floating-menu@3.31.3':
5315 resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==}
5316 peerDependencies:
5317 '@floating-ui/dom': ^1.0.0
5318 '@tiptap/core': 3.31.3
5319 '@tiptap/pm': 3.31.3
5320
5321 '@tiptap/extension-gapcursor@3.31.3':
5322 resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==}
5323 peerDependencies:
5324 '@tiptap/extensions': 3.31.3
5325
5326 '@tiptap/extension-hard-break@3.31.3':
5327 resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==}
5328 peerDependencies:
5329 '@tiptap/core': 3.31.3
5330
5331 '@tiptap/extension-heading@3.31.3':
5332 resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==}
5333 peerDependencies:
5334 '@tiptap/core': 3.31.3
5335
5336 '@tiptap/extension-horizontal-rule@3.31.3':
5337 resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==}
5338 peerDependencies:
5339 '@tiptap/core': 3.31.3
5340 '@tiptap/pm': 3.31.3
5341
5342 '@tiptap/extension-italic@3.31.3':
5343 resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==}
5344 peerDependencies:
5345 '@tiptap/core': 3.31.3
5346
5347 '@tiptap/extension-link@3.31.3':
5348 resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==}
5349 peerDependencies:
5350 '@tiptap/core': 3.31.3
5351 '@tiptap/pm': 3.31.3
5352
5353 '@tiptap/extension-list-item@3.31.3':
5354 resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==}
5355 peerDependencies:
5356 '@tiptap/extension-list': 3.31.3
5357
5358 '@tiptap/extension-list-keymap@3.31.3':
5359 resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==}
5360 peerDependencies:
5361 '@tiptap/extension-list': 3.31.3
5362
5363 '@tiptap/extension-list@3.31.3':
5364 resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==}
5365 peerDependencies:
5366 '@tiptap/core': 3.31.3
5367 '@tiptap/pm': 3.31.3
5368
5369 '@tiptap/extension-ordered-list@3.31.3':
5370 resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==}
5371 peerDependencies:
5372 '@tiptap/extension-list': 3.31.3
5373
5374 '@tiptap/extension-paragraph@3.31.3':
5375 resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==}
5376 peerDependencies:
5377 '@tiptap/core': 3.31.3
5378
5379 '@tiptap/extension-strike@3.31.3':
5380 resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==}
5381 peerDependencies:
5382 '@tiptap/core': 3.31.3
5383
5384 '@tiptap/extension-task-item@3.31.3':
5385 resolution: {integrity: sha512-gCWvvXsCzi9tVFXPqKlkbU81XxtIdfjGy7FM9QFRKiWAvq5uv+Q5nvwrCyMVyP4bA7zRGWFaPzwjL68djgzoBw==}
5386 peerDependencies:
5387 '@tiptap/extension-list': 3.31.3
5388
5389 '@tiptap/extension-task-list@3.31.3':
5390 resolution: {integrity: sha512-3WgVzmfEnDbmxbjUBlvTiKpT53KSBnnYhXUVQxM0nz1vpoWG8QifhsnCVsNI6TZmhgR0x/jY+UW9Hc/WLF8DkQ==}
5391 peerDependencies:
5392 '@tiptap/extension-list': 3.31.3
5393
5394 '@tiptap/extension-text@3.31.3':
5395 resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==}
5396 peerDependencies:
5397 '@tiptap/core': 3.31.3
5398
5399 '@tiptap/extension-underline@3.31.3':
5400 resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==}
5401 peerDependencies:
5402 '@tiptap/core': 3.31.3
5403
5404 '@tiptap/extensions@3.31.3':
5405 resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==}
5406 peerDependencies:
5407 '@tiptap/core': 3.31.3
5408 '@tiptap/pm': 3.31.3
5409
5410 '@tiptap/pm@3.31.3':
5411 resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==}
5412
5413 '@tiptap/react@3.31.3':
5414 resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==}
5415 peerDependencies:
5416 '@tiptap/core': 3.31.3
5417 '@tiptap/pm': 3.31.3
5418 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
5419 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
5420 react: ^17.0.0 || ^18.0.0 || ^19.0.0
5421 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
5422
5423 '@tiptap/starter-kit@3.31.3':
5424 resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==}
5425
53375426 '@tybys/wasm-util@0.10.3':
53385427 resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
53395428
......@@ -5474,6 +5563,9 @@ packages:
54745563 '@types/unist@3.0.3':
54755564 resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
54765565
5566 '@types/use-sync-external-store@0.0.6':
5567 resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
5568
54775569 '@types/webidl-conversions@7.0.3':
54785570 resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
54795571
......@@ -7659,6 +7751,10 @@ packages:
76597751 fast-deep-equal@3.1.3:
76607752 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
76617753
7754 fast-equals@5.4.2:
7755 resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==}
7756 engines: {node: '>=6.0.0'}
7757
76627758 fast-glob@3.3.3:
76637759 resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
76647760 engines: {node: '>=8.6.0'}
......@@ -8258,9 +8354,6 @@ packages:
82588354 resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
82598355 engines: {node: '>=20'}
82608356
8261 isomorphic.js@0.2.5:
8262 resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
8263
82648357 jake@10.9.4:
82658358 resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==}
82668359 engines: {node: '>=10'}
......@@ -8397,14 +8490,6 @@ packages:
83978490 resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
83988491 engines: {node: '>=6'}
83998492
8400 lexical@0.41.0:
8401 resolution: {integrity: sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==}
8402
8403 lib0@0.2.117:
8404 resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==}
8405 engines: {node: '>=16'}
8406 hasBin: true
8407
84088493 libsodium-wrappers@0.8.4:
84098494 resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==}
84108495
......@@ -8500,6 +8585,9 @@ packages:
85008585 resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
85018586 engines: {node: '>=10'}
85028587
8588 linkifyjs@4.3.3:
8589 resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
8590
85038591 locate-path@3.0.0:
85048592 resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
85058593 engines: {node: '>=6'}
......@@ -9249,6 +9337,9 @@ packages:
92499337 resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==}
92509338 engines: {node: '>=20'}
92519339
9340 orderedmap@2.1.1:
9341 resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
9342
92529343 outvariant@1.4.3:
92539344 resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
92549345
......@@ -9609,6 +9700,45 @@ packages:
96099700 property-information@7.2.0:
96109701 resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
96119702
9703 prosemirror-changeset@2.4.2:
9704 resolution: {integrity: sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==}
9705
9706 prosemirror-commands@1.7.2:
9707 resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==}
9708
9709 prosemirror-dropcursor@1.8.3:
9710 resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==}
9711
9712 prosemirror-gapcursor@1.4.1:
9713 resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
9714
9715 prosemirror-history@1.5.0:
9716 resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
9717
9718 prosemirror-inputrules@1.5.1:
9719 resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
9720
9721 prosemirror-keymap@1.2.3:
9722 resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
9723
9724 prosemirror-model@1.25.11:
9725 resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==}
9726
9727 prosemirror-schema-list@1.5.1:
9728 resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
9729
9730 prosemirror-state@1.4.4:
9731 resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
9732
9733 prosemirror-tables@1.8.5:
9734 resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
9735
9736 prosemirror-transform@1.12.1:
9737 resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==}
9738
9739 prosemirror-view@1.42.3:
9740 resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==}
9741
96129742 proxy-addr@2.0.7:
96139743 resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
96149744 engines: {node: '>= 0.10'}
......@@ -9682,11 +9812,6 @@ packages:
96829812 peerDependencies:
96839813 react: ^19.2.6
96849814
9685 react-error-boundary@6.1.2:
9686 resolution: {integrity: sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==}
9687 peerDependencies:
9688 react: ^18.0.0 || ^19.0.0
9689
96909815 react-freeze@1.0.4:
96919816 resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==}
96929817 engines: {node: '>=10'}
......@@ -10091,6 +10216,9 @@ packages:
1009110216 engines: {node: '>=18.0.0', npm: '>=8.0.0'}
1009210217 hasBin: true
1009310218
10219 rope-sequence@1.3.4:
10220 resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
10221
1009410222 router@2.2.0:
1009510223 resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
1009610224 engines: {node: '>= 18'}
......@@ -10488,9 +10616,6 @@ packages:
1048810616 resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==}
1048910617 hasBin: true
1049010618
10491 tabbable@6.4.0:
10492 resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
10493
1049410619 tagged-tag@1.0.0:
1049510620 resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
1049610621 engines: {node: '>=20'}
......@@ -11113,6 +11238,9 @@ packages:
1111311238 vscode-uri@3.1.0:
1111411239 resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
1111511240
11241 w3c-keyname@2.2.8:
11242 resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
11243
1111611244 walk-up-path@4.0.0:
1111711245 resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
1111811246 engines: {node: 20 || >=22}
......@@ -11313,10 +11441,6 @@ packages:
1131311441 resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
1131411442 engines: {node: '>=12'}
1131511443
11316 yjs@13.6.31:
11317 resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==}
11318 engines: {node: '>=16.0.0', npm: '>=8.0.0'}
11319
1132011444 yocto-queue@0.1.0:
1132111445 resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
1132211446 engines: {node: '>=10'}
......@@ -13727,14 +13851,6 @@ snapshots:
1372713851 react: 19.2.6
1372813852 react-dom: 19.2.6(react@19.2.6)
1372913853
13730 '@floating-ui/react@0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
13731 dependencies:
13732 '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
13733 '@floating-ui/utils': 0.2.11
13734 react: 19.2.6
13735 react-dom: 19.2.6(react@19.2.6)
13736 tabbable: 6.4.0
13737
1373813854 '@floating-ui/utils@0.2.11': {}
1373913855
1374013856 '@formkit/auto-animate@0.9.0': {}
......@@ -14047,165 +14163,6 @@ snapshots:
1404714163 optionalDependencies:
1404814164 react-dom: 19.2.6(react@19.2.6)
1404914165
14050 '@lexical/clipboard@0.41.0':
14051 dependencies:
14052 '@lexical/html': 0.41.0
14053 '@lexical/list': 0.41.0
14054 '@lexical/selection': 0.41.0
14055 '@lexical/utils': 0.41.0
14056 lexical: 0.41.0
14057
14058 '@lexical/code@0.41.0':
14059 dependencies:
14060 '@lexical/utils': 0.41.0
14061 lexical: 0.41.0
14062 prismjs: 1.30.0
14063
14064 '@lexical/devtools-core@0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
14065 dependencies:
14066 '@lexical/html': 0.41.0
14067 '@lexical/link': 0.41.0
14068 '@lexical/mark': 0.41.0
14069 '@lexical/table': 0.41.0
14070 '@lexical/utils': 0.41.0
14071 lexical: 0.41.0
14072 react: 19.2.6
14073 react-dom: 19.2.6(react@19.2.6)
14074
14075 '@lexical/dragon@0.41.0':
14076 dependencies:
14077 '@lexical/extension': 0.41.0
14078 lexical: 0.41.0
14079
14080 '@lexical/extension@0.41.0':
14081 dependencies:
14082 '@lexical/utils': 0.41.0
14083 '@preact/signals-core': 1.14.2
14084 lexical: 0.41.0
14085
14086 '@lexical/hashtag@0.41.0':
14087 dependencies:
14088 '@lexical/text': 0.41.0
14089 '@lexical/utils': 0.41.0
14090 lexical: 0.41.0
14091
14092 '@lexical/history@0.41.0':
14093 dependencies:
14094 '@lexical/extension': 0.41.0
14095 '@lexical/utils': 0.41.0
14096 lexical: 0.41.0
14097
14098 '@lexical/html@0.41.0':
14099 dependencies:
14100 '@lexical/selection': 0.41.0
14101 '@lexical/utils': 0.41.0
14102 lexical: 0.41.0
14103
14104 '@lexical/link@0.41.0':
14105 dependencies:
14106 '@lexical/extension': 0.41.0
14107 '@lexical/utils': 0.41.0
14108 lexical: 0.41.0
14109
14110 '@lexical/list@0.41.0':
14111 dependencies:
14112 '@lexical/extension': 0.41.0
14113 '@lexical/selection': 0.41.0
14114 '@lexical/utils': 0.41.0
14115 lexical: 0.41.0
14116
14117 '@lexical/mark@0.41.0':
14118 dependencies:
14119 '@lexical/utils': 0.41.0
14120 lexical: 0.41.0
14121
14122 '@lexical/markdown@0.41.0':
14123 dependencies:
14124 '@lexical/code': 0.41.0
14125 '@lexical/link': 0.41.0
14126 '@lexical/list': 0.41.0
14127 '@lexical/rich-text': 0.41.0
14128 '@lexical/text': 0.41.0
14129 '@lexical/utils': 0.41.0
14130 lexical: 0.41.0
14131
14132 '@lexical/offset@0.41.0':
14133 dependencies:
14134 lexical: 0.41.0
14135
14136 '@lexical/overflow@0.41.0':
14137 dependencies:
14138 lexical: 0.41.0
14139
14140 '@lexical/plain-text@0.41.0':
14141 dependencies:
14142 '@lexical/clipboard': 0.41.0
14143 '@lexical/dragon': 0.41.0
14144 '@lexical/selection': 0.41.0
14145 '@lexical/utils': 0.41.0
14146 lexical: 0.41.0
14147
14148 '@lexical/react@0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)':
14149 dependencies:
14150 '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
14151 '@lexical/devtools-core': 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
14152 '@lexical/dragon': 0.41.0
14153 '@lexical/extension': 0.41.0
14154 '@lexical/hashtag': 0.41.0
14155 '@lexical/history': 0.41.0
14156 '@lexical/link': 0.41.0
14157 '@lexical/list': 0.41.0
14158 '@lexical/mark': 0.41.0
14159 '@lexical/markdown': 0.41.0
14160 '@lexical/overflow': 0.41.0
14161 '@lexical/plain-text': 0.41.0
14162 '@lexical/rich-text': 0.41.0
14163 '@lexical/table': 0.41.0
14164 '@lexical/text': 0.41.0
14165 '@lexical/utils': 0.41.0
14166 '@lexical/yjs': 0.41.0(yjs@13.6.31)
14167 lexical: 0.41.0
14168 react: 19.2.6
14169 react-dom: 19.2.6(react@19.2.6)
14170 react-error-boundary: 6.1.2(react@19.2.6)
14171 transitivePeerDependencies:
14172 - yjs
14173
14174 '@lexical/rich-text@0.41.0':
14175 dependencies:
14176 '@lexical/clipboard': 0.41.0
14177 '@lexical/dragon': 0.41.0
14178 '@lexical/selection': 0.41.0
14179 '@lexical/utils': 0.41.0
14180 lexical: 0.41.0
14181
14182 '@lexical/selection@0.41.0':
14183 dependencies:
14184 lexical: 0.41.0
14185
14186 '@lexical/table@0.41.0':
14187 dependencies:
14188 '@lexical/clipboard': 0.41.0
14189 '@lexical/extension': 0.41.0
14190 '@lexical/utils': 0.41.0
14191 lexical: 0.41.0
14192
14193 '@lexical/text@0.41.0':
14194 dependencies:
14195 lexical: 0.41.0
14196
14197 '@lexical/utils@0.41.0':
14198 dependencies:
14199 '@lexical/selection': 0.41.0
14200 lexical: 0.41.0
14201
14202 '@lexical/yjs@0.41.0(yjs@13.6.31)':
14203 dependencies:
14204 '@lexical/offset': 0.41.0
14205 '@lexical/selection': 0.41.0
14206 lexical: 0.41.0
14207 yjs: 13.6.31
14208
1420914166 '@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)':
1421014167 dependencies:
1421114168 '@libsql/core': 0.17.3
......@@ -14877,8 +14834,6 @@ snapshots:
1487714834
1487814835 '@polka/url@1.0.0-next.29': {}
1487914836
14880 '@preact/signals-core@1.14.2': {}
14881
1488214837 '@prisma/debug@7.2.0': {}
1488314838
1488414839 '@prisma/dev@0.20.0(typescript@6.0.3)':
......@@ -15848,6 +15803,187 @@ snapshots:
1584815803 dependencies:
1584915804 '@testing-library/dom': 10.4.1
1585015805
15806 '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
15807 dependencies:
15808 '@tiptap/pm': 3.31.3
15809
15810 '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
15811 dependencies:
15812 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15813 '@tiptap/pm': 3.31.3
15814
15815 '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
15816 dependencies:
15817 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15818
15819 '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
15820 dependencies:
15821 '@floating-ui/dom': 1.7.6
15822 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15823 '@tiptap/pm': 3.31.3
15824 optional: true
15825
15826 '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
15827 dependencies:
15828 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15829
15830 '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
15831 dependencies:
15832 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15833 '@tiptap/pm': 3.31.3
15834
15835 '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
15836 dependencies:
15837 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15838
15839 '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
15840 dependencies:
15841 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15842
15843 '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
15844 dependencies:
15845 '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15846
15847 '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
15848 dependencies:
15849 '@floating-ui/dom': 1.7.6
15850 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15851 '@tiptap/pm': 3.31.3
15852 optional: true
15853
15854 '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
15855 dependencies:
15856 '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15857
15858 '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
15859 dependencies:
15860 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15861
15862 '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
15863 dependencies:
15864 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15865
15866 '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
15867 dependencies:
15868 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15869 '@tiptap/pm': 3.31.3
15870
15871 '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
15872 dependencies:
15873 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15874
15875 '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
15876 dependencies:
15877 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15878 '@tiptap/pm': 3.31.3
15879 linkifyjs: 4.3.3
15880
15881 '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
15882 dependencies:
15883 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15884
15885 '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
15886 dependencies:
15887 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15888
15889 '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
15890 dependencies:
15891 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15892 '@tiptap/pm': 3.31.3
15893
15894 '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
15895 dependencies:
15896 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15897
15898 '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
15899 dependencies:
15900 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15901
15902 '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
15903 dependencies:
15904 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15905
15906 '@tiptap/extension-task-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
15907 dependencies:
15908 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15909
15910 '@tiptap/extension-task-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
15911 dependencies:
15912 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15913
15914 '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
15915 dependencies:
15916 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15917
15918 '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
15919 dependencies:
15920 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15921
15922 '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
15923 dependencies:
15924 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15925 '@tiptap/pm': 3.31.3
15926
15927 '@tiptap/pm@3.31.3':
15928 dependencies:
15929 prosemirror-changeset: 2.4.2
15930 prosemirror-commands: 1.7.2
15931 prosemirror-dropcursor: 1.8.3
15932 prosemirror-gapcursor: 1.4.1
15933 prosemirror-history: 1.5.0
15934 prosemirror-inputrules: 1.5.1
15935 prosemirror-keymap: 1.2.3
15936 prosemirror-model: 1.25.11
15937 prosemirror-schema-list: 1.5.1
15938 prosemirror-state: 1.4.4
15939 prosemirror-tables: 1.8.5
15940 prosemirror-transform: 1.12.1
15941 prosemirror-view: 1.42.3
15942
15943 '@tiptap/react@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
15944 dependencies:
15945 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15946 '@tiptap/pm': 3.31.3
15947 '@types/react': 19.2.16
15948 '@types/react-dom': 19.2.3(@types/react@19.2.16)
15949 '@types/use-sync-external-store': 0.0.6
15950 fast-equals: 5.4.2
15951 react: 19.2.6
15952 react-dom: 19.2.6(react@19.2.6)
15953 use-sync-external-store: 1.6.0(react@19.2.6)
15954 optionalDependencies:
15955 '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15956 '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15957 transitivePeerDependencies:
15958 - '@floating-ui/dom'
15959
15960 '@tiptap/starter-kit@3.31.3':
15961 dependencies:
15962 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
15963 '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15964 '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
15965 '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
15966 '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
15967 '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15968 '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
15969 '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
15970 '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
15971 '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
15972 '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
15973 '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15974 '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
15975 '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15976 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15977 '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
15978 '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
15979 '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
15980 '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
15981 '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
15982 '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
15983 '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
15984 '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15985 '@tiptap/pm': 3.31.3
15986
1585115987 '@tybys/wasm-util@0.10.3':
1585215988 dependencies:
1585315989 tslib: 2.8.1
......@@ -16017,6 +16153,8 @@ snapshots:
1601716153
1601816154 '@types/unist@3.0.3': {}
1601916155
16156 '@types/use-sync-external-store@0.0.6': {}
16157
1602016158 '@types/webidl-conversions@7.0.3':
1602116159 optional: true
1602216160
......@@ -16493,7 +16631,7 @@ snapshots:
1649316631 json-schema-traverse: 1.0.0
1649416632 require-from-string: 2.0.2
1649516633
16496 alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f):
16634 alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e):
1649716635 dependencies:
1649816636 '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2)
1649916637 '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))
......@@ -18361,6 +18499,8 @@ snapshots:
1836118499
1836218500 fast-deep-equal@3.1.3: {}
1836318501
18502 fast-equals@5.4.2: {}
18503
1836418504 fast-glob@3.3.3:
1836518505 dependencies:
1836618506 '@nodelib/fs.stat': 2.0.5
......@@ -19072,8 +19212,6 @@ snapshots:
1907219212
1907319213 isexe@4.0.0: {}
1907419214
19075 isomorphic.js@0.2.5: {}
19076
1907719215 jake@10.9.4:
1907819216 dependencies:
1907919217 async: 3.2.6
......@@ -19209,12 +19347,6 @@ snapshots:
1920919347
1921019348 leven@3.1.0: {}
1921119349
19212 lexical@0.41.0: {}
19213
19214 lib0@0.2.117:
19215 dependencies:
19216 isomorphic.js: 0.2.5
19217
1921819350 libsodium-wrappers@0.8.4:
1921919351 dependencies:
1922019352 libsodium: 0.8.4
......@@ -19298,6 +19430,8 @@ snapshots:
1929819430
1929919431 lilconfig@2.1.0: {}
1930019432
19433 linkifyjs@4.3.3: {}
19434
1930119435 locate-path@3.0.0:
1930219436 dependencies:
1930319437 p-locate: 3.0.0
......@@ -20474,6 +20608,8 @@ snapshots:
2047420608 stdin-discarder: 0.3.2
2047520609 string-width: 8.2.1
2047620610
20611 orderedmap@2.1.1: {}
20612
2047720613 outvariant@1.4.3:
2047820614 optional: true
2047920615
......@@ -20866,6 +21002,80 @@ snapshots:
2086621002
2086721003 property-information@7.2.0: {}
2086821004
21005 prosemirror-changeset@2.4.2:
21006 dependencies:
21007 prosemirror-transform: 1.12.1
21008
21009 prosemirror-commands@1.7.2:
21010 dependencies:
21011 prosemirror-model: 1.25.11
21012 prosemirror-state: 1.4.4
21013 prosemirror-transform: 1.12.1
21014
21015 prosemirror-dropcursor@1.8.3:
21016 dependencies:
21017 prosemirror-state: 1.4.4
21018 prosemirror-transform: 1.12.1
21019 prosemirror-view: 1.42.3
21020
21021 prosemirror-gapcursor@1.4.1:
21022 dependencies:
21023 prosemirror-keymap: 1.2.3
21024 prosemirror-model: 1.25.11
21025 prosemirror-state: 1.4.4
21026 prosemirror-view: 1.42.3
21027
21028 prosemirror-history@1.5.0:
21029 dependencies:
21030 prosemirror-state: 1.4.4
21031 prosemirror-transform: 1.12.1
21032 prosemirror-view: 1.42.3
21033 rope-sequence: 1.3.4
21034
21035 prosemirror-inputrules@1.5.1:
21036 dependencies:
21037 prosemirror-state: 1.4.4
21038 prosemirror-transform: 1.12.1
21039
21040 prosemirror-keymap@1.2.3:
21041 dependencies:
21042 prosemirror-state: 1.4.4
21043 w3c-keyname: 2.2.8
21044
21045 prosemirror-model@1.25.11:
21046 dependencies:
21047 orderedmap: 2.1.1
21048
21049 prosemirror-schema-list@1.5.1:
21050 dependencies:
21051 prosemirror-model: 1.25.11
21052 prosemirror-state: 1.4.4
21053 prosemirror-transform: 1.12.1
21054
21055 prosemirror-state@1.4.4:
21056 dependencies:
21057 prosemirror-model: 1.25.11
21058 prosemirror-transform: 1.12.1
21059 prosemirror-view: 1.42.3
21060
21061 prosemirror-tables@1.8.5:
21062 dependencies:
21063 prosemirror-keymap: 1.2.3
21064 prosemirror-model: 1.25.11
21065 prosemirror-state: 1.4.4
21066 prosemirror-transform: 1.12.1
21067 prosemirror-view: 1.42.3
21068
21069 prosemirror-transform@1.12.1:
21070 dependencies:
21071 prosemirror-model: 1.25.11
21072
21073 prosemirror-view@1.42.3:
21074 dependencies:
21075 prosemirror-model: 1.25.11
21076 prosemirror-state: 1.4.4
21077 prosemirror-transform: 1.12.1
21078
2086921079 proxy-addr@2.0.7:
2087021080 dependencies:
2087121081 forwarded: 0.2.0
......@@ -20944,10 +21154,6 @@ snapshots:
2094421154 react: 19.2.6
2094521155 scheduler: 0.27.0
2094621156
20947 react-error-boundary@6.1.2(react@19.2.6):
20948 dependencies:
20949 react: 19.2.6
20950
2095121157 react-freeze@1.0.4(react@19.2.3):
2095221158 dependencies:
2095321159 react: 19.2.3
......@@ -21586,6 +21792,8 @@ snapshots:
2158621792 fsevents: 2.3.3
2158721793 optional: true
2158821794
21795 rope-sequence@1.3.4: {}
21796
2158921797 router@2.2.0:
2159021798 dependencies:
2159121799 debug: 4.4.3
......@@ -22090,8 +22298,6 @@ snapshots:
2209022298 transitivePeerDependencies:
2209122299 - encoding
2209222300
22093 tabbable@6.4.0: {}
22094
2209522301 tagged-tag@1.0.0: {}
2209622302
2209722303 tailwind-merge@3.6.0: {}
......@@ -22693,6 +22899,8 @@ snapshots:
2269322899
2269422900 vscode-uri@3.1.0: {}
2269522901
22902 w3c-keyname@2.2.8: {}
22903
2269622904 walk-up-path@4.0.0: {}
2269722905
2269822906 walker@1.0.8:
......@@ -22880,10 +23088,6 @@ snapshots:
2288023088 dependencies:
2288123089 pend: 1.2.0
2288223090
22883 yjs@13.6.31:
22884 dependencies:
22885 lib0: 0.2.117
22886
2288723091 yocto-queue@0.1.0: {}
2288823092
2288923093 yocto-queue@1.2.2: {}