diff --git a/apps/web/package.json b/apps/web/package.json index 86fb491308019683c78c7d5a619d2a8e385662ee..e6980cf778ea15709d2e891d7241399155580a57 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,7 +23,6 @@ "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "catalog:", - "@lexical/react": "^0.41.0", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", @@ -32,6 +31,12 @@ "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", + "@tiptap/core": "^3.31.3", + "@tiptap/extension-task-item": "^3.31.3", + "@tiptap/extension-task-list": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", "effect": "catalog:", @@ -41,7 +46,6 @@ "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", - "lexical": "^0.41.0", "lucide-react": "^0.564.0", "react": "19.2.6", "react-dom": "19.2.6", diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index c00565f45902521e02322764adf93abc32790433..52b45ab8405c486c28f4d6d3d1b890f8a60b3df4 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -11,7 +11,11 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; -import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { + isRichTextBoldShortcut, + resolveShortcutCommand, + shortcutLabelForCommand, +} from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; @@ -88,6 +92,15 @@ function SidebarControl() { ) { return; } + if ( + isRichTextBoldShortcut(event) && + event.target instanceof HTMLElement && + event.target.closest('[data-composer-rich-text="true"]') + ) { + // The rich-text composer claims Mod+B for bold; the toggle stays + // available everywhere else, including the plain-text composer. + return; + } if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; event.preventDefault(); diff --git a/apps/web/src/components/ComposerCitationNode.tsx b/apps/web/src/components/ComposerCitationNode.tsx deleted file mode 100644 index 4611eb7a8c54a413203ac8dfbba8799659507368..0000000000000000000000000000000000000000 --- a/apps/web/src/components/ComposerCitationNode.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import type { AssistantCitation } from "@t3tools/contracts"; -import { - serializeAssistantCitation, - withAssistantCitationComment, -} from "@t3tools/shared/assistantCitations"; -import { - $applyNodeReplacement, - $getNodeByKey, - $getRoot, - $isElementNode, - DecoratorNode, - HISTORY_PUSH_TAG, - SKIP_DOM_SELECTION_TAG, - type NodeKey, - type SerializedLexicalNode, - type Spread, -} from "lexical"; -import { createContext, use, type ReactElement } from "react"; -import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; - -import { AssistantCitationChip } from "./chat/AssistantCitationChip"; -import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME } from "./composerInlineChip"; - -type SerializedComposerCitationNode = Spread< - { - citation: AssistantCitation; - source: string; - type: "composer-citation"; - version: 1; - }, - SerializedLexicalNode ->; - -export type ComposerCitationCommentRequest = { - previousValue: string; - value: string; - citationStart: number; - sourceAnchor: AssistantCitationSourceAnchor; -}; - -export type ComposerCitationCommentTarget = { - nodeKey: NodeKey; - sourceAnchor?: AssistantCitationSourceAnchor; - removeOnCancel?: boolean; -}; - -export const ComposerCitationCommentContext = createContext<{ - openComment: ComposerCitationCommentTarget | null; - onOpenChange: (nodeKey: NodeKey, open: boolean) => void; - onSubmitAndSend: () => void; -}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); - -/** Consume a cite action once its controlled prompt has been committed to the editor. */ -export function $consumeComposerCitationCommentRequest(requestRef: { - current: ComposerCitationCommentRequest | null; -}): ComposerCitationCommentTarget | null { - const request = requestRef.current; - if (!request) return null; - const root = $getRoot(); - const value = root.getTextContent(); - if (value === request.previousValue) return null; - requestRef.current = null; - if (value !== request.value) return null; - - // Controlled prompts use one paragraph with inline nodes and explicit line breaks. - const paragraph = root.getFirstChild(); - if (!$isElementNode(paragraph)) return null; - let offset = 0; - for (const node of paragraph.getChildren()) { - if (offset === request.citationStart && node instanceof ComposerCitationNode) { - return { - nodeKey: node.getKey(), - sourceAnchor: request.sourceAnchor, - removeOnCancel: true, - }; - } - offset += node.getTextContentSize(); - } - return null; -} - -function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey: NodeKey }) { - const [editor] = useLexicalComposerContext(); - const commentContext = use(ComposerCitationCommentContext); - const commentTarget = - commentContext.openComment?.nodeKey === props.nodeKey ? commentContext.openComment : null; - const onSaveComment = (comment: string): boolean => { - if (!editor.isEditable()) return false; - let accepted = false; - editor.update( - () => { - const node = $getNodeByKey(props.nodeKey); - if (node instanceof ComposerCitationNode && node.isAttached()) { - node.setComment(comment); - accepted = true; - } - }, - { discrete: true, tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] }, - ); - return accepted; - }; - /** Cancelling a comment on a just-created citation removes the chip the cite action added. */ - const onRemove = () => { - if (!editor.isEditable()) return; - editor.update( - () => { - const node = $getNodeByKey(props.nodeKey); - if (node instanceof ComposerCitationNode) { - node.selectPrevious(); - node.remove(); - } - }, - { tag: HISTORY_PUSH_TAG }, - ); - editor.getRootElement()?.focus({ preventScroll: true }); - }; - return ( - - { - if (open && !editor.isEditable()) return; - commentContext.onOpenChange(props.nodeKey, open); - }, - ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), - onSave: onSaveComment, - onSaveAndSend: (comment) => { - if (!onSaveComment(comment)) return false; - commentContext.onSubmitAndSend(); - return true; - }, - }} - /> - - ); -} - -export class ComposerCitationNode extends DecoratorNode { - __citation: AssistantCitation; - __source: string; - - static override getType(): string { - return "composer-citation"; - } - - static override clone(node: ComposerCitationNode): ComposerCitationNode { - return new ComposerCitationNode(node.__citation, node.__source, node.__key); - } - - static override importJSON(serializedNode: SerializedComposerCitationNode): ComposerCitationNode { - return $createComposerCitationNode( - serializedNode.citation, - serializedNode.source, - ).updateFromJSON(serializedNode); - } - - constructor(citation: AssistantCitation, source: string, key?: NodeKey) { - super(key); - this.__citation = citation; - this.__source = source; - } - - override exportJSON(): SerializedComposerCitationNode { - const node = this.getLatest(); - return { - ...super.exportJSON(), - citation: node.__citation, - source: node.__source, - type: "composer-citation", - version: 1, - }; - } - - override createDOM(): HTMLElement { - const dom = document.createElement("span"); - dom.className = `${COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME} max-w-full`; - return dom; - } - - override updateDOM(): false { - return false; - } - - override getTextContent(): string { - return this.getLatest().__source; - } - - setComment(comment: string): this { - const latest = this.getLatest(); - const citation = withAssistantCitationComment(latest.__citation, comment); - if (citation.comment === latest.__citation.comment) return latest; - const source = serializeAssistantCitation(citation); - const writable = this.getWritable(); - writable.__citation = citation; - writable.__source = source; - return writable; - } - - override isInline(): true { - return true; - } - - override decorate(): ReactElement { - return ; - } -} - -export function $createComposerCitationNode( - citation: AssistantCitation, - source: string, -): ComposerCitationNode { - return $applyNodeReplacement(new ComposerCitationNode(citation, source)); -} diff --git a/apps/web/src/components/ComposerContextReferenceNode.test.ts b/apps/web/src/components/ComposerContextReferenceNode.test.ts deleted file mode 100644 index b03ae10633a9dd1f5b743db9e657860c63eb5e71..0000000000000000000000000000000000000000 --- a/apps/web/src/components/ComposerContextReferenceNode.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import { $createParagraphNode, $getRoot, $isElementNode, createEditor } from "lexical"; - -import { - $createComposerContextReferenceNode, - ComposerContextReferenceNode, -} from "./ComposerContextReferenceNode"; -import { splitPromptIntoComposerSegments } from "../composer-editor-mentions"; - -vi.mock("./composerContextPresentation", () => ({ - ComposerContextReferenceChip: () => null, -})); - -const reference = { kind: "terminal", contextId: "ctx-1", label: "Terminal 1 lines 3-4" }; -const link = "[Terminal 1 lines 3-4](t3-context://v1/terminal/ctx-1)"; - -function createReferenceEditor() { - const editor = createEditor({ nodes: [ComposerContextReferenceNode] }); - editor.update( - () => { - $getRoot().append($createParagraphNode()); - }, - { discrete: true }, - ); - return editor; -} - -function $referenceNodes() { - const paragraph = $getRoot().getFirstChildOrThrow(); - if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph"); - return paragraph.getChildren().filter((node) => node instanceof ComposerContextReferenceNode); -} - -describe("ComposerContextReferenceNode", () => { - it("renders its canonical link as text so the prompt carries identity", () => { - const editor = createReferenceEditor(); - editor.update( - () => { - const paragraph = $getRoot().getFirstChild(); - if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph"); - paragraph.append($createComposerContextReferenceNode(reference, "ref-1")); - }, - { discrete: true }, - ); - const text = editor.getEditorState().read(() => $getRoot().getTextContent()); - expect(text).toBe(link); - expect(splitPromptIntoComposerSegments(text)).toEqual([ - { type: "context-reference", ...reference, source: link }, - ]); - }); - - it("round-trips through JSON keeping the occurrence id", () => { - const editor = createReferenceEditor(); - editor.update( - () => { - const paragraph = $getRoot().getFirstChild(); - if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph"); - paragraph.append($createComposerContextReferenceNode(reference, "ref-1")); - }, - { discrete: true }, - ); - const serialized = editor.getEditorState().toJSON(); - const restored = createReferenceEditor(); - restored.setEditorState(restored.parseEditorState(serialized)); - const nodes = restored.getEditorState().read(() => - $referenceNodes().map((node) => ({ - referenceId: node.__referenceId, - reference: node.getReference(), - })), - ); - expect(nodes).toEqual([{ referenceId: "ref-1", reference }]); - }); - - it("mints a distinct occurrence id per created node while sharing the payload id", () => { - const editor = createReferenceEditor(); - editor.update( - () => { - const paragraph = $getRoot().getFirstChild(); - if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph"); - paragraph.append( - $createComposerContextReferenceNode(reference), - $createComposerContextReferenceNode(reference), - ); - }, - { discrete: true }, - ); - const nodes = editor.getEditorState().read(() => $referenceNodes()); - expect(nodes[0]!.__contextId).toBe(nodes[1]!.__contextId); - expect(nodes[0]!.__referenceId).not.toBe(nodes[1]!.__referenceId); - expect(nodes[0]!.__referenceId).toMatch(/^[0-9a-f-]{36}$/); - }); -}); diff --git a/apps/web/src/components/ComposerContextReferenceNode.tsx b/apps/web/src/components/ComposerContextReferenceNode.tsx deleted file mode 100644 index 915d679978a9e6c259fc72c50de4ee63da389538..0000000000000000000000000000000000000000 --- a/apps/web/src/components/ComposerContextReferenceNode.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; -import type { ComposerContextId } from "@t3tools/contracts"; -import { - $applyNodeReplacement, - DecoratorNode, - type NodeKey, - type SerializedLexicalNode, - type Spread, -} from "lexical"; -import type { ReactElement } from "react"; - -import { randomUUID } from "~/lib/utils"; -import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME } from "./composerInlineChip"; -import { ComposerContextReferenceChip } from "./composerContextPresentation"; - -export interface ComposerContextReference { - kind: string; - contextId: string; - label: string; -} - -export type SerializedComposerContextReferenceNode = Spread< - ComposerContextReference & { - referenceId: string; - type: "composer-context-reference"; - version: 1; - }, - SerializedLexicalNode ->; - -/** - * One inline occurrence of a context payload. The node's text is the canonical link, so the - * prompt string carries kind and id and rebuilding the editor from it restores the same chip. - * `referenceId` identifies this occurrence; duplicating the node mints a new one. - */ -export class ComposerContextReferenceNode extends DecoratorNode { - __kind: string; - __contextId: string; - __label: string; - __referenceId: string; - - static override getType(): "composer-context-reference" { - return "composer-context-reference"; - } - - static override clone(node: ComposerContextReferenceNode): ComposerContextReferenceNode { - return new ComposerContextReferenceNode( - { kind: node.__kind, contextId: node.__contextId, label: node.__label }, - node.__referenceId, - node.__key, - ); - } - - static override importJSON( - serializedNode: SerializedComposerContextReferenceNode, - ): ComposerContextReferenceNode { - return $createComposerContextReferenceNode( - { - kind: serializedNode.kind, - contextId: serializedNode.contextId, - label: serializedNode.label, - }, - serializedNode.referenceId, - ).updateFromJSON(serializedNode); - } - - constructor(reference: ComposerContextReference, referenceId: string, key?: NodeKey) { - super(key); - this.__kind = reference.kind; - this.__contextId = reference.contextId; - this.__label = reference.label; - this.__referenceId = referenceId; - } - - override exportJSON(): SerializedComposerContextReferenceNode { - const latest = this.getLatest(); - return { - ...super.exportJSON(), - kind: latest.__kind, - contextId: latest.__contextId, - label: latest.__label, - referenceId: latest.__referenceId, - type: "composer-context-reference", - version: 1, - }; - } - - override createDOM(): HTMLElement { - const dom = document.createElement("span"); - dom.className = `${COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME} max-w-full`; - return dom; - } - - override updateDOM(): false { - return false; - } - - override getTextContent(): string { - const latest = this.getLatest(); - return formatComposerContextReference({ - kind: latest.__kind, - contextId: latest.__contextId as ComposerContextId, - label: latest.__label, - }); - } - - override isInline(): true { - return true; - } - - getReference(): ComposerContextReference { - const latest = this.getLatest(); - return { kind: latest.__kind, contextId: latest.__contextId, label: latest.__label }; - } - - override decorate(): ReactElement { - const latest = this.getLatest(); - return ( - - ); - } -} - -export function $createComposerContextReferenceNode( - reference: ComposerContextReference, - referenceId: string = randomUUID(), -): ComposerContextReferenceNode { - return $applyNodeReplacement(new ComposerContextReferenceNode(reference, referenceId)); -} diff --git a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx deleted file mode 100644 index 98d72e246c50082088d0f48329c122e6b904c066..0000000000000000000000000000000000000000 --- a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { $copyNode, $getRoot, $isElementNode, PASTE_COMMAND, type LexicalEditor } from "lexical"; -import { act, createRef } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { collapseExpandedComposerCursor } from "../composer-logic"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; - -vi.mock("./chat/FileTagChip", () => ({ - FILE_TAG_CHIP_CLASS_NAME: "", - FileTagChipContent: () => null, -})); -vi.mock("./chat/ComposerPendingTerminalContexts", () => ({ - ComposerPendingTerminalContextChip: () => null, -})); -vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null })); - -let lexicalEditor: LexicalEditor; -// Keep the real composer, registered nodes, updates, and snapshot API. Only the -// DOM view is omitted so Lexical runs headlessly in this component test. -vi.mock("@lexical/react/LexicalPlainTextPlugin", () => ({ - PlainTextPlugin: function HeadlessEditor() { - [lexicalEditor] = useLexicalComposerContext(); - return null; - }, -})); - -let renderer: ReactTestRenderer | undefined; -const editorRef = createRef(); - -function composer(value: string) { - return ( - {}} - onPaste={() => {}} - editorRef={editorRef} - /> - ); -} - -async function renderPrompt(value: string) { - await act(() => { - if (renderer) renderer.update(composer(value)); - else renderer = create(composer(value)); - }); -} - -function $firstMention() { - const paragraph = $getRoot().getFirstChildOrThrow(); - if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); - const mention = paragraph.getFirstChildOrThrow(); - if (mention.getType() !== "composer-mention") throw new Error("Expected a mention"); - return mention; -} - -class TestClipboardEvent extends Event { - readonly clipboardData: DataTransfer; - - constructor(text: string) { - super("paste", { cancelable: true }); - this.clipboardData = { - files: [], - getData: (type: string) => (type === "text/plain" ? text : ""), - } as unknown as DataTransfer; - } -} - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("document", { activeElement: null }); -}); - -afterEach(async () => { - await act(() => renderer?.unmount()); - renderer = undefined; - vi.unstubAllGlobals(); -}); - -describe("composer mention serialization", () => { - it.each([ - "@README.md control", - "@terminal-1:3 Explain this output\n\n\n- Terminal 1 line 3:\n 3 | output\n", - '@"docs/My \\"File\\".md" please', - '@"docs/ι›ͺ πŸ‘‹.md" please', - "[README.md](README.md) control", - "[config#draft?.json](config%23draft%3f.json) control", - "Plain text\n Keep indentation πŸ‘‹", - ])("preserves the initial prompt %s", async (prompt) => { - await renderPrompt(prompt); - expect(editorRef.current?.readSnapshot().value).toBe(prompt); - }); - - it("preserves original source when replacing the controlled prompt", async () => { - for (const prompt of ["", "@README.md control", "Older plain control", "@README.md control"]) { - await renderPrompt(prompt); - expect(editorRef.current?.readSnapshot()).toMatchObject({ - value: prompt, - expandedCursor: prompt.length, - }); - if (prompt === "@README.md control") { - expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); - } - } - }); - - it("preserves source when Lexical clones the mention and reloads exported state", async () => { - const prompt = '@"docs/ι›ͺ πŸ‘‹.md" remains a chip'; - await renderPrompt(prompt); - const originalKey = lexicalEditor.getEditorState().read(() => $firstMention().getKey()); - - await act(() => { - lexicalEditor.update( - () => { - const mention = $firstMention(); - mention.replace($copyNode(mention)); - }, - { discrete: true }, - ); - }); - expect(lexicalEditor.getEditorState().read(() => $firstMention().getKey())).not.toBe( - originalKey, - ); - expect(editorRef.current?.readSnapshot().value).toBe(prompt); - const exportedState = lexicalEditor.getEditorState().toJSON(); - - await renderPrompt(""); - await act(() => { - lexicalEditor.setEditorState(lexicalEditor.parseEditorState(exportedState)); - }); - expect(editorRef.current?.readSnapshot().value).toBe(prompt); - expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); - }); - - it("keeps canonical serialization when importing legacy mention JSON without source", async () => { - await renderPrompt(""); - await act(() => { - lexicalEditor.setEditorState( - lexicalEditor.parseEditorState( - JSON.stringify({ - root: { - type: "root", - version: 1, - children: [ - { - type: "paragraph", - version: 1, - children: [{ type: "composer-mention", version: 1, path: "README.md" }], - }, - ], - }, - }), - ), - ); - }); - expect(editorRef.current?.readSnapshot().value).toBe("[README.md](README.md)"); - expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); - }); - - it("still serializes a newly inserted mention canonically", async () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - await renderPrompt(""); - const event = new TestClipboardEvent("@README.md "); - await act(() => { - lexicalEditor.update( - () => { - $getRoot().selectEnd(); - lexicalEditor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - }); - expect(event.defaultPrevented).toBe(true); - expect(editorRef.current?.readSnapshot().value).toBe("[README.md](README.md) "); - expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); - }); -}); diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts deleted file mode 100644 index 9d46174d174f045b6dde48034956c59768f54a01..0000000000000000000000000000000000000000 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ /dev/null @@ -1,864 +0,0 @@ -import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; -import { elementContextToPreviewAnnotation } from "../lib/elementContext"; -import { previewAnnotationContextRecord } from "../lib/composerContextRecords"; -import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; -import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { - $createParagraphNode, - $createTextNode, - $getRoot, - $getSelection, - $isElementNode, - $isRangeSelection, - COMMAND_PRIORITY_EDITOR, - createEditor, - PASTE_COMMAND, -} from "lexical"; - -import { - importPastedComposerText, - readPastedComposerContext, - registerComposerInlineTokenPaste, -} from "./composerInlineTokenPaste"; -import { - $consumeComposerCitationCommentRequest, - $createComposerCitationNode, - ComposerCitationNode, - type ComposerCitationCommentRequest, -} from "./ComposerCitationNode"; -import { splitPromptIntoComposerSegments } from "../composer-editor-mentions"; -import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; - -vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null })); - -const citation: AssistantCitation = { - version: 1, - environmentId: EnvironmentId.make("remote/ι›ͺ"), - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("message-1"), - text: 'Keep "πŸ‘‹", (punctuation) and @AGENTS.md.', - start: 5, - end: 43, - prefix: "前: ", - suffix: " 後", -}; -const citationSource = serializeAssistantCitation(citation).replaceAll("+", "%20"); - -function createCitationEditor(text = "") { - const editor = createEditor({ nodes: [ComposerCitationNode] }); - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - if (text) paragraph.append($createTextNode(text)); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: (_node, offset) => offset, - }); - return editor; -} - -function $citationNodes() { - const paragraph = $getRoot().getFirstChildOrThrow(); - if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); - return paragraph.getChildren().filter((node) => node instanceof ComposerCitationNode); -} - -function pasteText(editor: ReturnType, text: string) { - const event = new TestClipboardEvent(text); - editor.update( - () => { - editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - return event; -} - -class TestClipboardEvent extends Event { - readonly clipboardData: DataTransfer; - - constructor(text: string, extra: Record = {}) { - super("paste", { cancelable: true }); - this.clipboardData = { - files: [], - getData: (type: string) => (type === "text/plain" ? text : (extra[type] ?? "")), - } as unknown as DataTransfer; - } -} - -describe("registerComposerInlineTokenPaste", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("handles a copied mention without also running the plain-text paste fallback", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createEditor(); - const mention = "[improve-deploy-error-logging.md](.changeset/improve-deploy-error-logging.md)"; - const plainTextFallback = vi.fn(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) return false; - selection.insertText(mention); - return true; - }); - - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => - $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: () => 0, - }); - editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); - - const event = new TestClipboardEvent(mention); - let handled = false; - editor.update( - () => { - handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - - expect(handled).toBe(true); - expect(plainTextFallback).not.toHaveBeenCalled(); - expect(event.defaultPrevented).toBe(true); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - " ", - ); - }); - - it.each([ - "yarn expo install @expo/ui", - "npm install @jane/foo.js", - "import '@scope/pkg/sub/path'", - ])("leaves scoped package command %s to the plain-text paste fallback", (command) => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createEditor(); - const plainTextFallback = vi.fn((event: ClipboardEvent) => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) return false; - selection.insertText(event.clipboardData?.getData("text/plain") ?? ""); - return true; - }); - - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => - $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: () => 0, - }); - editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); - - const event = new TestClipboardEvent(command); - let handled = false; - editor.update( - () => { - handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - - expect(handled).toBe(true); - expect(plainTextFallback).toHaveBeenCalledOnce(); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(command); - }); - - it("pastes a canonical scoped folder link as a mention", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createEditor(); - const mention = "[sub](@scope/pkg/sub)"; - const plainTextFallback = vi.fn(() => true); - - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => - $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: () => 0, - }); - editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); - - const event = new TestClipboardEvent(mention); - let handled = false; - editor.update( - () => { - handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - - expect(handled).toBe(true); - expect(plainTextFallback).not.toHaveBeenCalled(); - expect(event.defaultPrevented).toBe(true); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - " ", - ); - }); - - it("restores multiple citation nodes beside punctuation and Unicode without changing their source", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor(); - const secondSource = serializeAssistantCitation({ - ...citation, - messageId: MessageId.make("message-2"), - text: "Another quote πŸ‘‹", - }); - const text = `前(${citationSource}),${secondSource}${citationSource}`; - const plainTextFallback = vi.fn(() => true); - editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); - - const event = pasteText(editor, text); - - expect(event.defaultPrevented).toBe(true); - expect(plainTextFallback).not.toHaveBeenCalled(); - editor.getEditorState().read(() => { - expect($getRoot().getTextContent()).toBe(text); - expect($citationNodes().map((node) => node.getTextContent())).toEqual([ - citationSource, - secondSource, - citationSource, - ]); - }); - }); - - it("restores citations alongside mentions and only pads a trailing mention", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor(); - - pasteText(editor, `${citationSource}\n[AGENTS.md](AGENTS.md) ${citationSource}`); - - editor.getEditorState().read(() => { - expect($getRoot().getTextContent()).toBe( - `${citationSource}\n ${citationSource}`, - ); - expect($citationNodes()).toHaveLength(2); - }); - - const trailingMentionEditor = createCitationEditor(); - pasteText(trailingMentionEditor, `${citationSource} [AGENTS.md](AGENTS.md)`); - - expect(trailingMentionEditor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - `${citationSource} `, - ); - }); - - it.each(["what do you mean??\nPlease keep the indentation:\n cafΓ© πŸ‘‹"])( - "keeps the edited comment %j bound to its quote through an editor reload and paste", - (comment) => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor(); - const commentedCitation = { ...citation, comment }; - const source = serializeAssistantCitation(commentedCitation); - pasteText(editor, `前(${citationSource})後`); - editor.update( - () => { - $citationNodes()[0]!.setComment(comment); - }, - { discrete: true }, - ); - - editor.getEditorState().read(() => { - expect($getRoot().getTextContent()).toBe(`前(${source})後`); - expect($citationNodes()[0]!.exportJSON().citation).toEqual(commentedCitation); - }); - - const restored = createCitationEditor(); - restored.setEditorState(restored.parseEditorState(editor.getEditorState().toJSON())); - restored.update( - () => { - const paragraph = $getRoot().getFirstChildOrThrow(); - if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); - paragraph.selectStart().insertText("About "); - }, - { discrete: true }, - ); - - restored.getEditorState().read(() => { - expect($getRoot().getTextContent()).toBe(`About 前(${source})後`); - expect($getRoot().getChildrenSize()).toBe(1); - expect($citationNodes()[0]!.exportJSON().citation).toEqual(commentedCitation); - }); - - const pasted = createCitationEditor(); - pasteText( - pasted, - restored.getEditorState().read(() => $getRoot().getTextContent()), - ); - pasted.getEditorState().read(() => { - expect($getRoot().getTextContent()).toBe(`About 前(${source})後`); - expect($citationNodes()[0]!.exportJSON().citation).toEqual(commentedCitation); - }); - }, - ); - - it("edits only one repeated quote without moving selection or mutating earlier editor states", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor(); - const prompt = `前(${citationSource}),(${citationSource})後`; - pasteText(editor, prompt); - editor.update( - () => { - $getRoot().getAllTextNodes().at(-1)!.select(2, 1); - }, - { discrete: true }, - ); - const beforeEdit = editor.getEditorState(); - const selection = beforeEdit.read(() => $getSelection()!.clone()); - const node = beforeEdit.read(() => $citationNodes()[1]!); - editor.update(() => node.setComment("First draft"), { discrete: true }); - const comment = "Use this occurrence, not the earlier quote."; - editor.update(() => node.setComment(comment), { discrete: true }); - const afterEdit = editor.getEditorState(); - const source = serializeAssistantCitation({ ...citation, comment }); - - afterEdit.read(() => { - expect($getRoot().getTextContent()).toBe(`前(${citationSource}),(${source})後`); - expect($citationNodes().map((citationNode) => citationNode.exportJSON().citation)).toEqual([ - citation, - { ...citation, comment }, - ]); - expect($getSelection()?.is(selection)).toBe(true); - }); - beforeEdit.read(() => { - expect($getRoot().getTextContent()).toBe(prompt); - expect($citationNodes().map((citationNode) => citationNode.exportJSON().citation)).toEqual([ - citation, - citation, - ]); - }); - - editor.setEditorState(beforeEdit); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(prompt); - editor.setEditorState(afterEdit); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - `前(${citationSource}),(${source})後`, - ); - }); - - it.each(["", " \n\t "])("clears a comment with %j without removing the quote", (comment) => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor(); - pasteText(editor, citationSource); - editor.update(() => $citationNodes()[0]!.setComment("Remove this comment."), { - discrete: true, - }); - editor.update(() => $citationNodes()[0]!.setComment(comment), { discrete: true }); - - editor.getEditorState().read(() => { - expect($citationNodes()).toHaveLength(1); - expect($citationNodes()[0]!.exportJSON().citation).toStrictEqual(citation); - expect($getRoot().getTextContent()).toBe(serializeAssistantCitation(citation)); - }); - }); - - it("keeps a citation pasted after an unfinished @ restorable as a chip", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor("@"); - - pasteText(editor, citationSource); - - const prompt = editor.getEditorState().read(() => $getRoot().getTextContent()); - expect(splitPromptIntoComposerSegments(prompt)).toEqual([ - { type: "text", text: "@" }, - { type: "citation", citation, source: citationSource }, - ]); - }); - - it("copies, cuts, and pastes selected citation nodes with all source metadata intact", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor(); - const firstCitation = { ...citation, comment: "Explain the first occurrence." }; - const secondCitation = { ...citation, comment: "Keep the second occurrence.\n cafΓ© πŸ‘‹" }; - const selectedText = `${serializeAssistantCitation(firstCitation)},${serializeAssistantCitation(secondCitation)}`; - pasteText(editor, `before ${citationSource},${citationSource} after`); - editor.update( - () => { - $citationNodes()[0]!.setComment(firstCitation.comment); - $citationNodes()[1]!.setComment(secondCitation.comment); - }, - { discrete: true }, - ); - let copied = ""; - editor.update( - () => { - const paragraph = $getRoot().getFirstChildOrThrow(); - if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); - paragraph.select(1, 4); - const selection = $getSelection(); - if (!$isRangeSelection(selection)) throw new Error("Expected a range selection"); - copied = selection.getTextContent(); - selection.removeText(); - }, - { discrete: true }, - ); - - expect(copied).toBe(selectedText); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe("before after"); - - pasteText(editor, copied); - - editor.getEditorState().read(() => { - expect($getRoot().getTextContent()).toBe(`before ${selectedText} after`); - expect($citationNodes().map((node) => node.exportJSON().citation)).toEqual([ - firstCitation, - secondCitation, - ]); - }); - }); - - it.each([true, false])( - "deletes a citation and its comment atomically, backwards=%s", - (backwards) => { - const editor = createCitationEditor(); - const commentedCitation = { ...citation, comment: "Delete this with its quote.\n cafΓ© πŸ‘‹" }; - const source = serializeAssistantCitation(commentedCitation); - editor.update( - () => { - const paragraph = $getRoot().getFirstChildOrThrow(); - if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); - const before = $createTextNode("前("); - const after = $createTextNode(")後"); - paragraph.append( - before, - $createComposerCitationNode(citation, citationSource).setComment( - commentedCitation.comment, - ), - after, - ); - if (backwards) { - after.selectStart(); - } else { - before.selectEnd(); - } - }, - { discrete: true }, - ); - const beforeDeletion = editor.getEditorState(); - const serialized = beforeDeletion.toJSON(); - - editor.update( - () => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) throw new Error("Expected a range selection"); - selection.deleteCharacter(backwards); - }, - { discrete: true }, - ); - - const afterDeletion = editor.getEditorState(); - expect(afterDeletion.read(() => $getRoot().getTextContent())).toBe("前()後"); - - editor.setEditorState(beforeDeletion); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - `前(${source})後`, - ); - editor.setEditorState(afterDeletion); - expect(editor.getEditorState().read(() => $citationNodes())).toHaveLength(0); - - editor.setEditorState(editor.parseEditorState(serialized)); - editor.getEditorState().read(() => { - expect($getRoot().getTextContent()).toBe(`前(${source})後`); - expect($citationNodes()[0]?.exportJSON().citation).toEqual(commentedCitation); - }); - }, - ); -}); - -describe("context reference paste", () => { - it.each([ - { focus: "focused", prefix: "" }, - { focus: "blurred", prefix: "" }, - { focus: "focused", prefix: "log ".repeat(10_000) }, - { focus: "blurred", prefix: "log ".repeat(10_000) }, - ])( - "imports structured paste when $focus with $prefix.length extra characters", - ({ focus, prefix }) => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createEditor({ nodes: [ComposerCitationNode] }); - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - const imported: string[] = []; - const importFragment = ( - fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, - ) => { - imported.push(...fragment.records.map((record) => record.contextId)); - return new Map([["img-old", "img-new"]]); - }; - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => - $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: () => 0, - importContextFragment: importFragment, - }); - const event = new TestClipboardEvent( - `${prefix}![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)`, - { - "web application/x-t3-context-fragment+json": JSON.stringify({ - version: 1, - source: { environmentId: "env-1" }, - records: [ - { - version: 1, - contextId: "img-old", - kind: "image", - label: "shot", - attachmentId: "a", - name: "shot.png", - mimeType: "image/png", - sizeBytes: 1, - }, - { - version: 1, - contextId: "ctx-t", - kind: "terminal", - label: "T", - terminalId: "t", - terminalLabel: "T", - lineStart: 1, - lineEnd: 1, - text: "x", - }, - { - version: 1, - contextId: "img-unrelated", - kind: "image", - label: "other", - attachmentId: "b", - name: "other.png", - mimeType: "image/png", - sizeBytes: 1, - }, - ], - }), - }, - ); - expect( - readPastedComposerContext(event.clipboardData)?.records.map((record) => record.contextId), - ).toEqual(["img-old", "ctx-t"]); - if (focus === "blurred") { - expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( - `${prefix}![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)`, - ); - expect(imported).toEqual(["img-old", "ctx-t"]); - return; - } - editor.update( - () => { - editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - expect(imported).toEqual(["img-old", "ctx-t"]); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - `${prefix} and `, - ); - }, - ); - - it("converts a copied legacy element into a sendable annotation and rewrites its link", () => { - const copied = upgradeLegacyContextMessage( - [ - "Fix this", - "", - "", - "- ", - " styles:", - " color: red;", - "", - ].join("\n"), - ); - const event = new TestClipboardEvent(copied.text, { - "web application/x-t3-context-fragment+json": JSON.stringify({ - version: 1, - source: { environmentId: "env-1" }, - records: copied.records, - }), - }); - const annotations: ReturnType[] = []; - const text = importPastedComposerText(event.clipboardData, (fragment) => { - const record = fragment.records[0]!; - if (record.kind !== "element" || "payload" in record) throw new Error("Expected element"); - const annotation = previewAnnotationContextRecord( - elementContextToPreviewAnnotation(record, "imported", "2026-01-01T00:00:00Z"), - ); - annotations.push(annotation); - return new Map([[record.contextId, annotation.contextId]]); - }); - expect(text).toContain("t3-context://v1/preview-annotation/preview-annotation_imported"); - expect(annotations[0]?.elements?.[0]).toMatchObject({ - selector: "#save", - htmlPreview: "", - styles: "color: red;", - }); - }); - - it("imports an annotation's dependent screenshot when only its chip is pasted", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createEditor({ nodes: [ComposerCitationNode] }); - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - const imported: string[] = []; - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => - $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: () => 0, - importContextFragment: (fragment) => { - imported.push(...fragment.records.map((record) => record.contextId)); - return new Map(); - }, - }); - const annotationId = "preview-annotation_ann-1"; - const screenshotId = "image_ann-1"; - const event = new TestClipboardEvent( - `[Fix button](t3-context://v1/preview-annotation/${annotationId})`, - { - "web application/x-t3-context-fragment+json": JSON.stringify({ - version: 1, - source: { environmentId: "env-1" }, - records: [ - { - version: 1, - contextId: annotationId, - kind: "preview-annotation", - label: "Fix button", - annotationId: "ann-1", - pageUrl: "https://example.com", - pageTitle: "Example", - comment: "Fix button", - targetSummary: "1 selected element", - styleChanges: [], - screenshotContextId: screenshotId, - }, - { - version: 1, - contextId: screenshotId, - kind: "image", - label: "annotation.png", - attachmentId: "attachment-1", - name: "annotation.png", - mimeType: "image/png", - sizeBytes: 10, - }, - { - version: 1, - contextId: "image_unrelated", - kind: "image", - label: "unrelated.png", - attachmentId: "attachment-2", - name: "unrelated.png", - mimeType: "image/png", - sizeBytes: 10, - }, - ], - }), - }, - ); - editor.update( - () => { - editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - - expect(imported).toEqual([annotationId, screenshotId]); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - ``, - ); - }); - - it("turns pasted context links into reference nodes", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor("see "); - pasteText(editor, "[Terminal 1 line 4](t3-context://v1/terminal/ctx-1) now"); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - "see now", - ); - }); -}); - -describe("citation comment opening", () => { - const sourceAnchor: AssistantCitationSourceAnchor = { - source: { nodeType: 1 } as HTMLElement, - range: { collapsed: false } as Range, - viewport: { nodeType: 1 } as HTMLElement, - }; - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("opens only the newly inserted occurrence after committing its exact prompt", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor(); - const previousValue = `前 ${citationSource} middle\nbefore after ${citationSource} 後`; - pasteText(editor, previousValue); - const citationStart = `前 ${citationSource} middle\nbefore `.length; - const value = - previousValue.slice(0, citationStart) + - `${citationSource} ` + - previousValue.slice(citationStart); - const request: ComposerCitationCommentRequest = { - previousValue, - value, - citationStart, - sourceAnchor, - }; - const requestRef = { current: request as ComposerCitationCommentRequest | null }; - - editor.getEditorState().read(() => { - expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); - expect(requestRef.current).toBe(request); - }); - editor.update( - () => { - const text = $getRoot() - .getAllTextNodes() - .find((node) => node.getTextContent() === "before after "); - if (!text) throw new Error("Expected insertion text"); - text.select("before ".length, "before ".length); - }, - { discrete: true }, - ); - pasteText(editor, `${citationSource} `); - - editor.getEditorState().read(() => { - const selection = $getSelection()?.clone(); - expect($getRoot().getTextContent()).toBe(value); - expect($citationNodes()).toHaveLength(3); - const target = $consumeComposerCitationCommentRequest(requestRef); - expect(target?.nodeKey).toBe($citationNodes()[1]!.getKey()); - expect(target?.sourceAnchor).toBe(sourceAnchor); - expect($getSelection()?.is(selection!)).toBe(true); - expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); - }); - }); - - it("does not reopen comments when an inserted citation is restored or pasted", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor("before "); - const previousState = editor.getEditorState(); - const value = `before ${citationSource} `; - const requestRef: { current: ComposerCitationCommentRequest | null } = { - current: { previousValue: "before ", value, citationStart: "before ".length, sourceAnchor }, - }; - pasteText(editor, `${citationSource} `); - const insertedState = editor.getEditorState(); - insertedState.read(() => { - expect($consumeComposerCitationCommentRequest(requestRef)?.nodeKey).toBe( - $citationNodes()[0]!.getKey(), - ); - }); - editor.update(() => $citationNodes()[0]!.setComment("Keep this attached."), { - discrete: true, - }); - const commentedState = editor.getEditorState(); - - for (const restoredState of [previousState, insertedState, commentedState]) { - editor.setEditorState(restoredState); - editor.getEditorState().read(() => { - expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); - }); - } - - const reloaded = createCitationEditor(); - reloaded.setEditorState(reloaded.parseEditorState(commentedState.toJSON())); - reloaded.getEditorState().read(() => { - expect($consumeComposerCitationCommentRequest({ current: null })).toBeNull(); - expect($citationNodes()[0]!.exportJSON().citation.comment).toBe("Keep this attached."); - }); - const pasted = createCitationEditor(); - pasteText( - pasted, - commentedState.read(() => $getRoot().getTextContent()), - ); - pasted.getEditorState().read(() => { - expect($consumeComposerCitationCommentRequest({ current: null })).toBeNull(); - expect($citationNodes()[0]!.exportJSON().citation.comment).toBe("Keep this attached."); - }); - }); - - it("discards an opening request if another prompt replaces the insertion", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor(); - const requestRef: { current: ComposerCitationCommentRequest | null } = { - current: { previousValue: "", value: citationSource, citationStart: 0, sourceAnchor }, - }; - editor.update( - () => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) throw new Error("Expected an insertion point"); - selection.insertText("A different draft"); - }, - { discrete: true }, - ); - editor.getEditorState().read(() => { - expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); - }); - editor.update( - () => { - const paragraph = $getRoot().getFirstChildOrThrow(); - if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); - paragraph.clear().selectStart(); - }, - { discrete: true }, - ); - pasteText(editor, citationSource); - editor.getEditorState().read(() => { - expect($getRoot().getTextContent()).toBe(citationSource); - expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); - }); - }); -}); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 3ba086e0e981e6db38ebf5a98b071f1ecaeca357..7e699b6e0304d48cefbf7b81a5950083af62619f 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1,2137 +1,17 @@ -import { ContextChipPopover } from "./contextChipParts"; -import { Button } from "./ui/button"; -import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { ContentEditable } from "@lexical/react/LexicalContentEditable"; -import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; -import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; -import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; -import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; -import { - type ComposerContextClipboardFragment, - type ServerProviderSkill, -} from "@t3tools/contracts"; -import { - COMPOSER_CONTEXT_CLIPBOARD_MIME, - encodeComposerContextClipboardHtml, -} from "@t3tools/shared/composerContextClipboard"; -import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { - $applyNodeReplacement, - $createRangeSelectionFromDom, - $createRangeSelection, - $getSelection, - $setSelection, - $isElementNode, - $isLineBreakNode, - $isRangeSelection, - $isTextNode, - $createLineBreakNode, - $createParagraphNode, - $createTextNode, - KEY_ARROW_DOWN_COMMAND, - KEY_ARROW_LEFT_COMMAND, - KEY_ARROW_RIGHT_COMMAND, - KEY_ARROW_UP_COMMAND, - KEY_DOWN_COMMAND, - KEY_ENTER_COMMAND, - KEY_TAB_COMMAND, - COMMAND_PRIORITY_HIGH, - COPY_COMMAND, - CUT_COMMAND, - COMMAND_PRIORITY_LOW, - KEY_BACKSPACE_COMMAND, - BLUR_COMMAND, - FOCUS_COMMAND, - $getRoot, - $getNodeByKey, - HISTORY_MERGE_TAG, - HISTORY_PUSH_TAG, - SKIP_DOM_SELECTION_TAG, - DecoratorNode, - type ElementNode, - type LexicalNode, - type SerializedLexicalNode, - type EditorState, - type NodeKey, - type Spread, -} from "lexical"; -import { - createContext, - use, - useCallback, - useEffect, - useEffectEvent, - useImperativeHandle, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { ComposerPromptEditorTiptap } from "./ComposerPromptEditorTiptap"; +import type { ComposerPromptEditorProps } from "./ComposerPromptEditorTiptap"; -import { - clampCollapsedComposerCursor, - collapseExpandedComposerCursor, - expandCollapsedComposerCursor, - isCollapsedCursorAdjacentToInlineToken, -} from "~/composer-logic"; -import { - selectionTouchesMentionBoundary, - splitPromptIntoComposerSegments, -} from "~/composer-editor-mentions"; -import { collectInlineContextIds } from "~/lib/composerContextReferences"; -import { cn, isMacPlatform } from "~/lib/utils"; -import { basenameOfPath } from "~/pierre-icons"; -import { - COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, - COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, - COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, - SKILL_CHIP_ICON_SVG, -} from "./composerInlineChip"; -import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; -import { getTimelinePageScrollKey } from "./chat/pageScrollController"; -import { - $createComposerContextReferenceNode, - ComposerContextReferenceNode, -} from "./ComposerContextReferenceNode"; -import { - ComposerContextActionsContext, - ComposerContextRecordsContext, - type ComposerDraftContextRecords, -} from "./composerContextPresentation"; -import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; -import { didComposerSelectionChangeVisibly } from "./composerSelection"; -import { - $consumeComposerCitationCommentRequest, - $createComposerCitationNode, - ComposerCitationCommentContext, - ComposerCitationNode, - type ComposerCitationCommentRequest, - type ComposerCitationCommentTarget, -} from "./ComposerCitationNode"; - -const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; -const SURROUND_SYMBOLS: [string, string][] = [ - ["(", ")"], - ["[", "]"], - ["{", "}"], - ["'", "'"], - ['"', '"'], - ["β€œ", "”"], - ["`", "`"], - ["<", ">"], - ["Β«", "Β»"], - ["*", "*"], - ["_", "_"], -]; -const SURROUND_SYMBOLS_MAP = new Map(SURROUND_SYMBOLS); -const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; - -type SerializedComposerMentionNode = Spread< - { - path: string; - source?: string; - type: "composer-mention"; - version: 1; - }, - SerializedLexicalNode ->; - -type SerializedComposerSkillNode = Spread< - { - skillName: string; - skillLabel?: string; - skillDescription?: string; - type: "composer-skill"; - version: 1; - }, - SerializedLexicalNode ->; - -function ComposerMentionDecorator(props: { path: string }) { - const actions = use(ComposerContextActionsContext); - const theme = resolvedThemeFromDocument(); - const chip = ( - - ); - - return ( - - - - {props.path} - - - ); -} - -class ComposerMentionNode extends DecoratorNode { - __path: string; - __source: string; - - static override getType(): string { - return "composer-mention"; - } - - static override clone(node: ComposerMentionNode): ComposerMentionNode { - return new ComposerMentionNode(node.__path, node.__source, node.__key); - } - - static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { - return $createComposerMentionNode(serializedNode.path, serializedNode.source).updateFromJSON( - serializedNode, - ); - } - - constructor(path: string, source = serializeComposerFileLink(path), key?: NodeKey) { - super(key); - this.__path = path; - this.__source = source; - } - - override exportJSON(): SerializedComposerMentionNode { - return { - ...super.exportJSON(), - path: this.__path, - source: this.__source, - type: "composer-mention", - version: 1, - }; - } - - override createDOM(): HTMLElement { - const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; - return dom; - } - - override updateDOM(): false { - return false; - } - - override getTextContent(): string { - return this.__source; - } - - override isInline(): true { - return true; - } - - override decorate(): React.ReactElement { - return ; - } -} - -function $createComposerMentionNode(path: string, source?: string): ComposerMentionNode { - return $applyNodeReplacement(new ComposerMentionNode(path, source)); -} - -function resolveSkillDescription( - skill: Pick, -): string | null { - const shortDescription = skill.shortDescription?.trim(); - if (shortDescription) { - return shortDescription; - } - const description = skill.description?.trim(); - return description || null; -} - -type ComposerSkillMetadata = { - label: string; - description: string | null; -}; - -function skillMetadataByName( - skills: ReadonlyArray, -): ReadonlyMap { - return new Map( - skills.map((skill) => [ - skill.name, - { - label: formatProviderSkillDisplayName(skill), - description: resolveSkillDescription(skill), - }, - ]), - ); -} - -const ComposerSkillsContext = createContext>([]); - -function ComposerSkillDecorator(props: { - skillName: string; - skillLabel: string; - skillDescription: string | null; -}) { - const actions = use(ComposerContextActionsContext); - const skill = use(ComposerSkillsContext).find((candidate) => candidate.name === props.skillName); - return ( - - - ); -} - -class ComposerSkillNode extends DecoratorNode { - __skillName: string; - __skillLabel: string; - __skillDescription: string | null; - - static override getType(): string { - return "composer-skill"; - } - - static override clone(node: ComposerSkillNode): ComposerSkillNode { - return new ComposerSkillNode( - node.__skillName, - node.__skillLabel, - node.__skillDescription, - node.__key, - ); - } - - static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode { - return $createComposerSkillNode( - serializedNode.skillName, - serializedNode.skillLabel ?? serializedNode.skillName, - serializedNode.skillDescription ?? null, - ).updateFromJSON(serializedNode); - } - - constructor( - skillName: string, - skillLabel: string, - skillDescription: string | null, - key?: NodeKey, - ) { - super(key); - const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName; - this.__skillName = normalizedSkillName; - this.__skillLabel = skillLabel; - this.__skillDescription = skillDescription; - } - - override exportJSON(): SerializedComposerSkillNode { - return { - ...super.exportJSON(), - skillName: this.__skillName, - skillLabel: this.__skillLabel, - ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}), - type: "composer-skill", - version: 1, - }; - } - - override createDOM(): HTMLElement { - const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; - return dom; - } - - override updateDOM(): false { - return false; - } - - override getTextContent(): string { - return `$${this.__skillName}`; - } - - override isInline(): true { - return true; - } - - override decorate(): React.ReactElement { - return ( - - ); - } -} - -function $createComposerSkillNode( - skillName: string, - skillLabel: string, - skillDescription: string | null, -): ComposerSkillNode { - return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); -} - -type ComposerInlineTokenNode = - | ComposerMentionNode - | ComposerSkillNode - | ComposerCitationNode - | ComposerContextReferenceNode; - -function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { - return ( - candidate instanceof ComposerMentionNode || - candidate instanceof ComposerSkillNode || - candidate instanceof ComposerCitationNode || - candidate instanceof ComposerContextReferenceNode - ); -} - -function resolvedThemeFromDocument(): "light" | "dark" { - return document.documentElement.classList.contains("dark") ? "dark" : "light"; -} - -function skillSignature(skills: ReadonlyArray): string { - return skills - .map((skill) => - [ - skill.name, - skill.displayName ?? "", - skill.shortDescription ?? "", - skill.description ?? "", - skill.path, - skill.scope ?? "", - skill.enabled ? "1" : "0", - ].join("\u001f"), - ) - .join("\u001e"); -} - -function clampExpandedCursor(value: string, cursor: number): number { - if (!Number.isFinite(cursor)) return value.length; - return Math.max(0, Math.min(value.length, Math.floor(cursor))); -} - -function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 { - return 1; -} - -function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number { - return node.getTextContentSize(); -} - -function getAbsoluteOffsetForInlineTokenPoint( - node: ComposerInlineTokenNode, - absoluteOffset: number, - pointOffset: number, -): number { - return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0); -} - -function getExpandedAbsoluteOffsetForInlineTokenPoint( - node: ComposerInlineTokenNode, - absoluteOffset: number, - pointOffset: number, -): number { - return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0); -} - -function findSelectionPointForInlineToken( - node: ComposerInlineTokenNode, - remainingRef: { value: number }, -): { key: string; offset: number; type: "element" } | null { - const parent = node.getParent(); - if (!parent || !$isElementNode(parent)) return null; - const index = node.getIndexWithinParent(); - if (remainingRef.value === 0) { - return { - key: parent.getKey(), - offset: index, - type: "element", - }; - } - if (remainingRef.value === getComposerInlineTokenTextLength(node)) { - return { - key: parent.getKey(), - offset: index + 1, - type: "element", - }; - } - remainingRef.value -= getComposerInlineTokenTextLength(node); - return null; -} - -function getComposerNodeTextLength(node: LexicalNode): number { - if (isComposerInlineTokenNode(node)) { - return getComposerInlineTokenTextLength(node); - } - if ($isTextNode(node)) { - return node.getTextContentSize(); - } - if ($isLineBreakNode(node)) { - return 1; - } - if ($isElementNode(node)) { - return node.getChildren().reduce((total, child) => total + getComposerNodeTextLength(child), 0); - } - return 0; -} - -function getComposerNodeExpandedTextLength(node: LexicalNode): number { - if (isComposerInlineTokenNode(node)) { - return getComposerInlineTokenExpandedTextLength(node); - } - if ($isTextNode(node)) { - return node.getTextContentSize(); - } - if ($isLineBreakNode(node)) { - return 1; - } - if ($isElementNode(node)) { - return node - .getChildren() - .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0); - } - return 0; -} - -function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { - let offset = 0; - let current: LexicalNode | null = node; - - while (current) { - const nextParent = current.getParent() as LexicalNode | null; - if (!nextParent || !$isElementNode(nextParent)) { - break; - } - const siblings = nextParent.getChildren(); - const index = current.getIndexWithinParent(); - for (let i = 0; i < index; i += 1) { - const sibling = siblings[i]; - if (!sibling) continue; - offset += getComposerNodeTextLength(sibling); - } - current = nextParent; - } - - if ($isTextNode(node)) { - return offset + Math.min(pointOffset, node.getTextContentSize()); - } - if (isComposerInlineTokenNode(node)) { - return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } - - if ($isLineBreakNode(node)) { - return offset + Math.min(pointOffset, 1); - } - - if ($isElementNode(node)) { - const children = node.getChildren(); - const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); - for (let i = 0; i < clampedOffset; i += 1) { - const child = children[i]; - if (!child) continue; - offset += getComposerNodeTextLength(child); - } - return offset; - } - - return offset; -} - -function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { - let offset = 0; - let current: LexicalNode | null = node; - - while (current) { - const nextParent = current.getParent() as LexicalNode | null; - if (!nextParent || !$isElementNode(nextParent)) { - break; - } - const siblings = nextParent.getChildren(); - const index = current.getIndexWithinParent(); - for (let i = 0; i < index; i += 1) { - const sibling = siblings[i]; - if (!sibling) continue; - offset += getComposerNodeExpandedTextLength(sibling); - } - current = nextParent; - } - - if ($isTextNode(node)) { - return offset + Math.min(pointOffset, node.getTextContentSize()); - } - if (isComposerInlineTokenNode(node)) { - return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } - - if ($isLineBreakNode(node)) { - return offset + Math.min(pointOffset, 1); - } - - if ($isElementNode(node)) { - const children = node.getChildren(); - const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); - for (let i = 0; i < clampedOffset; i += 1) { - const child = children[i]; - if (!child) continue; - offset += getComposerNodeExpandedTextLength(child); - } - return offset; - } - - return offset; -} - -function findSelectionPointAtOffset( - node: LexicalNode, - remainingRef: { value: number }, -): { key: string; offset: number; type: "text" | "element" } | null { - if (isComposerInlineTokenNode(node)) { - return findSelectionPointForInlineToken(node, remainingRef); - } - - if ($isTextNode(node)) { - const size = node.getTextContentSize(); - if (remainingRef.value <= size) { - return { - key: node.getKey(), - offset: remainingRef.value, - type: "text", - }; - } - remainingRef.value -= size; - return null; - } - - if ($isLineBreakNode(node)) { - const parent = node.getParent(); - if (!parent) return null; - const index = node.getIndexWithinParent(); - if (remainingRef.value === 0) { - return { - key: parent.getKey(), - offset: index, - type: "element", - }; - } - if (remainingRef.value === 1) { - return { - key: parent.getKey(), - offset: index + 1, - type: "element", - }; - } - remainingRef.value -= 1; - return null; - } - - if ($isElementNode(node)) { - const children = node.getChildren(); - for (const child of children) { - const point = findSelectionPointAtOffset(child, remainingRef); - if (point) { - return point; - } - } - if (remainingRef.value === 0) { - return { - key: node.getKey(), - offset: children.length, - type: "element", - }; - } - } - - return null; -} - -function $getComposerRootLength(): number { - const root = $getRoot(); - const children = root.getChildren(); - return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0); -} - -function $setSelectionAtComposerOffset(nextOffset: number): void { - const root = $getRoot(); - const composerLength = $getComposerRootLength(); - const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength)); - const remainingRef = { value: boundedOffset }; - const point = findSelectionPointAtOffset(root, remainingRef) ?? { - key: root.getKey(), - offset: root.getChildren().length, - type: "element" as const, - }; - const selection = $createRangeSelection(); - selection.anchor.set(point.key, point.offset, point.type); - selection.focus.set(point.key, point.offset, point.type); - $setSelection(selection); -} - -function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: number): void { - const root = $getRoot(); - const composerLength = $getComposerRootLength(); - const boundedStart = Math.max(0, Math.min(startOffset, composerLength)); - const boundedEnd = Math.max(0, Math.min(endOffset, composerLength)); - const anchorRemainingRef = { value: boundedStart }; - const focusRemainingRef = { value: boundedEnd }; - const anchorPoint = findSelectionPointAtOffset(root, anchorRemainingRef) ?? { - key: root.getKey(), - offset: root.getChildren().length, - type: "element" as const, - }; - const focusPoint = findSelectionPointAtOffset(root, focusRemainingRef) ?? { - key: root.getKey(), - offset: root.getChildren().length, - type: "element" as const, - }; - const selection = $createRangeSelection(); - selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type); - selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type); - $setSelection(selection); -} - -function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType): { - start: number; - end: number; -} | null { - if (!$isRangeSelection(selection)) { - return null; - } - const anchorNode = selection.anchor.getNode(); - const focusNode = selection.focus.getNode(); - const anchorOffset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); - const focusOffset = getExpandedAbsoluteOffsetForPoint(focusNode, selection.focus.offset); - return { - start: Math.min(anchorOffset, focusOffset), - end: Math.max(anchorOffset, focusOffset), - }; -} - -function $selectionTouchesInlineToken(selection: ReturnType): boolean { - if (!$isRangeSelection(selection)) { - return false; - } - return selection.getNodes().some((node) => isComposerInlineTokenNode(node)); -} - -function $readSelectionOffsetFromEditorState(fallback: number): number { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return fallback; - } - const anchorNode = selection.anchor.getNode(); - const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); - const composerLength = $getComposerRootLength(); - return Math.max(0, Math.min(offset, composerLength)); -} - -function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return fallback; - } - const anchorNode = selection.anchor.getNode(); - const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); - const expandedLength = $getRoot().getTextContent().length; - return Math.max(0, Math.min(offset, expandedLength)); -} - -function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { - const lines = text.split("\n"); - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? ""; - if (line.length > 0) { - parent.append($createTextNode(line)); - } - if (index < lines.length - 1) { - parent.append($createLineBreakNode()); - } - } -} - -function $setComposerEditorPrompt( - prompt: string, - skillMetadata: ReadonlyMap, -): void { - const root = $getRoot(); - root.clear(); - const paragraph = $createParagraphNode(); - root.append(paragraph); - - const segments = splitPromptIntoComposerSegments(prompt); - for (const segment of segments) { - if (segment.type === "citation") { - paragraph.append($createComposerCitationNode(segment.citation, segment.source)); - continue; - } - if (segment.type === "mention") { - paragraph.append($createComposerMentionNode(segment.path, segment.source)); - continue; - } - if (segment.type === "skill") { - const metadata = skillMetadata.get(segment.name); - paragraph.append( - $createComposerSkillNode( - segment.name, - metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }), - metadata?.description ?? null, - ), - ); - continue; - } - if (segment.type === "context-reference") { - paragraph.append( - $createComposerContextReferenceNode({ - kind: segment.kind, - contextId: segment.contextId, - label: segment.label, - }), - ); - continue; - } - $appendTextWithLineBreaks(paragraph, segment.text); - } -} - -function collectContextIdOccurrences(node: LexicalNode): string[] { - if (node instanceof ComposerContextReferenceNode) { - return [node.__contextId]; - } - if ($isElementNode(node)) { - return node.getChildren().flatMap((child) => collectContextIdOccurrences(child)); - } - return []; -} - -/** Payload ids referenced by the document, once each in first-occurrence order. */ -function collectContextIds(node: LexicalNode): string[] { - return Array.from(new Set(collectContextIdOccurrences(node))); -} - -export interface ComposerPromptEditorHandle { - focus: () => void; - focusAt: (cursor: number) => void; - focusAtEnd: () => void; - readSelectionRange: () => { start: number; end: number }; - requestCitationComment: (request: ComposerCitationCommentRequest) => void; - readSnapshot: () => { - value: string; - cursor: number; - expandedCursor: number; - contextIds: string[]; - }; - /** - * True when a collapsed caret sits on the first ("start") or last ("end") - * visual line, counting soft wraps. Prompt history only claims ArrowUp and - * ArrowDown at these edges so arrows still move the caret inside multiline - * text. - */ - isCaretOnVisualEdge: (edge: "start" | "end") => boolean; -} - -interface ComposerPromptEditorProps { - value: string; - cursor: number; - /** Draft records behind the prompt's context references, keyed by context id. */ - contextRecords: ComposerDraftContextRecords; - /** Structured clipboard payload for the given referenced ids, or null to skip. */ - buildContextClipboardFragment?: - | ((contextIds: ReadonlyArray) => string | null) - | undefined; - /** Imports a structured paste's records; returns ids that changed. */ - importContextFragment?: - | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap) - | undefined; - skills: ReadonlyArray; - disabled: boolean; - placeholder: string; - containerClassName?: string; - className?: string; - placeholderClassName?: string; - onChange: ( - nextValue: string, - nextCursor: number, - expandedCursor: number, - cursorAdjacentToMention: boolean, - contextIds: string[], - ) => void; - onVisibleSelectionChange?: () => void; - onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent, - ) => boolean; - onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; - onPageScrollKeyUp?: (key: string) => void; - onPageScrollRelease?: () => void; - onCitationSubmitAndSend?: () => void; - onPaste: React.ClipboardEventHandler; - editorRef: React.RefObject; -} +export type { + ComposerCitationCommentRequest, + ComposerPromptEditorHandle, + ComposerPromptEditorProps, +} from "./ComposerPromptEditorTiptap"; /** - * Client rect of the line the collapsed caret is on, as seen from `edge`. - * A caret at a soft-wrap boundary belongs to two visual lines and the - * range reports a rect for each, so take the one farthest from the edge - * under test: an ambiguous caret then never claims the key and the arrow - * moves the caret as usual. A collapsed range reports zero-height rects at - * some positions, so probe the adjacent character on the same side. When - * the range container is the paragraph itself (an empty line, or a caret - * beside an inline chip) measure the child next to the caret before - * falling back to the paragraph. + * The composer editor. Tiptap in both modes: the `richTextEnabled` setting + * toggles Markdown styling, never the engine. Plain mode renders every + * marker as a literal character and serializes byte-identically. */ -function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { - const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); - const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; - if (collapsedRect) return collapsedRect; - - const container = range.startContainer; - if (container.nodeType === Node.TEXT_NODE) { - const textNode = container as Text; - if (textNode.data.length === 0) return null; - const probeStart = Math.max( - 0, - Math.min( - edge === "start" ? range.startOffset : range.startOffset - 1, - textNode.data.length - 1, - ), - ); - const probeRange = document.createRange(); - probeRange.setStart(textNode, probeStart); - probeRange.setEnd(textNode, probeStart + 1); - const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); - if (probeRect) return probeRect; - const boundingRect = probeRange.getBoundingClientRect(); - return boundingRect.height > 0 ? boundingRect : null; - } - - if (!(container instanceof HTMLElement)) return null; - // The caret sits between the paragraph's children, which is where Lexical - // puts it next to an inline chip. Measure the neighbouring child. - const neighbour = - container.childNodes[Math.max(0, range.startOffset - 1)] ?? - container.childNodes[range.startOffset]; - if (neighbour instanceof HTMLElement) { - const neighbourRect = neighbour.getBoundingClientRect(); - if (neighbourRect.height > 0) return neighbourRect; - } else if (neighbour instanceof Text && neighbour.data.length > 0) { - // Probe the character on the caret's side. A soft-wrapped text node's - // first rect is its first visual line, which may not be the caret's. - const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; - const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; - const probeRange = document.createRange(); - probeRange.setStart(neighbour, probeStart); - probeRange.setEnd(neighbour, probeStart + 1); - const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); - if (probeRect) return probeRect; - } - const containerRect = container.getBoundingClientRect(); - return containerRect.height > 0 ? containerRect : null; -} - -function ComposerCommandKeyPlugin(props: { - onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent, - ) => boolean; -}) { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - const handleCommand = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent | null, - ): boolean => { - if (!props.onCommandKeyDown || !event) { - return false; - } - - if (key === "Enter" && (event.isComposing || event.keyCode === 229)) { - event.stopPropagation(); - return true; - } - - const handled = props.onCommandKeyDown(key, event); - if (handled) { - event.preventDefault(); - event.stopPropagation(); - } - return handled; - }; - - const unregisterArrowDown = editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - (event) => handleCommand("ArrowDown", event), - COMMAND_PRIORITY_HIGH, - ); - const unregisterArrowUp = editor.registerCommand( - KEY_ARROW_UP_COMMAND, - (event) => handleCommand("ArrowUp", event), - COMMAND_PRIORITY_HIGH, - ); - const unregisterEnter = editor.registerCommand( - KEY_ENTER_COMMAND, - (event) => handleCommand("Enter", event), - COMMAND_PRIORITY_HIGH, - ); - const unregisterTab = editor.registerCommand( - KEY_TAB_COMMAND, - (event) => handleCommand("Tab", event), - COMMAND_PRIORITY_HIGH, - ); - - return () => { - unregisterArrowDown(); - unregisterArrowUp(); - unregisterEnter(); - unregisterTab(); - }; - }, [editor, props]); - - return null; -} - -function ComposerInlineTokenArrowPlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - const unregisterLeft = editor.registerCommand( - KEY_ARROW_LEFT_COMMAND, - (event) => { - let nextOffset: number | null = null; - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - const currentOffset = $readSelectionOffsetFromEditorState(0); - if (currentOffset <= 0) return; - const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { - return; - } - nextOffset = currentOffset - 1; - }); - if (nextOffset === null) return false; - const selectionOffset = nextOffset; - event?.preventDefault(); - event?.stopPropagation(); - editor.update(() => { - $setSelectionAtComposerOffset(selectionOffset); - }); - return true; - }, - COMMAND_PRIORITY_HIGH, - ); - const unregisterRight = editor.registerCommand( - KEY_ARROW_RIGHT_COMMAND, - (event) => { - let nextOffset: number | null = null; - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - const currentOffset = $readSelectionOffsetFromEditorState(0); - const composerLength = $getComposerRootLength(); - if (currentOffset >= composerLength) return; - const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { - return; - } - nextOffset = currentOffset + 1; - }); - if (nextOffset === null) return false; - const selectionOffset = nextOffset; - event?.preventDefault(); - event?.stopPropagation(); - editor.update(() => { - $setSelectionAtComposerOffset(selectionOffset); - }); - return true; - }, - COMMAND_PRIORITY_HIGH, - ); - return () => { - unregisterLeft(); - unregisterRight(); - }; - }, [editor]); - - return null; -} - -function ComposerHomeEndKeyPlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerCommand( - KEY_DOWN_COMMAND, - (event) => { - if (!isMacPlatform(navigator.platform)) { - return false; - } - if (event.key !== "Home" && event.key !== "End") { - return false; - } - if (event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { - return false; - } - - const rootElement = editor.getRootElement(); - const selection = window.getSelection(); - const anchorNode = selection?.anchorNode; - if (!rootElement || !selection || !anchorNode || !rootElement.contains(anchorNode)) { - return false; - } - if (selection.rangeCount === 0 || typeof selection.modify !== "function") { - return false; - } - - event.preventDefault(); - event.stopPropagation(); - - selection.modify( - event.shiftKey ? "extend" : "move", - event.key === "Home" ? "backward" : "forward", - "lineboundary", - ); - editor.update(() => { - $setSelection($createRangeSelectionFromDom(selection, editor)); - }); - return true; - }, - COMMAND_PRIORITY_HIGH, - ); - }, [editor]); - - return null; -} - -function ComposerInlineTokenSelectionNormalizePlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerUpdateListener(({ editorState }) => { - let afterOffset: number | null = null; - editorState.read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - const anchorNode = selection.anchor.getNode(); - if (!isComposerInlineTokenNode(anchorNode)) return; - if (selection.anchor.offset === 0) return; - const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0); - afterOffset = beforeOffset + 1; - }); - if (afterOffset !== null) { - queueMicrotask(() => { - editor.update(() => { - $setSelectionAtComposerOffset(afterOffset!); - }); - }); - } - }); - }, [editor]); - - return null; -} - -function ComposerInlineTokenBackspacePlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerCommand( - KEY_BACKSPACE_COMMAND, - (event) => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return false; - } - - const anchorNode = selection.anchor.getNode(); - const removeInlineTokenNode = (candidate: unknown): boolean => { - if (!isComposerInlineTokenNode(candidate)) { - return false; - } - const tokenStart = getAbsoluteOffsetForPoint(candidate, 0); - candidate.remove(); - $setSelectionAtComposerOffset(tokenStart); - event?.preventDefault(); - return true; - }; - if (removeInlineTokenNode(anchorNode)) { - return true; - } - - if ($isTextNode(anchorNode)) { - if (selection.anchor.offset > 0) { - return false; - } - if (removeInlineTokenNode(anchorNode.getPreviousSibling())) { - return true; - } - const parent = anchorNode.getParent(); - if ($isElementNode(parent)) { - const index = anchorNode.getIndexWithinParent(); - if (index > 0 && removeInlineTokenNode(parent.getChildAtIndex(index - 1))) { - return true; - } - } - return false; - } - - if ($isElementNode(anchorNode)) { - const childIndex = selection.anchor.offset - 1; - if (childIndex >= 0 && removeInlineTokenNode(anchorNode.getChildAtIndex(childIndex))) { - return true; - } - } - - return false; - }, - COMMAND_PRIORITY_HIGH, - ); - }, [editor]); - - return null; -} - -/** - * Chips render as non-editable decorators, so the browser never paints the - * native text selection over them; without help, a selection spanning chips - * is only visible in the slivers between them. Mirror the selection onto the - * chips with a data attribute the stylesheet turns into a highlight overlay. - */ -function ComposerChipSelectionPlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - let selectedKeys = new Set(); - // Lexical keeps the range selection on blur without emitting an update, - // so focus is tracked separately; while blurred the native highlight is - // gone and the mirrored one has to go with it. - let hasFocus = editor.getRootElement() === document.activeElement; - - const applyKeys = (nextKeys: Set) => { - for (const key of selectedKeys) { - if (!nextKeys.has(key)) { - editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected"); - } - } - for (const key of nextKeys) { - editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true"); - } - selectedKeys = nextKeys; - }; - - const readSelectedKeys = () => { - const nextKeys = new Set(); - editor.getEditorState().read(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection) && !selection.isCollapsed()) { - for (const node of selection.getNodes()) { - if (node instanceof DecoratorNode) { - nextKeys.add(node.getKey()); - } - } - } - }); - return nextKeys; - }; - - const unregisterUpdate = editor.registerUpdateListener(() => { - applyKeys(hasFocus ? readSelectedKeys() : new Set()); - }); - const unregisterFocus = editor.registerCommand( - FOCUS_COMMAND, - () => { - hasFocus = true; - applyKeys(readSelectedKeys()); - return false; - }, - COMMAND_PRIORITY_LOW, - ); - const unregisterBlur = editor.registerCommand( - BLUR_COMMAND, - () => { - hasFocus = false; - applyKeys(new Set()); - return false; - }, - COMMAND_PRIORITY_LOW, - ); - return () => { - unregisterUpdate(); - unregisterFocus(); - unregisterBlur(); - }; - }, [editor]); - - return null; -} - -function ComposerInlineTokenPastePlugin(props: { - importContextFragment?: ComposerPromptEditorProps["importContextFragment"]; -}) { - const [editor] = useLexicalComposerContext(); - const importContextFragment = props.importContextFragment; - - useEffect( - () => - registerComposerInlineTokenPaste(editor, { - createMentionNode: $createComposerMentionNode, - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: $createComposerContextReferenceNode, - getExpandedAbsoluteOffsetForPoint, - ...(importContextFragment ? { importContextFragment } : {}), - }), - [editor, importContextFragment], - ); - - return null; -} - -/** - * Copying chips must carry their payloads: the default copy writes the canonical links as - * text, and this adds the structured fragment for the referenced records beside it. - */ -function ComposerContextClipboardPlugin(props: { - buildContextClipboardFragment?: ComposerPromptEditorProps["buildContextClipboardFragment"]; -}) { - const [editor] = useLexicalComposerContext(); - const build = props.buildContextClipboardFragment; - - useEffect(() => { - if (!build) return; - const listener = (event: ClipboardEvent | KeyboardEvent | null, cut: boolean) => { - if (!event || !("clipboardData" in event) || !event.clipboardData) return false; - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) return false; - const text = selection.getTextContent(); - const contextIds = collectInlineContextIds(text); - if (contextIds.length === 0) return false; - const fragment = build(contextIds); - if (!fragment) return false; - event.preventDefault(); - event.clipboardData.setData("text/plain", text); - event.clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment); - event.clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); - if (cut) selection.removeText(); - return true; - }; - const unregisterCopy = editor.registerCommand( - COPY_COMMAND, - (event) => listener(event, false), - COMMAND_PRIORITY_HIGH, - ); - const unregisterCut = editor.registerCommand( - CUT_COMMAND, - (event) => listener(event, true), - COMMAND_PRIORITY_HIGH, - ); - return () => { - unregisterCopy(); - unregisterCut(); - }; - }, [build, editor]); - - return null; -} - -function ComposerSurroundSelectionPlugin(props: { skills: ReadonlyArray }) { - const [editor] = useLexicalComposerContext(); - const skillMetadataRef = useRef(skillMetadataByName(props.skills)); - const pendingSurroundSelectionRef = useRef<{ - value: string; - expandedStart: number; - expandedEnd: number; - } | null>(null); - const pendingDeadKeySelectionRef = useRef<{ - value: string; - expandedStart: number; - expandedEnd: number; - } | null>(null); - - useEffect(() => { - skillMetadataRef.current = skillMetadataByName(props.skills); - }, [props.skills]); - - const applySurroundInsertion = useEffectEvent((inputData: string): boolean => { - const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); - const pendingSurroundSelection = pendingSurroundSelectionRef.current; - if (!surroundCloseSymbol) { - pendingSurroundSelectionRef.current = null; - return false; - } - - let handled = false; - editor.update(() => { - const selectionSnapshot = - pendingSurroundSelection ?? - (() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - return null; - } - if ($selectionTouchesInlineToken(selection)) { - return null; - } - const range = getSelectionRangeForExpandedComposerOffsets(selection); - if (!range || range.start === range.end) { - return null; - } - const value = $getRoot().getTextContent(); - if (selectionTouchesMentionBoundary(value, range.start, range.end)) { - return null; - } - return { - value, - expandedStart: range.start, - expandedEnd: range.end, - }; - })(); - - if (!selectionSnapshot || !surroundCloseSymbol) { - return; - } - - const selectedText = selectionSnapshot.value.slice( - selectionSnapshot.expandedStart, - selectionSnapshot.expandedEnd, - ); - const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt(nextValue, skillMetadataRef.current); - const selectionStart = collapseExpandedComposerCursor( - nextValue, - selectionSnapshot.expandedStart, - ); - $setSelectionRangeAtComposerOffsets( - selectionStart + inputData.length, - selectionStart + inputData.length + selectedText.length, - ); - handled = true; - pendingSurroundSelectionRef.current = null; - }); - - return handled; - }); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (pendingDeadKeySelectionRef.current) { - if (event.key === "Dead" || event.key === " " || event.code === "Space") { - return; - } - pendingDeadKeySelectionRef.current = null; - } - - if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - if ($selectionTouchesInlineToken(selection)) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - const range = getSelectionRangeForExpandedComposerOffsets(selection); - if (!range || range.start === range.end) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - const value = $getRoot().getTextContent(); - if (selectionTouchesMentionBoundary(value, range.start, range.end)) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - const snapshot = { - value, - expandedStart: range.start, - expandedEnd: range.end, - }; - pendingSurroundSelectionRef.current = snapshot; - pendingDeadKeySelectionRef.current = null; - }); - }; - - const onBeforeInput = (event: InputEvent) => { - if ( - event.inputType === "insertCompositionText" && - event.data === "`" && - BACKTICK_SURROUND_CLOSE_SYMBOL !== null && - pendingSurroundSelectionRef.current - ) { - pendingDeadKeySelectionRef.current = pendingSurroundSelectionRef.current; - return; - } - - if (pendingDeadKeySelectionRef.current) { - return; - } - - if (event.inputType === "insertCompositionText") { - return; - } - - if (typeof event.data !== "string") { - pendingSurroundSelectionRef.current = null; - return; - } - const inputData = event.inputType === "insertText" ? event.data : null; - if (!inputData || inputData.length !== 1) { - pendingSurroundSelectionRef.current = null; - return; - } - if (!applySurroundInsertion(inputData)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - }; - - const tryApplyDeadKeyBacktickSurround = (options?: { finalAttempt?: boolean }) => { - queueMicrotask(() => { - editor.update( - () => { - const pendingDeadKeySelection = pendingDeadKeySelectionRef.current; - if (!pendingDeadKeySelection) { - return; - } - - const currentValue = $getRoot().getTextContent(); - const backtickCloseSymbol = BACKTICK_SURROUND_CLOSE_SYMBOL; - if (backtickCloseSymbol === null) { - pendingDeadKeySelectionRef.current = null; - return; - } - - const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.expandedStart)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.expandedEnd)}`; - if (currentValue !== expectedResolvedValue) { - if (options?.finalAttempt) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - } - return; - } - - const selectedText = pendingDeadKeySelection.value.slice( - pendingDeadKeySelection.expandedStart, - pendingDeadKeySelection.expandedEnd, - ); - const replacementStart = collapseExpandedComposerCursor( - currentValue, - pendingDeadKeySelection.expandedStart, - ); - $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1); - const replacementSelection = $getSelection(); - if (!$isRangeSelection(replacementSelection)) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`); - $setSelectionRangeAtComposerOffsets( - replacementStart + 1, - replacementStart + 1 + selectedText.length, - ); - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - }, - { tag: HISTORY_MERGE_TAG }, - ); - }); - }; - - const onInput = (event: Event) => { - const inputEvent = event as InputEvent; - if ( - inputEvent.inputType === "insertText" || - inputEvent.inputType === "insertCompositionText" - ) { - tryApplyDeadKeyBacktickSurround(); - } - }; - - const onCompositionEnd = () => { - tryApplyDeadKeyBacktickSurround({ finalAttempt: true }); - }; - - let activeRootElement: HTMLElement | null = null; - const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { - prevRootElement?.removeEventListener("keydown", onKeyDown); - prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true); - prevRootElement?.removeEventListener("input", onInput); - prevRootElement?.removeEventListener("compositionend", onCompositionEnd); - rootElement?.addEventListener("keydown", onKeyDown); - rootElement?.addEventListener("beforeinput", onBeforeInput, true); - rootElement?.addEventListener("input", onInput); - rootElement?.addEventListener("compositionend", onCompositionEnd); - activeRootElement = rootElement; - }); - - return () => { - if (activeRootElement) { - activeRootElement.removeEventListener("keydown", onKeyDown); - activeRootElement.removeEventListener("beforeinput", onBeforeInput, true); - activeRootElement.removeEventListener("input", onInput); - activeRootElement.removeEventListener("compositionend", onCompositionEnd); - } - unregisterRootListener(); - }; - }, [editor]); - - return null; -} - -function ComposerPromptEditorInner({ - value, - cursor, - contextRecords, - buildContextClipboardFragment, - importContextFragment, - skills, - disabled, - placeholder, - containerClassName, - className, - placeholderClassName, - onChange, - onVisibleSelectionChange, - onCommandKeyDown, - onPageScrollKeyDown, - onPageScrollKeyUp, - onPageScrollRelease, - onCitationSubmitAndSend, - onPaste, - editorRef, -}: ComposerPromptEditorProps) { - const [editor] = useLexicalComposerContext(); - const onChangeRef = useRef(onChange); - const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); - const initialCursor = clampCollapsedComposerCursor(value, cursor); - const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); - const skillsSignature = skillSignature(skills); - const skillsSignatureRef = useRef(skillsSignature); - const skillMetadataRef = useRef(skillMetadataByName(skills)); - const snapshotRef = useRef({ - value, - cursor: initialCursor, - expandedCursor: initialExpandedCursor, - contextIds: collectInlineContextIds(value), - }); - const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); - const isApplyingControlledUpdateRef = useRef(false); - // Latest controlled value, readable from editor listeners that fire before the layout - // effect has rewritten the editor to match it. - const latestValueRef = useRef(value); - useLayoutEffect(() => { - latestValueRef.current = value; - }, [value]); - const citationCommentRequestRef = useRef(null); - const [openCitationComment, setOpenCitationComment] = - useState(null); - const citationCommentActions = useMemo( - () => ({ - openComment: openCitationComment, - onOpenChange: (nodeKey: NodeKey, open: boolean) => { - setOpenCitationComment((current) => - open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current, - ); - }, - onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), - }), - [onCitationSubmitAndSend, openCitationComment], - ); - - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - - useEffect(() => { - onVisibleSelectionChangeRef.current = onVisibleSelectionChange; - }, [onVisibleSelectionChange]); - - useLayoutEffect(() => { - skillMetadataRef.current = skillMetadataByName(skills); - }, [skills]); - - useEffect(() => { - editor.setEditable(!disabled); - }, [disabled, editor]); - - useEffect(() => { - const openCitationNodeKey = openCitationComment?.nodeKey; - if (!openCitationNodeKey) return; - return editor.registerUpdateListener(({ editorState }) => { - const isAttached = editorState.read(() => { - const node = $getNodeByKey(openCitationNodeKey); - return node instanceof ComposerCitationNode && node.isAttached(); - }); - if (!isAttached) { - setOpenCitationComment((current) => - current?.nodeKey === openCitationNodeKey ? null : current, - ); - } - }); - }, [editor, openCitationComment?.nodeKey]); - - useLayoutEffect(() => { - const normalizedCursor = clampCollapsedComposerCursor(value, cursor); - const previousSnapshot = snapshotRef.current; - const skillsChanged = skillsSignatureRef.current !== skillsSignature; - if ( - previousSnapshot.value === value && - previousSnapshot.cursor === normalizedCursor && - !skillsChanged - ) { - return; - } - - const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); - snapshotRef.current = { - value, - cursor: normalizedCursor, - expandedCursor: normalizedExpandedCursor, - contextIds: collectInlineContextIds(value), - }; - selectionRangeRef.current = { - start: normalizedExpandedCursor, - end: normalizedExpandedCursor, - }; - skillsSignatureRef.current = skillsSignature; - - const rootElement = editor.getRootElement(); - const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !skillsChanged && !isFocused) { - return; - } - - isApplyingControlledUpdateRef.current = true; - const isCiteInsertion = citationCommentRequestRef.current?.value === value; - let citationToOpen: ComposerCitationCommentTarget | null = null; - editor.update( - () => { - const shouldRewriteEditorState = previousSnapshot.value !== value || skillsChanged; - if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, skillMetadataRef.current); - } - if (shouldRewriteEditorState || isFocused) { - $setSelectionAtComposerOffset(normalizedCursor); - } - citationToOpen = $consumeComposerCitationCommentRequest(citationCommentRequestRef); - }, - { - ...(isCiteInsertion ? { tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] } : {}), - onUpdate: () => { - if (citationToOpen) setOpenCitationComment(citationToOpen); - }, - }, - ); - queueMicrotask(() => { - isApplyingControlledUpdateRef.current = false; - }); - }, [cursor, editor, skillsSignature, value]); - - const focusAt = useCallback( - (nextCursor: number) => { - const rootElement = editor.getRootElement(); - if (!rootElement) return; - rootElement.focus({ preventScroll: true }); - // A newer prompt is waiting to be applied (a chip was just inserted through the store). - // Reporting the editor's stale text now would overwrite that prompt; the pending rewrite - // places the caret from the store's cursor instead. - if (snapshotRef.current.value !== latestValueRef.current) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); - editor.update(() => { - $setSelectionAtComposerOffset(boundedCursor); - }); - if (boundedCursor === snapshotRef.current.cursor) return; - snapshotRef.current = { - value: snapshotRef.current.value, - cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), - contextIds: snapshotRef.current.contextIds, - }; - selectionRangeRef.current = { - start: snapshotRef.current.expandedCursor, - end: snapshotRef.current.expandedCursor, - }; - onChangeRef.current( - snapshotRef.current.value, - boundedCursor, - snapshotRef.current.expandedCursor, - false, - snapshotRef.current.contextIds, - ); - }, - [editor], - ); - - const readSnapshot = useCallback((): { - value: string; - cursor: number; - expandedCursor: number; - contextIds: string[]; - } => { - let snapshot = snapshotRef.current; - editor.getEditorState().read(() => { - const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCollapsedComposerCursor( - nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), - ); - const fallbackExpandedCursor = clampExpandedCursor( - nextValue, - snapshotRef.current.expandedCursor, - ); - const nextExpandedCursor = clampExpandedCursor( - nextValue, - $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), - ); - const selectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); - const contextIds = collectContextIds($getRoot()); - snapshot = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - contextIds, - }; - selectionRangeRef.current = selectionRange ?? { - start: nextExpandedCursor, - end: nextExpandedCursor, - }; - }); - snapshotRef.current = snapshot; - return snapshot; - }, [editor]); - - useImperativeHandle( - editorRef, - () => ({ - focus: () => { - focusAt(snapshotRef.current.cursor); - }, - focusAt, - focusAtEnd: () => { - focusAt( - collapseExpandedComposerCursor( - snapshotRef.current.value, - snapshotRef.current.value.length, - ), - ); - }, - readSelectionRange: () => { - readSnapshot(); - return selectionRangeRef.current; - }, - requestCitationComment: (request) => { - citationCommentRequestRef.current = request; - const target = editor - .getEditorState() - .read(() => $consumeComposerCitationCommentRequest(citationCommentRequestRef)); - if (target) setOpenCitationComment(target); - }, - readSnapshot, - isCaretOnVisualEdge: (edge) => { - const snapshot = readSnapshot(); - if (snapshot.value.length === 0) return true; - const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); - const afterCaret = snapshot.value.slice(snapshot.expandedCursor); - if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { - return false; - } - const rootElement = editor.getRootElement(); - const selection = window.getSelection(); - if ( - !rootElement || - !selection || - !selection.isCollapsed || - selection.rangeCount === 0 || - !selection.anchorNode || - !rootElement.contains(selection.anchorNode) - ) { - return false; - } - const caretRect = caretLineRect(selection.getRangeAt(0), edge); - if (!caretRect) return false; - const edgeElement = - edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; - const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); - const threshold = caretRect.height / 2; - return edge === "start" - ? caretRect.top - edgeRect.top < threshold - : edgeRect.bottom - caretRect.bottom < threshold; - }, - }), - [editor, focusAt, readSnapshot], - ); - - const handleEditorChange = useCallback((editorState: EditorState) => { - editorState.read(() => { - const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCollapsedComposerCursor( - nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), - ); - const fallbackExpandedCursor = clampExpandedCursor( - nextValue, - snapshotRef.current.expandedCursor, - ); - const nextExpandedCursor = clampExpandedCursor( - nextValue, - $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), - ); - const nextSelectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); - const previousSelectionRange = selectionRangeRef.current; - selectionRangeRef.current = nextSelectionRange ?? { - start: nextExpandedCursor, - end: nextExpandedCursor, - }; - const contextIds = collectContextIds($getRoot()); - const previousSnapshot = snapshotRef.current; - const snapshotChanged = !( - previousSnapshot.value === nextValue && - previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.contextIds.length === contextIds.length && - previousSnapshot.contextIds.every((id, index) => id === contextIds[index]) - ); - if (isApplyingControlledUpdateRef.current) { - return; - } - if (!snapshotChanged) { - if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { - onVisibleSelectionChangeRef.current?.(); - } - return; - } - // A selection-only update while a newer prompt waits to be applied (an attachment - // chip was just inserted through the store) would report stale text and stale - // context ids, clobbering the prompt and dropping the record. Let the rewrite land. - if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { - return; - } - snapshotRef.current = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - contextIds, - }; - const cursorAdjacentToMention = - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - contextIds, - ); - }); - }, []); - - return ( - - -
- } - onKeyDown={(event) => { - if ( - event.key === "Control" || - event.key === "Meta" || - event.key === "Alt" || - event.key === "Shift" - ) { - onPageScrollRelease?.(); - } - - if (event.key !== "PageUp" && event.key !== "PageDown") { - return; - } - - const pageScrollKey = getTimelinePageScrollKey({ - altKey: event.altKey, - clientHeight: event.currentTarget.clientHeight, - ctrlKey: event.ctrlKey, - defaultPrevented: event.defaultPrevented, - isComposing: event.nativeEvent.isComposing, - key: event.key, - keyCode: event.keyCode, - metaKey: event.metaKey, - scrollHeight: event.currentTarget.scrollHeight, - scrollTop: event.currentTarget.scrollTop, - shiftKey: event.shiftKey, - }); - if (!pageScrollKey) { - onPageScrollRelease?.(); - return; - } - if (!onPageScrollKeyDown) { - return; - } - - event.preventDefault(); - onPageScrollKeyDown(pageScrollKey); - }} - onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} - onBlur={onPageScrollRelease} - onPasteCapture={onPaste} - /> - } - placeholder={ - contextRecords.size > 0 ? null : ( -
- {placeholder} -
- ) - } - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - - - - - - -
-
-
- ); -} - -export function ComposerPromptEditor({ - value, - cursor, - contextRecords, - buildContextClipboardFragment, - importContextFragment, - skills, - disabled, - placeholder, - containerClassName, - className, - placeholderClassName, - onChange, - onVisibleSelectionChange, - onCommandKeyDown, - onPageScrollKeyDown, - onPageScrollKeyUp, - onPageScrollRelease, - onCitationSubmitAndSend, - onPaste, - editorRef, -}: ComposerPromptEditorProps) { - const initialValueRef = useRef(value); - const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); - const initialConfig = useMemo( - () => ({ - namespace: "t3tools-composer-editor", - editable: true, - nodes: [ - ComposerMentionNode, - ComposerSkillNode, - ComposerCitationNode, - ComposerContextReferenceNode, - ], - editorState: () => { - $setComposerEditorPrompt(initialValueRef.current, initialSkillMetadataRef.current); - }, - onError: (error) => { - throw error; - }, - }), - [], - ); - - return ( - - - - - - ); +export function ComposerPromptEditor(props: ComposerPromptEditorProps) { + return ; } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5126246dd05c7e5e74602f57bbf251dd233d851a --- /dev/null +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -0,0 +1,1379 @@ +import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; +import { TaskList } from "@tiptap/extension-task-list"; +import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { splitBlockKeepMarks } from "@tiptap/pm/commands"; +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import type { + AssistantCitation, + ComposerContextClipboardFragment, + ServerProviderSkill, +} from "@t3tools/contracts"; +import { + serializeAssistantCitation, + withAssistantCitationComment, +} from "@t3tools/shared/assistantCitations"; +import { + COMPOSER_CONTEXT_CLIPBOARD_MIME, + encodeComposerContextClipboardHtml, +} from "@t3tools/shared/composerContextClipboard"; +import { + createContext, + use, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { EditorContent, useEditor } from "@tiptap/react"; + +import { + clampCollapsedComposerCursor, + collapseExpandedComposerCursor, + expandCollapsedComposerCursor, + isCollapsedCursorAdjacentToInlineToken, +} from "~/composer-logic"; +import { + collectComposerPromptInlineTokens, + selectionTouchesMentionBoundary, +} from "~/composer-editor-mentions"; +import { + buildDocJson, + buildTiptapContent, + collapsedToFlat, + ComposerTaskItemExtension, + flatToCollapsed, + flatToMarkdown, + flatToPm, + pmToFlat, + serializeEditorDoc, + type SkillMeta, +} from "~/composer-rich-text-doc"; +import { collectInlineContextIds } from "~/lib/composerContextReferences"; +import { cn, isMacPlatform } from "~/lib/utils"; +import { basenameOfPath } from "~/pierre-icons"; +import { + COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, + COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, + SKILL_CHIP_ICON_SVG, +} from "./composerInlineChip"; +import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; +import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { getTimelinePageScrollKey } from "./chat/pageScrollController"; +import { ContextChipPopover } from "./contextChipParts"; +import { Button } from "./ui/button"; +import { + ComposerContextActionsContext, + ComposerContextReferenceChip, + ComposerContextRecordsContext, +} from "./composerContextPresentation"; +import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; +import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { importPastedComposerText } from "./composerInlineTokenPaste"; +import { didComposerSelectionChangeVisibly } from "./composerSelection"; +import type { ComposerDraftContextRecords } from "./composerContextPresentation"; + +export interface ComposerPromptEditorHandle { + focus: () => void; + focusAt: (cursor: number) => void; + focusAtEnd: () => void; + readSelectionRange: () => { start: number; end: number }; + requestCitationComment: (request: ComposerCitationCommentRequest) => void; + readSnapshot: () => { + value: string; + cursor: number; + expandedCursor: number; + contextIds: string[]; + }; + /** + * True when a collapsed caret sits on the first ("start") or last ("end") + * visual line, counting soft wraps. Prompt history only claims ArrowUp and + * ArrowDown at these edges so arrows still move the caret inside multiline + * text. + */ + isCaretOnVisualEdge: (edge: "start" | "end") => boolean; +} + +export interface ComposerPromptEditorProps { + value: string; + cursor: number; + /** + * Render Markdown styling (bold, italic, code, strike, task checkboxes). + * Off renders the same Tiptap engine as plain text: every marker stays a + * literal character. + */ + richTextEnabled?: boolean; + /** Draft records behind the prompt's context references, keyed by context id. */ + contextRecords: ComposerDraftContextRecords; + /** Structured clipboard payload for the given referenced ids, or null to skip. */ + buildContextClipboardFragment?: + | ((contextIds: ReadonlyArray) => string | null) + | undefined; + /** Imports a structured paste's records; returns ids that changed. */ + importContextFragment?: + | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap) + | undefined; + skills: ReadonlyArray; + disabled: boolean; + placeholder: string; + containerClassName?: string; + className?: string; + placeholderClassName?: string; + onChange: ( + nextValue: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + contextIds: string[], + ) => void; + onVisibleSelectionChange?: () => void; + onCommandKeyDown?: ( + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + event: KeyboardEvent, + isTaskItem?: boolean, + ) => boolean; + onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; + onPageScrollKeyUp?: (key: string) => void; + onPageScrollRelease?: () => void; + onCitationSubmitAndSend?: () => void; + onPaste: React.ClipboardEventHandler; + editorRef: React.RefObject; +} + +export type ComposerCitationCommentRequest = { + previousValue: string; + value: string; + citationStart: number; + sourceAnchor: AssistantCitationSourceAnchor; +}; + +type OpenCitationComment = { + key: string; + sourceAnchor?: AssistantCitationSourceAnchor; + removeOnCancel?: boolean; +}; + +const ComposerCitationCommentContext = createContext<{ + openComment: OpenCitationComment | null; + onOpenChange: (citeKey: string, open: boolean) => void; + onSubmitAndSend: () => void; +}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); + +const RichComposerSkillsContext = createContext>([]); + +const SURROUND_CLOSE: Record = { + "(": ")", + "[": "]", + "{": "}", + "'": "'", + '"': '"', + "β€œ": "”", + "`": "`", + "<": ">", + "Β«": "Β»", + "*": "*", + _: "_", +}; + +function resolvedThemeFromDocument(): "light" | "dark" { + return document.documentElement.classList.contains("dark") ? "dark" : "light"; +} + +// ── Inline atom nodes (chips) ───────────────────────────────────────────── + +const ComposerMentionExtension = Node.create({ + name: "composer-mention", + group: "inline", + inline: true, + atom: true, + selectable: true, + addAttributes() { + return { + path: { default: "" }, + source: { default: "" }, + }; + }, + parseHTML() { + return [{ tag: "span[data-composer-mention]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["span", { "data-composer-mention": "", ...HTMLAttributes }]; + }, + addNodeView() { + return ReactNodeViewRenderer(ComposerMentionNodeView); + }, +}); + +function ComposerMentionNodeView({ node }: NodeViewProps) { + const actions = use(ComposerContextActionsContext); + const path = (node.attrs.path as string) ?? ""; + const chip = ( + + ); + return ( + + + + + {path} + + + + ); +} + +const ComposerSkillExtension = Node.create({ + name: "composer-skill", + group: "inline", + inline: true, + atom: true, + selectable: true, + addAttributes() { + return { + skillName: { default: "" }, + skillLabel: { default: "" }, + skillDescription: { default: null }, + }; + }, + parseHTML() { + return [{ tag: "span[data-composer-skill]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["span", { "data-composer-skill": "", ...HTMLAttributes }]; + }, + addNodeView() { + return ReactNodeViewRenderer(ComposerSkillNodeView); + }, +}); + +function ComposerSkillNodeView({ node }: NodeViewProps) { + const actions = use(ComposerContextActionsContext); + const skills = use(RichComposerSkillsContext); + const skillName = (node.attrs.skillName as string) ?? ""; + const skillLabel = (node.attrs.skillLabel as string) || skillName; + const skillDescription = (node.attrs.skillDescription as string | null) ?? null; + const skill = skills.find((candidate) => candidate.name === skillName); + return ( + + + + + ); +} + +const ComposerCitationExtension = Node.create({ + name: "composer-citation", + group: "inline", + inline: true, + atom: true, + selectable: true, + addAttributes() { + return { + citation: { default: null }, + source: { default: "" }, + citeKey: { default: "" }, + }; + }, + parseHTML() { + return [{ tag: "span[data-composer-citation]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["span", { "data-composer-citation": "", ...HTMLAttributes }]; + }, + addNodeView() { + return ReactNodeViewRenderer(ComposerCitationNodeView); + }, +}); + +function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { + const commentContext = use(ComposerCitationCommentContext); + const citation = node.attrs.citation as AssistantCitation; + const citeKey = node.attrs.citeKey as string; + const commentTarget = + commentContext.openComment?.key === citeKey ? commentContext.openComment : null; + + const nodePos = useCallback(() => { + const pos = typeof getPos === "function" ? getPos() : null; + return typeof pos === "number" ? pos : null; + }, [getPos]); + + const onSaveComment = useCallback( + (comment: string): boolean => { + if (!editor.isEditable) return false; + const pos = nodePos(); + if (pos === null) return false; + const current = editor.state.doc.nodeAt(pos); + if (!current || current.type.name !== "composer-citation") return false; + const currentCitation = current.attrs.citation as AssistantCitation; + const next = withAssistantCitationComment(currentCitation, comment); + const tr = editor.state.tr.setNodeMarkup(pos, undefined, { + ...current.attrs, + citation: next, + source: serializeAssistantCitation(next), + }); + editor.view.dispatch(tr); + return true; + }, + [editor, nodePos], + ); + + const onRemove = useCallback(() => { + if (!editor.isEditable) return; + const pos = nodePos(); + if (pos === null) return; + const current = editor.state.doc.nodeAt(pos); + if (!current) return; + editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + current.nodeSize }) + .run(); + }, [editor, nodePos]); + + return ( + + { + if (open && !editor.isEditable) return; + commentContext.onOpenChange(citeKey, open); + }, + ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), + onSave: onSaveComment, + onSaveAndSend: (comment) => { + if (!onSaveComment(comment)) return false; + commentContext.onSubmitAndSend(); + return true; + }, + }} + /> + + ); +} + +const ComposerContextReferenceExtension = Node.create({ + name: "composer-context-reference", + group: "inline", + inline: true, + atom: true, + selectable: true, + addAttributes() { + return { + kind: { default: "" }, + contextId: { default: "" }, + label: { default: "" }, + source: { default: "" }, + }; + }, + parseHTML() { + return [{ tag: "span[data-composer-context-reference]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["span", { "data-composer-context-reference": "", ...HTMLAttributes }]; + }, + addNodeView() { + return ReactNodeViewRenderer(ComposerContextReferenceNodeView); + }, +}); + +function ComposerContextReferenceNodeView({ node }: NodeViewProps) { + return ( + + + + ); +} + +// ── Marker reveal (show ** when the cursor is on styled text) ────────────── + +type StyledRange = { + from: number; + to: number; + markers: { at: number; side: number; text: string }[]; +}; + +function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { + const ranges: StyledRange[] = []; + const map = serializeEditorDoc(doc); + let range: StyledRange | null = null; + let openLength = 0; + for (const run of map.runs) { + if (run.openLen > 0) { + range ??= { from: run.pmPos, to: run.pmPos, markers: [] }; + range.markers.push({ + at: run.pmPos, + side: -1, + text: map.value.slice(run.mdStart, run.mdStart + run.openLen), + }); + } + if (range === null) continue; + range.to = run.pmPos + run.docLen; + if (run.closeLen > 0) { + const end = run.mdStart + run.mdLen; + range.markers.push({ + at: range.to, + side: -2, + text: map.value.slice(end - run.closeLen, end), + }); + } + openLength += run.openLen - run.closeLen; + if (openLength === 0) { + ranges.push(range); + range = null; + } + } + return ranges; +} + +const MarkerPluginKey = new PluginKey("composer-rich-markers"); + +const ComposerMarkerPlugin = new Plugin({ + key: MarkerPluginKey, + state: { + init: (_, state) => decorationsForSelection(state.doc, state.selection), + apply: (tr, old) => + tr.docChanged || tr.selectionSet ? decorationsForSelection(tr.doc, tr.selection) : old, + }, + props: { + decorations(state) { + return MarkerPluginKey.getState(state); + }, + }, +}); + +function decorationsForSelection( + doc: ProseMirrorNode, + selection: { from: number; to: number; empty: boolean }, +): DecorationSet { + const decorations: Decoration[] = []; + if (!selection.empty) { + doc.nodesBetween(selection.from, selection.to, (node, pos) => { + if (node.type.name.startsWith("composer-")) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { class: "composer-chip-range-selected" }), + ); + return false; + } + return true; + }); + } + for (const range of collectStyledRanges(doc)) { + const active = selection.empty + ? selection.from >= range.from && selection.from <= range.to + : selection.from < range.to && selection.to > range.from; + if (!active) continue; + for (const { at, side, text } of range.markers) { + const marker = document.createElement("span"); + marker.className = "composer-rich-marker"; + marker.textContent = text; + marker.setAttribute("aria-hidden", "true"); + decorations.push( + Decoration.widget(at, marker, { side, key: `marker-${at}-${side}-${marker.textContent}` }), + ); + } + } + return DecorationSet.create(doc, decorations); +} + +const ComposerMarkersExtension = Extension.create({ + name: "composer-rich-markers", + addProseMirrorPlugins() { + return [ComposerMarkerPlugin]; + }, +}); + +// Document model (markdown ⇄ ProseMirror) lives in ~/composer-rich-text-doc so +// unit tests can round-trip it without a browser. +// ── Editor component ─────────────────────────────────────────────────────── + +type TiptapEditor = NonNullable>; + +export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { + // Extensions are creation-time: flipping the setting remounts the editor. + // Both halves initialize from the controlled Markdown value, so the draft + // survives the flip. + return ( + + ); +} + +function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { + const { + value, + cursor, + richTextEnabled, + contextRecords, + buildContextClipboardFragment, + importContextFragment, + skills, + disabled, + placeholder, + containerClassName, + className, + placeholderClassName, + onChange, + onVisibleSelectionChange, + onCommandKeyDown, + onPageScrollKeyDown, + onPageScrollKeyUp, + onPageScrollRelease, + onCitationSubmitAndSend, + onPaste, + editorRef, + } = props; + // The setting toggles styling, not the engine: both modes are Tiptap. + // Plain mode disables the mark extensions, so markers stay literal text. + const richText = richTextEnabled ?? false; + + const onChangeRef = useRef(onChange); + const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); + const onCommandKeyDownRef = useRef(onCommandKeyDown); + const buildFragmentRef = useRef(buildContextClipboardFragment); + const importFragmentRef = useRef(importContextFragment); + const skillsRef = useRef(skills); + const latestValueRef = useRef(value); + // The editor instance for callbacks created before it exists (paste). + // Effects flush before any user interaction, so this is always set. + const editorHolder = useRef(null); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + useEffect(() => { + onVisibleSelectionChangeRef.current = onVisibleSelectionChange; + }, [onVisibleSelectionChange]); + useEffect(() => { + onCommandKeyDownRef.current = onCommandKeyDown; + }, [onCommandKeyDown]); + useEffect(() => { + buildFragmentRef.current = buildContextClipboardFragment; + }, [buildContextClipboardFragment]); + useEffect(() => { + importFragmentRef.current = importContextFragment; + }, [importContextFragment]); + useEffect(() => { + skillsRef.current = skills; + }, [skills]); + useLayoutEffect(() => { + latestValueRef.current = value; + }, [value]); + + const skillLabelFor = useCallback((name: string): SkillMeta => { + const normalized = name.startsWith("$") ? name.slice(1) : name; + const skill = skillsRef.current.find((candidate) => candidate.name === normalized); + if (!skill) { + return { label: formatProviderSkillDisplayName({ name: normalized }), description: null }; + } + const shortDescription = skill.shortDescription?.trim(); + return { + label: formatProviderSkillDisplayName(skill), + description: shortDescription || skill.description?.trim() || null, + }; + }, []); + + const initialCursor = clampCollapsedComposerCursor(value, cursor); + const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); + const snapshotRef = useRef({ + value, + cursor: initialCursor, + expandedCursor: initialExpandedCursor, + contextIds: collectInlineContextIds(value), + }); + const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); + const isApplyingControlledUpdateRef = useRef(false); + const hasAppliedControlledSelectionRef = useRef(false); + const citationRequestRef = useRef(null); + const [openCitation, setOpenCitation] = useState(null); + const [isEmpty, setIsEmpty] = useState(value.length === 0); + + const citationCommentActions = useMemo( + () => ({ + openComment: openCitation, + onOpenChange: (nodeKey: string, open: boolean) => { + setOpenCitation((current) => { + if (open) { + if (current?.key === nodeKey) return current; + return { key: nodeKey }; + } + return current?.key === nodeKey ? null : current; + }); + }, + onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), + }), + [onCitationSubmitAndSend, openCitation], + ); + + const handleEditorChange = useCallback((updated: TiptapEditor) => { + const map = serializeEditorDoc(updated.state.doc); + const { from, to } = updated.state.selection; + const fromFlat = pmToFlat(map, from); + const toFlat = pmToFlat(map, to); + const nextValue = map.value; + const nextCursor = clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)); + const nextExpandedCursor = Math.max( + 0, + Math.min(map.value.length, flatToMarkdown(map, fromFlat)), + ); + const nextSelectionRange = { + start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), + end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), + }; + const previousSelectionRange = selectionRangeRef.current; + selectionRangeRef.current = nextSelectionRange; + setIsEmpty(nextValue.length === 0); + const previousSnapshot = snapshotRef.current; + const snapshotChanged = !( + previousSnapshot.value === nextValue && + previousSnapshot.cursor === nextCursor && + previousSnapshot.expandedCursor === nextExpandedCursor && + previousSnapshot.contextIds.length === map.contextIds.length && + previousSnapshot.contextIds.every((id, index) => id === map.contextIds[index]) + ); + if (isApplyingControlledUpdateRef.current) return; + if (!snapshotChanged) { + if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { + onVisibleSelectionChangeRef.current?.(); + } + return; + } + // A selection-only update while a newer prompt waits to be applied (a chip + // was just inserted through the store) would report stale text and clobber + // the prompt. Let the controlled rewrite land instead. + if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { + return; + } + snapshotRef.current = { + value: nextValue, + cursor: nextCursor, + expandedCursor: nextExpandedCursor, + contextIds: map.contextIds, + }; + const cursorAdjacentToMention = + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); + onChangeRef.current( + nextValue, + nextCursor, + nextExpandedCursor, + cursorAdjacentToMention, + map.contextIds, + ); + }, []); + + const editor = useEditor( + { + extensions: [ + StarterKit.configure({ + blockquote: false, + bulletList: false, + codeBlock: false, + heading: false, + horizontalRule: false, + listItem: false, + link: false, + orderedList: false, + underline: false, + dropcursor: false, + gapcursor: false, + trailingNode: false, + // Plain mode has no marks: typed markers stay literal characters. + ...(richText ? {} : { bold: false, italic: false, strike: false, code: false }), + }), + ComposerMentionExtension, + ComposerSkillExtension, + ComposerCitationExtension, + ComposerContextReferenceExtension, + ComposerMarkersExtension, + ...(richText + ? [ + TaskList, + ComposerTaskItemExtension.extend({ + addInputRules() { + return [ + wrappingInputRule({ + find: /^- \[([ xX])\] $/, + type: this.type, + getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), + }), + ]; + }, + }), + ] + : []), + ], + content: buildDocJson( + value, + (name) => { + const normalized = name.startsWith("$") ? name.slice(1) : name; + const found = skills.find((candidate) => candidate.name === normalized); + if (!found) { + return { + label: formatProviderSkillDisplayName({ name: normalized }), + description: null, + }; + } + const shortDescription = found.shortDescription?.trim(); + return { + label: formatProviderSkillDisplayName(found), + description: shortDescription || found.description?.trim() || null, + }; + }, + { styling: richText }, + ), + editable: !disabled, + editorProps: { + attributes: { + class: cn( + "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", + className, + ), + "data-testid": "composer-editor", + "data-composer-rich-text": richText ? "true" : "false", + "aria-placeholder": placeholder, + }, + handleKeyDown: (view, event) => { + if ( + isMacPlatform(navigator.platform) && + (event.key === "Home" || event.key === "End") && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + !event.isComposing + ) { + const selection = window.getSelection(); + if ( + selection?.anchorNode && + view.dom.contains(selection.anchorNode) && + typeof selection.modify === "function" + ) { + event.preventDefault(); + event.stopPropagation(); + selection.modify( + event.shiftKey ? "extend" : "move", + event.key === "Home" ? "backward" : "forward", + "lineboundary", + ); + if (selection.anchorNode && selection.focusNode) { + view.dispatch( + view.state.tr + .setSelection( + TextSelection.create( + view.state.doc, + view.posAtDOM(selection.anchorNode, selection.anchorOffset), + view.posAtDOM(selection.focusNode, selection.focusOffset), + ), + ) + .scrollIntoView(), + ); + } + return true; + } + } + if ( + (event.key === "ArrowLeft" || event.key === "ArrowRight") && + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + !event.isComposing && + view.state.selection.empty + ) { + const { $from } = view.state.selection; + const direction = event.key === "ArrowLeft" ? -1 : 1; + const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter; + if (adjacent?.type.name.startsWith("composer-")) { + event.preventDefault(); + event.stopPropagation(); + view.dispatch( + view.state.tr + .setSelection( + TextSelection.create(view.state.doc, $from.pos + direction * adjacent.nodeSize), + ) + .scrollIntoView(), + ); + return true; + } + } + if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) { + event.stopPropagation(); + return true; + } + // Enter on a focused task checkbox must not send the prompt (or + // split anything): Space toggles it, Enter does nothing. + if (event.key === "Enter" && event.target instanceof HTMLInputElement) { + event.preventDefault(); + return true; + } + const handler = onCommandKeyDownRef.current; + if (event.key === "Enter") { + const instance = editorHolder.current; + const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); + const handled = handler?.("Enter", event, isTaskItem) ?? false; + if (handled) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + event.preventDefault(); + if ( + isTaskItem && + instance && + (instance.commands.splitListItem("taskItem", { checked: false }) || + (view.state.selection.$from.parent.content.size === 0 && + instance.commands.liftListItem("taskItem"))) + ) { + return true; + } + // Split the paragraph so a single newline visibly advances the caret. + return splitBlockKeepMarks(view.state, (tr) => { + // The split is programmatic, so the browser won't follow the + // caret into view on its own. + view.dispatch(tr.scrollIntoView()); + }); + } + if (!handler) return false; + const key = + event.key === "Tab" + ? ("Tab" as const) + : event.key === "ArrowDown" + ? ("ArrowDown" as const) + : event.key === "ArrowUp" + ? ("ArrowUp" as const) + : null; + if (!key) return false; + const handled = handler(key, event); + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + return handled; + }, + handleTextInput: (view, from, to, text) => { + if (text.length !== 1) return false; + const closer = SURROUND_CLOSE[text]; + if (!closer || from === to) return false; + // Never wrap chips or other atoms, and never wrap styled text: the + // default replace keeps marks intact, wrapping would drop them. + let touchesSpecial = false; + view.state.doc.nodesBetween(from, to, (node) => { + if ( + (node.isAtom && node.isInline && !node.isText) || + (node.isText && node.marks.length > 0) + ) { + touchesSpecial = true; + return false; + } + return true; + }); + if (touchesSpecial) return false; + const map = serializeEditorDoc(view.state.doc); + const startMd = flatToMarkdown(map, pmToFlat(map, from)); + const endMd = flatToMarkdown(map, pmToFlat(map, to)); + if (selectionTouchesMentionBoundary(map.value, startMd, endMd)) return false; + const tr = view.state.tr.insertText(closer, to).insertText(text, from); + tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length)); + view.dispatch(tr); + return true; + }, + handlePaste: (view, event) => { + const clipboardData = event.clipboardData; + if (!clipboardData || clipboardData.files.length > 0) return false; + const pastedText = clipboardData.getData("text/plain"); + if (!pastedText) return false; + event.preventDefault(); + const importFragment = importFragmentRef.current; + let text = importFragment + ? importPastedComposerText(clipboardData, importFragment) + : pastedText; + // Complete chips at paste boundaries just as autocomplete does. + const tokens = collectComposerPromptInlineTokens(`${text}\n`); + const lastToken = tokens.at(-1); + if ( + (lastToken?.type === "mention" || lastToken?.type === "skill") && + lastToken.end === text.length + ) { + text += " "; + } + if ( + (tokens[0]?.type === "mention" || tokens[0]?.type === "skill") && + tokens[0].start === 0 + ) { + const map = serializeEditorDoc(view.state.doc); + const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); + if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; + } + const editorInstance = editorHolder.current; + if (editorInstance) { + insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { + editorInstance.commands.insertContent(content); + }); + scrollTiptapCaretIntoView(editorInstance); + } + return true; + }, + }, + onUpdate: ({ editor: updated }) => { + handleEditorChange(updated); + }, + onSelectionUpdate: ({ editor: updated }) => { + handleEditorChange(updated); + }, + }, + [], + ); + + useEffect(() => { + editor?.setEditable(!disabled); + }, [disabled, editor]); + + useEffect(() => { + editorHolder.current = editor; + }, [editor]); + + const readSnapshot = useCallback(() => { + const snapshot = snapshotRef.current; + if (!editor) return snapshot; + const map = serializeEditorDoc(editor.state.doc); + const { from, to } = editor.state.selection; + const fromFlat = pmToFlat(map, from); + const next: typeof snapshot = { + value: map.value, + cursor: clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)), + expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))), + contextIds: map.contextIds, + }; + const toFlat = pmToFlat(map, to); + selectionRangeRef.current = { + start: Math.min(next.expandedCursor, flatToMarkdown(map, toFlat)), + end: Math.max(next.expandedCursor, flatToMarkdown(map, toFlat)), + }; + snapshotRef.current = next; + return next; + }, [editor]); + + // Controlled value/cursor from the store (history recall, chip insertion…). + useLayoutEffect(() => { + if (!editor) return; + const initialSelection = !hasAppliedControlledSelectionRef.current; + hasAppliedControlledSelectionRef.current = true; + const normalizedCursor = clampCollapsedComposerCursor(value, cursor); + const previousSnapshot = snapshotRef.current; + if ( + !initialSelection && + previousSnapshot.value === value && + previousSnapshot.cursor === normalizedCursor + ) { + return; + } + const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); + snapshotRef.current = { + value, + cursor: normalizedCursor, + expandedCursor: normalizedExpandedCursor, + contextIds: collectInlineContextIds(value), + }; + selectionRangeRef.current = { + start: normalizedExpandedCursor, + end: normalizedExpandedCursor, + }; + setIsEmpty(value.length === 0); + const rootElement = editor.view.dom; + const isFocused = Boolean(rootElement && document.activeElement === rootElement); + if (!initialSelection && previousSnapshot.value === value && !isFocused) return; + + isApplyingControlledUpdateRef.current = true; + const pendingCitation = + citationRequestRef.current?.value === value ? citationRequestRef.current : null; + if (previousSnapshot.value !== value) { + editor.commands.setContent(buildDocJson(value, skillLabelFor, { styling: richText }), { + emitUpdate: false, + }); + } + const map = serializeEditorDoc(editor.state.doc); + const flat = collapsedToFlat(map, normalizedCursor); + editor.commands.setTextSelection(flatToPm(map, flat)); + if (isFocused) scrollTiptapCaretIntoView(editor); + if (pendingCitation) { + citationRequestRef.current = null; + const target = map.runs.find( + (run) => + run.kind === "token" && + run.nodeName === "composer-citation" && + run.mdStart === pendingCitation.citationStart, + ); + if (target) { + const node = editor.state.doc.nodeAt(target.pmPos); + const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey; + if (citeKey) { + setOpenCitation({ + key: citeKey, + sourceAnchor: pendingCitation.sourceAnchor, + removeOnCancel: true, + }); + } + } + } + queueMicrotask(() => { + isApplyingControlledUpdateRef.current = false; + }); + }, [cursor, editor, richText, skillLabelFor, value]); + + const focusAt = useCallback( + (nextCursor: number) => { + if (!editor) return; + editor.view.dom.focus({ preventScroll: true }); + // A newer prompt is waiting to be applied (a chip was just inserted + // through the store). Reporting the editor's stale text now would + // overwrite that prompt; the pending rewrite places the caret instead. + if (snapshotRef.current.value !== latestValueRef.current) return; + const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); + const map = serializeEditorDoc(editor.state.doc); + const flat = collapsedToFlat(map, boundedCursor); + editor.commands.setTextSelection(flatToPm(map, flat)); + scrollTiptapCaretIntoView(editor); + if (boundedCursor === snapshotRef.current.cursor) return; + snapshotRef.current = { + value: snapshotRef.current.value, + cursor: boundedCursor, + expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), + contextIds: snapshotRef.current.contextIds, + }; + selectionRangeRef.current = { + start: snapshotRef.current.expandedCursor, + end: snapshotRef.current.expandedCursor, + }; + onChangeRef.current( + snapshotRef.current.value, + boundedCursor, + snapshotRef.current.expandedCursor, + false, + snapshotRef.current.contextIds, + ); + }, + [editor], + ); + + useImperativeHandle( + editorRef, + () => ({ + focus: () => { + focusAt(snapshotRef.current.cursor); + }, + focusAt, + focusAtEnd: () => { + focusAt( + collapseExpandedComposerCursor( + snapshotRef.current.value, + snapshotRef.current.value.length, + ), + ); + }, + readSelectionRange: () => { + readSnapshot(); + return selectionRangeRef.current; + }, + requestCitationComment: (request) => { + citationRequestRef.current = request; + if (!editor) return; + const map = serializeEditorDoc(editor.state.doc); + if (map.value !== request.value) return; + const target = map.runs.find( + (run) => + run.kind === "token" && + run.nodeName === "composer-citation" && + run.mdStart === request.citationStart, + ); + if (!target) return; + const node = editor.state.doc.nodeAt(target.pmPos); + const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey; + if (citeKey) { + citationRequestRef.current = null; + setOpenCitation({ + key: citeKey, + sourceAnchor: request.sourceAnchor, + removeOnCancel: true, + }); + } + }, + readSnapshot, + isCaretOnVisualEdge: (edge) => { + const snapshot = readSnapshot(); + if (snapshot.value.length === 0) return true; + const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); + const afterCaret = snapshot.value.slice(snapshot.expandedCursor); + if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { + return false; + } + const rootElement = editor?.view.dom; + const selection = window.getSelection(); + if ( + !rootElement || + !selection || + !selection.isCollapsed || + selection.rangeCount === 0 || + !selection.anchorNode || + !rootElement.contains(selection.anchorNode) + ) { + return false; + } + const caretRect = caretLineRect(selection.getRangeAt(0), edge); + if (!caretRect) return false; + const edgeElement = + edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; + const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); + const threshold = caretRect.height / 2; + return edge === "start" + ? caretRect.top - edgeRect.top < threshold + : edgeRect.bottom - caretRect.bottom < threshold; + }, + }), + [editor, focusAt, readSnapshot], + ); + + const handleCopyCut = useCallback( + (event: React.ClipboardEvent, cut: boolean) => { + const build = buildFragmentRef.current; + if (!editor || (cut && !editor.isEditable)) return; + const clipboardData = event.clipboardData; + const { from, to } = editor.state.selection; + if (from === to) return; + const { doc, schema } = editor.state; + const slice = doc.slice(from, to); + const first = slice.content.firstChild; + const content = first?.isInline + ? schema.nodes.paragraph!.create(null, slice.content) + : first?.type.name === "taskItem" + ? schema.nodes.taskList!.create(null, slice.content) + : slice.content; + const text = serializeEditorDoc(doc.type.create(null, content)).value; + const contextIds = Array.from(new Set(collectInlineContextIds(text))); + const fragment = contextIds.length > 0 ? build?.(contextIds) : null; + event.preventDefault(); + clipboardData.setData("text/plain", text); + if (fragment) { + clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment); + clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); + } + if (cut) { + editor.chain().focus().deleteSelection().run(); + } + }, + [editor], + ); + + return ( + + + +
+ { + if ( + event.key === "Control" || + event.key === "Meta" || + event.key === "Alt" || + event.key === "Shift" + ) { + onPageScrollRelease?.(); + } + if (event.key !== "PageUp" && event.key !== "PageDown") return; + const target = event.currentTarget.querySelector( + '[data-testid="composer-editor"]', + ) as HTMLElement | null; + if (!target) return; + const pageScrollKey = getTimelinePageScrollKey({ + altKey: event.altKey, + clientHeight: target.clientHeight, + ctrlKey: event.ctrlKey, + defaultPrevented: event.defaultPrevented, + isComposing: event.nativeEvent.isComposing, + key: event.key, + keyCode: event.keyCode, + metaKey: event.metaKey, + scrollHeight: target.scrollHeight, + scrollTop: target.scrollTop, + shiftKey: event.shiftKey, + }); + if (!pageScrollKey) { + onPageScrollRelease?.(); + return; + } + if (!onPageScrollKeyDown) return; + event.preventDefault(); + onPageScrollKeyDown(pageScrollKey); + }} + onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} + onBlur={onPageScrollRelease} + onPasteCapture={onPaste} + onCopyCapture={(event) => handleCopyCut(event, false)} + onCutCapture={(event) => handleCopyCut(event, true)} + /> + {isEmpty && contextRecords.size === 0 && placeholder ? ( +
+ {placeholder} +
+ ) : null} +
+
+
+
+ ); +} + +/** + * Insert pasted markdown at the selection, rebuilding inline tokens as chips + * and styled spans as marks. + * + * Newlines always become paragraph splits β€” never trailing hard breaks, which + * render no visible line β€” so pasted text lands exactly as typed. + */ +function insertMarkdownParagraphs( + value: string, + skillLabelFor: (name: string) => SkillMeta, + options: { styling: boolean }, + insertContent: (content: JSONContent[] | JSONContent) => void, +): void { + const blocks = buildTiptapContent(value, skillLabelFor, options); + if (blocks.length === 1 && blocks[0]?.type === "paragraph") { + const inline = (blocks[0]?.content ?? []) as JSONContent[]; + if (inline.length === 0) return; + insertContent(inline); + return; + } + insertContent(blocks as JSONContent[]); +} + +/** + * Follow a programmatically placed caret: native scrolling only happens for + * real input, so controlled rewrites, pastes, and focus restores scroll the + * composer to the caret explicitly. + */ +function scrollTiptapCaretIntoView(editor: TiptapEditor): void { + editor.view.dispatch(editor.state.tr.scrollIntoView()); +} + +/** + * Client rect of the caret's visual line, so prompt history keeps claiming + * ArrowUp/Down only at the first and last soft-wrapped lines. + */ +function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { + const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); + const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; + if (collapsedRect) return collapsedRect; + + const container = range.startContainer; + // TEXT_NODE without importing the DOM lib's Node (shadowed by Tiptap's). + if (container.nodeType === 3) { + const textNode = container as Text; + if (textNode.data.length === 0) return null; + const probeStart = Math.max( + 0, + Math.min( + edge === "start" ? range.startOffset : range.startOffset - 1, + textNode.data.length - 1, + ), + ); + const probeRange = document.createRange(); + probeRange.setStart(textNode, probeStart); + probeRange.setEnd(textNode, probeStart + 1); + const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); + if (probeRect) return probeRect; + const boundingRect = probeRange.getBoundingClientRect(); + return boundingRect.height > 0 ? boundingRect : null; + } + + if (!(container instanceof HTMLElement)) return null; + const neighbour = + container.childNodes[Math.max(0, range.startOffset - 1)] ?? + container.childNodes[range.startOffset]; + if (neighbour instanceof HTMLElement) { + const neighbourRect = neighbour.getBoundingClientRect(); + if (neighbourRect.height > 0) return neighbourRect; + } else if (neighbour instanceof Text && neighbour.data.length > 0) { + const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; + const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; + const probeRange = document.createRange(); + probeRange.setStart(neighbour, probeStart); + probeRange.setEnd(neighbour, probeStart + 1); + const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); + if (probeRect) return probeRect; + } + const containerRect = container.getBoundingClientRect(); + return containerRect.height > 0 ? containerRect : null; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f3d11569c3cd7473c77404d6738714c7c16890fc..b22920844f22e91ea383473636f75dd3cb51e23d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -79,6 +79,7 @@ import { replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; +import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; import { deriveComposerSendState, getAntigravitySendBlockReason, @@ -3264,7 +3265,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [setIsComposerScrollCollapsed]); /** - * Payloads for chips the prompt no longer references. Lexical's history restores the + * Payloads for chips the prompt no longer references. History undo restores the * reference text but knows nothing about the draft records behind it, so a delete keeps its * payload here and an undo puts it back rather than leaving a dangling chip. */ @@ -3420,6 +3421,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) replacement: string, options?: { expectedText?: string; + expandedCursorAfterReplace?: number; focusEditorAfterReplace?: boolean; citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor }; }, @@ -3440,7 +3442,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return false; } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); - const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); + const nextCursor = collapseExpandedComposerCursor( + next.text, + options?.expandedCursorAfterReplace ?? next.cursor, + ); const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); if (options?.citationComment) { composerEditorRef.current?.requestCitationComment({ @@ -3927,6 +3932,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const onComposerCommandKey = ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, + isTaskItem = false, ) => { if (key === "Tab" && event.shiftKey) { if (!planModeUiEnabled) return false; @@ -3970,6 +3976,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) submitComposer(undefined, submissionIntent); return true; } + // Native task splitting preserves marks and chips on both sides of the caret. + if (key === "Enter" && isTaskItem) return false; + if (!event.isComposing && (key === "Enter" || (key === "Tab" && !event.shiftKey))) { + const selection = composerEditorRef.current?.readSelectionRange(); + const snapshot = readComposerSnapshot(); + if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { + const edit = + key === "Enter" + ? listContinuationForEnter(snapshot.value, selection.start) + : listIndentForTab(snapshot.value, selection.start, selection.end); + if ( + edit && + applyPromptReplacement( + edit.start, + edit.end, + edit.replacement, + key === "Tab" + ? { expandedCursorAfterReplace: selection.start + edit.replacement.length } + : undefined, + ) + ) { + return true; + } + } + } return false; }; @@ -6699,6 +6730,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ; diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 33eac5d58aa35a63ff0bdc4ecc5a3f264af806fa..85102b2cf99f5c89f99b9fdae43a028930378d17 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -1,5 +1,5 @@ import { ComposerContextId } from "@t3tools/contracts"; -import type { AssistantCitation, ComposerContextClipboardFragment } from "@t3tools/contracts"; +import type { ComposerContextClipboardFragment } from "@t3tools/contracts"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, decodeComposerContextFragment, @@ -10,140 +10,6 @@ import { formatComposerContextReference, replaceComposerContextReferences, } from "@t3tools/shared/composerContextReferences"; -import { - $createLineBreakNode, - $createTextNode, - $getRoot, - $getSelection, - $isRangeSelection, - COMMAND_PRIORITY_HIGH, - PASTE_COMMAND, - type LexicalEditor, - type LexicalNode, -} from "lexical"; - -import { collectComposerPromptInlineTokens } from "../composer-editor-mentions"; - -interface ComposerInlineTokenPasteOptions { - createMentionNode: (path: string) => LexicalNode; - createCitationNode: (citation: AssistantCitation, source: string) => LexicalNode; - createContextReferenceNode: (reference: { - kind: string; - contextId: string; - label: string; - }) => LexicalNode; - getExpandedAbsoluteOffsetForPoint: (node: LexicalNode, pointOffset: number) => number; - /** - * Imports the records behind a structured paste into the draft. Returns the ids that had - * to change (a re-attached binary gets a fresh local id) so the pasted links follow. - */ - importContextFragment?: ( - fragment: ComposerContextClipboardFragment, - ) => ReadonlyMap; -} - -export function registerComposerInlineTokenPaste( - editor: LexicalEditor, - options: ComposerInlineTokenPasteOptions, -): () => void { - return editor.registerCommand( - PASTE_COMMAND, - (event) => { - if (!(event instanceof ClipboardEvent) || event.clipboardData === null) { - return false; - } - if (event.clipboardData.files.length > 0) { - return false; - } - const pastedText = event.clipboardData.getData("text/plain"); - if (pastedText.length === 0) { - return false; - } - const text = importPastedComposerText(event.clipboardData, options.importContextFragment); - // Token grammar requires trailing whitespace; a virtual newline lets a - // mention at the very end of the pasted text still parse. - const tokens = collectComposerPromptInlineTokens(`${text}\n`).filter( - (token) => - (token.type === "mention" || - token.type === "citation" || - token.type === "context-reference") && - token.end <= text.length, - ); - if (tokens.length === 0) { - return false; - } - - // Lexical command listeners already run inside an editor update. Starting - // a nested update here queues the token insertion until after this - // listener returns, which lets the plain-text paste handler run as well. - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return false; - } - const nodes: LexicalNode[] = []; - const appendText = (value: string) => { - const lines = value.split("\n"); - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? ""; - if (line.length > 0) { - nodes.push($createTextNode(line)); - } - if (index < lines.length - 1) { - nodes.push($createLineBreakNode()); - } - } - }; - const firstToken = tokens[0]; - if (firstToken?.type === "mention" && firstToken.start === 0) { - const startPoint = selection.isBackward() ? selection.focus : selection.anchor; - const insertionOffset = options.getExpandedAbsoluteOffsetForPoint( - startPoint.getNode(), - startPoint.offset, - ); - const precedingChar = $getRoot() - .getTextContent() - .slice(insertionOffset - 1, insertionOffset); - if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { - nodes.push($createTextNode(" ")); - } - } - let cursor = 0; - for (const token of tokens) { - if (token.start < cursor) { - continue; - } - if (token.start > cursor) { - appendText(text.slice(cursor, token.start)); - } - nodes.push( - token.type === "citation" - ? options.createCitationNode(token.citation, token.source) - : token.type === "context-reference" - ? options.createContextReferenceNode({ - kind: token.kind, - contextId: token.contextId, - label: token.label, - }) - : options.createMentionNode(token.value), - ); - cursor = token.end; - } - if (cursor < text.length) { - appendText(text.slice(cursor)); - } else if (tokens.at(-1)?.type === "mention") { - // Keep the serialized prompt valid: mention tokens need trailing - // whitespace, so a paste ending in a mention gets the same - // trailing space the autocomplete inserts. - nodes.push($createTextNode(" ")); - } - selection.insertNodes(nodes); - event.preventDefault(); - return true; - }, - COMMAND_PRIORITY_HIGH, - ); -} - /** Clipboard records referenced by the copied text, including dependent screenshots. */ export function readPastedComposerContext( clipboardData: Pick, @@ -177,7 +43,9 @@ export function readPastedComposerContext( /** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ export function importPastedComposerText( clipboardData: Pick, - importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], + importContextFragment?: ( + fragment: ComposerContextClipboardFragment, + ) => ReadonlyMap, ): string { const pastedText = clipboardData.getData("text/plain"); const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 46b837a7e621faef8375c024aa909ec9ee075036..2d64538d5c464d4b612872f3b86b54a20dc3a890 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -10,7 +10,7 @@ import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; // The font previews are the real surfaces, not lookalikes: the composer's -// Lexical editor, the diff panel's file diff, and the Ghostty canvas +// Tiptap editor, the diff panel's file diff, and the Ghostty canvas // renderer. Each already consumes the appearance font tokens (or, for the // terminal, the settings passed down as props), so what the row shows is // exactly what the app renders. diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9de7946eac6a4bc5963de7a56071c903f27d919a..4fb847df684b6630778f87f1158f798579c6d27f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -579,6 +579,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? ["Collapse composer on scroll"] : []), + ...(settings.composerRichTextEnabled !== DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled + ? ["Rich text composer"] + : []), ...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []), ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? ["Follow-up behavior"] @@ -641,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.confirmThreadUnpin, settings.composerCollapseOnScroll, + settings.composerRichTextEnabled, settings.sendShortcut, settings.followUpBehavior, settings.addProjectBaseDirectory, @@ -755,6 +759,7 @@ export function useSettingsRestore(onRestored?: () => void) { proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, + composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, @@ -2577,6 +2582,33 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ composerRichTextEnabled: Boolean(checked) }) + } + aria-label="Rich text composer" + /> + } + /> + { + it("continues ordered lists with the next number", () => { + const value = "1. foo"; + const edit = listContinuationForEnter(value, value.length); + expect(edit).not.toBeNull(); + expect(applyEdit(value, edit!)).toBe("1. foo\n2. "); + }); + + it("increments multi-digit and paren markers", () => { + expect(applyEdit("12. foo", listContinuationForEnter("12. foo", 7)!)).toBe("12. foo\n13. "); + expect(applyEdit("3) foo", listContinuationForEnter("3) foo", 6)!)).toBe("3) foo\n4) "); + }); + + it("continues bullets and keeps indentation", () => { + expect(applyEdit("- foo", listContinuationForEnter("- foo", 5)!)).toBe("- foo\n- "); + expect(applyEdit(" * foo", listContinuationForEnter(" * foo", 7)!)).toBe(" * foo\n * "); + }); + + it("continues tasks unchecked", () => { + expect(applyEdit("- [x] done", listContinuationForEnter("- [x] done", 10)!)).toBe( + "- [x] done\n- [ ] ", + ); + }); + + it("splits mid-line items", () => { + expect(applyEdit("1. foobar", listContinuationForEnter("1. foobar", 5)!)).toBe( + "1. fo\n2. obar", + ); + }); + + it("exits the list on an empty item", () => { + expect(applyEdit("1. foo\n2. ", listContinuationForEnter("1. foo\n2. ", 10)!)).toBe("1. foo\n"); + expect(applyEdit("- ", listContinuationForEnter("- ", 2)!)).toBe(""); + }); + + it("ignores non-list lines and carets inside the marker", () => { + expect(listContinuationForEnter("plain text", 5)).toBeNull(); + expect(listContinuationForEnter("1.foo no space", 3)).toBeNull(); + expect(listContinuationForEnter("-foo", 2)).toBeNull(); + expect(listContinuationForEnter("1. foo", 1)).toBeNull(); + }); + + it("refuses to split an inline chip", () => { + const value = "1. @README.md go"; + const cursor = value.indexOf("README") + 2; + expect(listContinuationForEnter(value, cursor)).toBeNull(); + }); + + it("indents list items on Tab", () => { + expect(applyEdit("- foo", listIndentForTab("- foo", 2, 2)!)).toBe(" - foo"); + expect(listIndentForTab("plain", 2, 2)).toBeNull(); + expect(listIndentForTab("- foo", 1, 3)).toBeNull(); + }); +}); diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts new file mode 100644 index 0000000000000000000000000000000000000000..0672bbc1d27fff50dea976d4748f4113ff66cad9 --- /dev/null +++ b/apps/web/src/composer-list-continuation.ts @@ -0,0 +1,137 @@ +import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; + +/** + * List continuation and indentation for the composer. + * + * Implemented once at the ChatComposer level (store replacement), so both + * composer modes behave identically and serialize identically: + * Shift+Enter on a list item continues it, Enter on an empty item exits the + * list, and Tab indents the item. Plain Markdown markers only β€” no real list + * nodes anywhere. + */ + +export interface ComposerListEdit { + /** Expanded (plain string) offsets into the prompt. */ + start: number; + end: number; + replacement: string; +} + +type ListMarker = + | { kind: "ordered"; indent: string; numberText: string; delimiter: "." | ")"; space: string } + | { kind: "task"; indent: string; space: string } + | { kind: "bullet"; indent: string; bullet: string; space: string }; + +function parseListMarker(line: string): { marker: ListMarker; markerEnd: number } | null { + const indent = line.match(/^[ \t]*/)?.[0] ?? ""; + const rest = line.slice(indent.length); + const ordered = rest.match(/^(\d+)([.)])((?:[ \t]+)|\s*$)/); + if (ordered && ordered[1] !== undefined && ordered[2] !== undefined) { + return { + marker: { + kind: "ordered", + indent, + numberText: ordered[1], + delimiter: ordered[2] === ")" ? ")" : ".", + space: ordered[3] ?? "", + }, + markerEnd: indent.length + ordered[0].length, + }; + } + const task = rest.match(/^-\s\[[ xX]\]((?:[ \t]+)|\s*$)/); + if (task) { + return { + marker: { kind: "task", indent, space: task[1] ?? "" }, + markerEnd: indent.length + task[0].length, + }; + } + const bullet = rest.match(/^([-*+])((?:[ \t]+)|\s*$)/); + if (bullet && bullet[1] !== undefined) { + return { + marker: { kind: "bullet", indent, bullet: bullet[1], space: bullet[2] ?? "" }, + markerEnd: indent.length + bullet[0].length, + }; + } + return null; +} + +function nextMarkerText(marker: ListMarker): string { + if (marker.kind === "ordered") { + const number = Number.parseInt(marker.numberText, 10); + const next = Number.isSafeInteger(number) + ? String(number + 1).padStart(marker.numberText.length, "0") + : marker.numberText; + return `${marker.indent}${next}${marker.delimiter} `; + } + if (marker.kind === "task") { + return `${marker.indent}- [ ] `; + } + return `${marker.indent}${marker.bullet} `; +} + +function segmentSource( + segment: ReturnType[number], +): string { + if (segment.type === "text") return segment.text; + if (segment.type === "skill") return `$${segment.name}`; + return segment.source; +} + +/** True when splitting at the caret would cut an inline chip in two. */ +function isInsideInlineToken(value: string, cursor: number): boolean { + let offset = 0; + for (const segment of splitPromptIntoComposerSegments(value)) { + const end = offset + segmentSource(segment).length; + if (segment.type !== "text" && cursor > offset && cursor < end) return true; + offset = end; + } + return false; +} + +function currentLine(value: string, cursor: number): { start: number; end: number; text: string } { + const start = value.lastIndexOf("\n", cursor - 1) + 1; + const endIndex = value.indexOf("\n", cursor); + const end = endIndex === -1 ? value.length : endIndex; + return { start, end, text: value.slice(start, end) }; +} + +/** + * Enter on a list item line: continue the list, or exit it when the item is + * empty. Returns null for non-list lines, carets inside the marker, and + * carets inside an inline chip β€” all fall through to a plain newline. + */ +export function listContinuationForEnter(value: string, cursor: number): ComposerListEdit | null { + if (!Number.isInteger(cursor) || cursor < 0 || cursor > value.length) return null; + const line = currentLine(value, cursor); + const parsed = parseListMarker(line.text); + if (!parsed) return null; + const markerEnd = line.start + parsed.markerEnd; + if (cursor < markerEnd) return null; + if (isInsideInlineToken(value, cursor)) return null; + if (value.slice(markerEnd, line.end).trim() === "") { + // Empty item: Enter exits the list by removing the marker. + return { start: line.start, end: Math.max(cursor, markerEnd), replacement: "" }; + } + return { + start: cursor, + end: cursor, + replacement: `\n${nextMarkerText(parsed.marker)}`, + }; +} + +/** + * Tab on a list item line: indent by two spaces. Ranged selections, non-list + * lines, and carets inside an inline chip fall through (Shift+Tab stays the + * plan-mode toggle and is handled before this is consulted). + */ +export function listIndentForTab( + value: string, + start: number, + end: number, +): ComposerListEdit | null { + if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null; + const line = currentLine(value, start); + if (!parseListMarker(line.text)) return null; + if (isInsideInlineToken(value, start)) return null; + return { start: line.start, end: line.start, replacement: " " }; +} diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..15c8db1e41fe2ff95b9c8afd25eb0595fc009fb4 --- /dev/null +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -0,0 +1,344 @@ +import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { TaskList } from "@tiptap/extension-task-list"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDocJson, + collapsedToFlat, + ComposerTaskItemExtension, + flatToCollapsed, + flatToMarkdown, + flatToPm, + pmToFlat, + serializeEditorDoc, +} from "./composer-rich-text-doc"; + +function stubAtom(name: string, attrs: Record) { + return Node.create({ + name, + group: "inline", + inline: true, + atom: true, + addAttributes: () => attrs, + }); +} + +const schema = getSchemaByResolvedExtensions( + resolveExtensions([ + StarterKit.configure({ + blockquote: false, + bulletList: false, + codeBlock: false, + heading: false, + horizontalRule: false, + listItem: false, + orderedList: false, + dropcursor: false, + gapcursor: false, + trailingNode: false, + }), + stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }), + stubAtom("composer-skill", { + skillName: { default: "" }, + skillLabel: { default: "" }, + skillDescription: { default: null }, + }), + stubAtom("composer-citation", { + citation: { default: null }, + source: { default: "" }, + citeKey: { default: "" }, + }), + stubAtom("composer-context-reference", { + kind: { default: "" }, + contextId: { default: "" }, + label: { default: "" }, + source: { default: "" }, + }), + TaskList, + ComposerTaskItemExtension, + ]), +); + +function roundTrip(value: string) { + const json = buildDocJson(value, (name) => ({ label: name, description: null })); + const doc = ProseMirrorNode.fromJSON(schema, json); + return serializeEditorDoc(doc); +} + +// Plain mode: the same engine with the mark extensions off. Markers stay +// literal characters and task lines stay paragraphs. +const plainSchema = getSchemaByResolvedExtensions( + resolveExtensions([ + StarterKit.configure({ + blockquote: false, + bulletList: false, + codeBlock: false, + heading: false, + horizontalRule: false, + listItem: false, + orderedList: false, + dropcursor: false, + gapcursor: false, + trailingNode: false, + bold: false, + italic: false, + strike: false, + code: false, + }), + stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }), + stubAtom("composer-skill", { + skillName: { default: "" }, + skillLabel: { default: "" }, + skillDescription: { default: null }, + }), + stubAtom("composer-citation", { + citation: { default: null }, + source: { default: "" }, + citeKey: { default: "" }, + }), + stubAtom("composer-context-reference", { + kind: { default: "" }, + contextId: { default: "" }, + label: { default: "" }, + source: { default: "" }, + }), + TaskList, + ComposerTaskItemExtension, + ]), +); + +function roundTripPlain(value: string) { + const json = buildDocJson(value, (name) => ({ label: name, description: null }), { + styling: false, + }); + const doc = ProseMirrorNode.fromJSON(plainSchema, json); + return serializeEditorDoc(doc); +} + +describe("composer rich text document model", () => { + it.each([ + "", + "\n\n", + "plain text", + "hello **bold** world", + "a *italic* word and `code` here", + "struck ~~out~~ now", + "***bold italic*** keeps nesting", + "line one\nline two", + "trailing newline\n", + "1. foo\n2. asdf\n", + "- [ ] buy milk", + "- [ ] buy milk", + "\t-\t[x]\t\titem", + "- [ ] ", + "- [ ]\n - [ ] child", + " - [ ] first\n - [ ] second\n - [ ] child", + "**before @README.md after**", + "*a **b** c*", + "*a**b***", + "**a*b***", + "**a *b* c**", + "literal \uFFFC **before @README.md after**", + "- [x] done\n- [ ] next", + "- [ ] parent\n - [ ] child\n - [ ] sibling\n- [ ] uncle", + "- [ ] empty task follows\n- [ ]", + "- [ ] **bold** task with @README.md", + "para\n- [ ] task\npara", + "- [ ]No space stays literal", + "-[ ] also literal", + "@README.md explain this", + '@"docs/My File.md" and $my-skill please', + "snake_case stays literal", + "unmatched ** stays literal", + "**bold** then @README.md then *italic*", + ])("round-trips %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each([ + "", + "\n", + "text\n\n", + "- [ ]\n- [ ] next\n", + "- [ ] parent\n - [ ] child\n- [ ]", + "para\n- [ ] task\npara", + "**before @README.md after**", + ])("maps editable positions in %s", (value) => { + const doc = ProseMirrorNode.fromJSON( + schema, + buildDocJson(value, (name) => ({ label: name, description: null })), + ); + const map = serializeEditorDoc(doc); + for (let flat = 0; flat <= map.docLength; flat += 1) { + const position = flatToPm(map, flat); + expect(doc.resolve(position).parent.isTextblock).toBe(true); + expect(pmToFlat(map, position)).toBe(flat); + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }); + + it("keeps the caret after a trailing hard break inside the same paragraph", () => { + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [schema.text("a"), schema.node("hardBreak")]), + ]); + const map = serializeEditorDoc(doc); + expect(flatToPm(map, map.docLength)).toBe(3); + expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true); + }); + + it("renders a leading task dedent as siblings and nests under the new indent", () => { + const doc = ProseMirrorNode.fromJSON( + schema, + buildDocJson(" - [ ] first\n - [ ] second\n - [ ] child", (name) => ({ + label: name, + description: null, + })), + ); + const list = doc.firstChild!; + expect(list.childCount).toBe(2); + expect(list.child(0).childCount).toBe(1); + expect(list.child(1).child(1).firstChild!.textContent).toBe("child"); + }); + + it("applies a shared mark to text on both sides of a chip", () => { + const doc = ProseMirrorNode.fromJSON( + schema, + buildDocJson("**before @README.md after**", (name) => ({ label: name, description: null })), + ); + expect(doc.firstChild!.childCount).toBe(3); + doc.firstChild!.forEach((child) => + expect(child.marks.map((mark) => mark.type.name)).toContain("bold"), + ); + expect(serializeEditorDoc(doc).value).toBe("**before @README.md after**"); + }); + + it.each([ + [["bold"], ["bold", "italic"], ["italic"]], + [["italic"], ["bold", "italic"], ["bold"]], + [["bold"], ["bold", "strike"], ["strike"]], + [["strike"], ["bold", "strike"], ["bold"]], + ])("preserves crossing mark ranges %j through controlled rebuilds", (...marks) => { + const doc = schema.node("doc", null, [ + schema.node( + "paragraph", + null, + marks.map((names, index) => + schema.text( + String.fromCharCode(97 + index), + names.map((name) => schema.mark(name)), + ), + ), + ), + ]); + const serialized = serializeEditorDoc(doc).value; + const rebuilt = ProseMirrorNode.fromJSON( + schema, + buildDocJson(serialized, (name) => ({ label: name, description: null })), + ); + expect(rebuilt.eq(doc)).toBe(true); + expect(serializeEditorDoc(rebuilt).value).toBe(serialized); + }); + + it.each([ + { parts: [{ text: "hello ", marks: ["bold"] }], expected: "**hello** " }, + { parts: [{ text: " ", marks: ["bold"] }], expected: " " }, + { parts: [{ text: " left ", marks: ["bold", "italic"] }], expected: " ***left*** " }, + { + parts: [ + { text: "one ", marks: ["bold"] }, + { text: " two ", marks: ["bold", "italic"] }, + { text: " three", marks: ["bold"] }, + ], + expected: "**one *two* three**", + }, + { + parts: [ + { text: "hello ", marks: ["bold"] }, + { text: "world ", marks: ["bold", "italic"] }, + ], + expected: "**hello *world*** ", + }, + { parts: [{ text: " hello ", marks: ["code"] }], expected: "` hello `" }, + { parts: [{ text: " hello ", marks: ["bold", "code"] }], expected: "**` hello `**" }, + { parts: [{ text: " ", marks: ["bold", "code"] }], expected: "**` `**" }, + ])("keeps boundary whitespace outside emphasis in $expected", ({ parts, expected }) => { + const doc = schema.node("doc", null, [ + schema.node( + "paragraph", + null, + parts.map(({ text, marks }) => + schema.text( + text, + marks.map((name) => schema.mark(name)), + ), + ), + ), + ]); + const map = serializeEditorDoc(doc); + expect(map.value).toBe(expected); + const rebuilt = ProseMirrorNode.fromJSON( + schema, + buildDocJson(map.value, (name) => ({ label: name, description: null })), + ); + expect(rebuilt.textContent).toBe(doc.textContent); + expect(serializeEditorDoc(rebuilt).value).toBe(map.value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(pmToFlat(map, flatToPm(map, flat))).toBe(flat); + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + if (flat < map.docLength && !/\s/.test(doc.textContent[flat]!)) { + expect(rebuilt.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name)).toEqual( + doc.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name), + ); + } + } + }); + + it("keeps chip sources canonical through the document", () => { + const map = roundTrip("explain @README.md with **care**\nsecond line *here*"); + expect(map.value).toBe("explain @README.md with **care**\nsecond line *here*"); + expect( + map.runs.some((run) => run.kind === "token" && run.nodeName === "composer-mention"), + ).toBe(true); + }); + + it("normalizes uppercase checkboxes to lowercase", () => { + expect(roundTrip("- [X] done").value).toBe("- [x] done"); + }); + + it.each([ + "plain text", + "hello **bold** stays literal", + "a *italic* stays literal", + "some `code` stays literal", + "struck ~~out~~ stays literal", + "- [ ] stays a paragraph", + "- [x] stays a paragraph", + "line one\nline two", + "@README.md explain this", + "**bold** then @README.md then *italic*", + ])("round-trips %s byte-identically in plain mode", (value) => { + expect(roundTripPlain(value).value).toBe(value); + }); + + it("maps every document offset through collapsed coordinates and back", () => { + const value = "hi **bold** @README.md bye"; + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }); + + it("maps markdown offsets at styled edges onto document text", () => { + const value = "a **bold** c"; + const map = roundTrip(value); + expect(map.value).toBe(value); + // document text is "a bold c" (flat), markdown has the markers. + expect(flatToMarkdown(map, 2)).toBe(4); + expect(flatToMarkdown(map, 6)).toBe(10); + expect(collapsedToFlat(map, 3)).toBe(2); + expect(collapsedToFlat(map, 9)).toBe(6); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6cedcc5abb38948f636e7b5ac6082a644729bf2 --- /dev/null +++ b/apps/web/src/composer-rich-text-doc.ts @@ -0,0 +1,644 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { TaskItem } from "@tiptap/extension-task-item"; + +import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; +import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text"; +import { collectInlineContextIds } from "~/lib/composerContextReferences"; + +/** + * Pure document model for the rich text (Tiptap) composer. + * + * The stored prompt stays markdown (`**bold**`, `@file` chips as canonical + * links). The Tiptap document holds styled text plus inline atom chips, so + * this module translates both ways and maps cursor offsets between the three + * coordinate spaces the composer speaks: + * + * - flat document offsets (styled markers excluded, chips count 1), + * - collapsed cursor offsets (markers literal, chips count 1 β€” the coordinate + * the draft store and mention detection use), + * - markdown offsets (markers literal, chips expand to their source). + * + * DOM-free on purpose: unit tests build a real ProseMirror document from the + * JSON this produces and assert the round trip without a browser. + */ + +export type SkillMeta = { label: string; description: string | null }; + +/** Outermost mark first, so closers mirror openers when nested. */ +const MARK_NESTING_ORDER: RichTextMark[] = ["strike", "bold", "italic", "code"]; + +const MARK_TO_TIPTAP: Record = { + bold: "bold", + italic: "italic", + strike: "strike", + code: "code", +}; + +const TIPTAP_TO_MARK: Record = { + bold: "bold", + italic: "italic", + strike: "strike", + code: "code", +}; + +/** + * Task list items keep their exact source indent in an attribute so nesting + * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` β€” + * the same fixed-point deal as `__bold__` becoming `**bold**`. + */ +export const ComposerTaskItemExtension = TaskItem.extend({ + addAttributes() { + return { + ...this.parent?.(), + indent: { default: "" }, + markerSpace: { default: " " }, + contentSpace: { default: null }, + }; + }, +}).configure({ nested: true }); + +function randomNodeKey(): string { + return `tiptap-${Math.random().toString(36).slice(2)}`; +} + +interface TaskLinePrefix { + indent: string; + checked: boolean; + markerSpace: string; + contentSpace: string; +} + +function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength: number } | null { + const match = head.match(/^([ \t]*)-([ \t]+)\[([ xX])\]([ \t]*)/); + if (!match) return null; + const after = head.slice(match[0].length); + if (after.length > 0 && !match[4]) return null; + return { + prefix: { + indent: match[1] ?? "", + checked: (match[3] ?? " ").toLowerCase() === "x", + markerSpace: match[2]!, + contentSpace: match[4]!, + }, + markerLength: match[0].length, + }; +} + +type InlineJson = Record; + +interface DocLine { + task: TaskLinePrefix | null; + inline: InlineJson[]; +} + +function atomJsonForSegment( + segment: Exclude[number], { type: "text" }>, + skillLabelFor: (name: string) => SkillMeta, +): InlineJson { + if (segment.type === "mention") { + return { + type: "composer-mention", + attrs: { path: segment.path, source: segment.source }, + }; + } + if (segment.type === "skill") { + const meta = skillLabelFor(segment.name); + return { + type: "composer-skill", + attrs: { + skillName: segment.name, + skillLabel: meta.label, + skillDescription: meta.description, + }, + }; + } + if (segment.type === "citation") { + return { + type: "composer-citation", + attrs: { citation: segment.citation, source: segment.source, citeKey: randomNodeKey() }, + }; + } + return { + type: "composer-context-reference", + attrs: { + kind: segment.kind, + contextId: segment.contextId, + label: segment.label, + source: segment.source, + }, + }; +} + +interface PendingTaskItem extends TaskLinePrefix { + content: InlineJson[]; + children: PendingTaskItem[]; +} + +function taskListJson(items: PendingTaskItem[]): InlineJson { + return { + type: "taskList", + content: items.map((item) => ({ + type: "taskItem", + attrs: { + checked: item.checked, + indent: item.indent, + markerSpace: item.markerSpace, + contentSpace: item.contentSpace, + }, + content: [ + { type: "paragraph", content: item.content }, + ...(item.children.length > 0 ? [taskListJson(item.children)] : []), + ], + })), + }; +} + +function textJsonForSpan(text: string, marks: RichTextMark[]): Record { + const json: Record = { type: "text", text }; + if (marks.length > 0) { + json.marks = [...marks] + .sort((a, b) => MARK_NESTING_ORDER.indexOf(a) - MARK_NESTING_ORDER.indexOf(b)) + .map((mark) => ({ type: MARK_TO_TIPTAP[mark] })); + } + return json; +} + +export function buildTiptapContent( + value: string, + skillLabelFor: (name: string) => SkillMeta, + options?: { styling?: boolean }, +): Record[] { + const styling = options?.styling ?? true; + // Hide token source from the markdown parser, then restore the atoms with + // the marks of their surrounding text. Choose a sentinel absent from input. + let sentinel = "\uFFFC"; + for (let codePoint = 0xe000; value.includes(sentinel); codePoint += 1) { + sentinel = String.fromCodePoint(codePoint); + } + const atoms: InlineJson[] = []; + const text = splitPromptIntoComposerSegments(value) + .map((segment) => { + if (segment.type === "text") return segment.text; + atoms.push(atomJsonForSegment(segment, skillLabelFor)); + return sentinel; + }) + .join(""); + let atomIndex = 0; + const lines: DocLine[] = text.split("\n").map((line) => { + const parsed = styling ? parseTaskPrefix(line) : null; + const content = parsed ? line.slice(parsed.markerLength) : line; + const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }]; + const inline: InlineJson[] = []; + for (const span of spans) { + span.text.split(sentinel).forEach((piece, index) => { + if (index > 0) { + const atom = atoms[atomIndex++]!; + inline.push({ ...atom, marks: textJsonForSpan("", span.marks).marks }); + } + if (piece) inline.push(textJsonForSpan(piece, span.marks)); + }); + } + return { task: parsed?.prefix ?? null, inline }; + }); + + // Pass 2: consecutive task lines group into (possibly nested) task lists + // by indent prefix; everything else stays a paragraph. + const blocks: Record[] = []; + let stack: { indent: string; items: PendingTaskItem[] }[] = []; + const flushTasks = () => { + if (stack.length > 0) { + blocks.push(taskListJson(stack[0]!.items)); + stack = []; + } + }; + for (const line of lines) { + if (!line.task) { + flushTasks(); + blocks.push({ type: "paragraph", content: line.inline }); + continue; + } + const item: PendingTaskItem = { + ...line.task, + content: line.inline, + children: [], + }; + for (;;) { + const top = stack[stack.length - 1]; + if (!top) { + // A leading indented item with no parent flattens but keeps indent. + stack.push({ indent: item.indent, items: [] }); + continue; + } + if (top.indent === item.indent) { + top.items.push(item); + break; + } + if (top.indent !== "" && !item.indent.startsWith(top.indent)) { + if (stack.length > 1) { + stack.pop(); + continue; + } + top.indent = item.indent; + top.items.push(item); + break; + } + const parent = top.items[top.items.length - 1]; + if (!parent) { + top.items.push(item); + break; + } + parent.children.push(item); + stack.push({ indent: item.indent, items: parent.children }); + break; + } + } + flushTasks(); + return blocks; +} + +export function buildDocJson( + value: string, + skillLabelFor: (name: string) => SkillMeta, + options?: { styling?: boolean }, +) { + return { type: "doc", content: buildTiptapContent(value, skillLabelFor, options) }; +} + +export interface RichRun { + kind: "text" | "token" | "break" | "prefix"; + /** Flat document offset (atoms count 1, markers excluded). */ + flatStart: number; + docLen: number; + /** Collapsed cursor length (markers literal, tokens count 1). */ + collapsedLen: number; + /** Markdown length (tokens expand to their source). */ + mdLen: number; + /** Marker layout inside text runs. */ + openLen: number; + closeLen: number; + /** ProseMirror position of the run start. */ + pmPos: number; + mdStart: number; + collapsedStart: number; + nodeName?: string; +} + +export interface RichDocMap { + value: string; + runs: RichRun[]; + docLength: number; + contextIds: string[]; +} + +function readAtomSource(node: ProseMirrorNode): string { + const attrs = node.attrs as Record; + switch (node.type.name) { + case "composer-mention": + case "composer-citation": + case "composer-context-reference": + return typeof attrs.source === "string" ? attrs.source : ""; + case "composer-skill": { + const name = typeof attrs.skillName === "string" ? attrs.skillName : ""; + return name ? `$${name}` : ""; + } + default: + return ""; + } +} + +interface RichAccumulator { + runs: RichRun[]; + value: string; + flat: number; + collapsed: number; + md: number; +} + +function pushBreakRun(acc: RichAccumulator, position?: number): void { + const previous = acc.runs[acc.runs.length - 1]; + const pmPos = position ?? (previous ? previous.pmPos + previous.docLen : 1); + // Block boundary: one newline in every coordinate space. + acc.runs.push({ + kind: "break", + flatStart: acc.flat, + docLen: 1, + collapsedLen: 1, + mdLen: 1, + openLen: 0, + closeLen: 0, + pmPos, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += "\n"; + acc.flat += 1; + acc.collapsed += 1; + acc.md += 1; +} + +function appendInlineRuns( + container: ProseMirrorNode, + contentStart: number, + acc: RichAccumulator, +): void { + const children: ProseMirrorNode[] = []; + container.forEach((child) => { + if (!child.isText || child.marks.some((mark) => mark.type.name === "code")) { + children.push(child); + return; + } + // Separate boundary whitespace so delimiters can move past it without + // changing the document offsets or marks on the visible text. + const text = child.text!; + const start = text.length - text.trimStart().length; + const end = Math.max(start, text.trimEnd().length); + let offset = 0; + for (const boundary of [start, end, text.length]) { + if (boundary > offset) children.push(child.cut(offset, boundary)); + offset = boundary; + } + }); + // Emphasis cannot open or close next to whitespace. Retain a whitespace + // mark only when its range has visible content on both sides. + for (const mark of MARK_NESTING_ORDER) { + if (mark === "code") continue; + for (const direction of [1, -1]) { + let hasContent = false; + for ( + let index = direction === 1 ? 0 : children.length - 1; + index >= 0 && index < children.length; + index += direction + ) { + const child = children[index]!; + if ( + child.type.name === "hardBreak" || + !child.marks.some((item) => item.type.name === mark) + ) { + hasContent = false; + } else if ( + child.isText && + !child.marks.some((item) => item.type.name === "code") && + /^\s+$/.test(child.text!) + ) { + if (!hasContent) + children[index] = child.mark(child.marks.filter((item) => item.type.name !== mark)); + } else { + hasContent = true; + } + } + } + } + // Longer shared marks surround shorter ones. This keeps both nested + // formatting and formatting across chips inside a single delimiter pair. + const markEnds = new Map(); + const orderedMarks: RichTextMark[][] = []; + for (let index = children.length - 1; index >= 0; index -= 1) { + const child = children[index]!; + const marks = + child.type.name === "hardBreak" + ? [] + : child.marks + .map((mark) => TIPTAP_TO_MARK[mark.type.name]) + .filter((mark): mark is RichTextMark => Boolean(mark)); + for (const mark of MARK_NESTING_ORDER) { + if (!marks.includes(mark)) markEnds.delete(mark); + else if (!markEnds.has(mark)) markEnds.set(mark, index); + } + orderedMarks[index] = marks.sort( + (a, b) => + markEnds.get(b)! - markEnds.get(a)! || + MARK_NESTING_ORDER.indexOf(a) - MARK_NESTING_ORDER.indexOf(b), + ); + } + for (let index = 1; index < orderedMarks.length; index += 1) { + const marks = orderedMarks[index]!; + const retained: RichTextMark[] = []; + for (const mark of orderedMarks[index - 1]!) { + if (!marks.includes(mark)) break; + retained.push(mark); + } + orderedMarks[index] = [...retained, ...marks.filter((mark) => !retained.includes(mark))]; + } + const commonLength = (left: RichTextMark[], right: RichTextMark[]) => { + let index = 0; + while (index < left.length && left[index] === right[index]) index += 1; + return index; + }; + let inlineOffset = 0; + children.forEach((child, index) => { + const pmPos = contentStart + inlineOffset; + inlineOffset += child.nodeSize; + if (child.type.name === "hardBreak") { + pushBreakRun(acc, pmPos); + return; + } + const marks = orderedMarks[index]!; + const open = marks + .slice(commonLength(marks, orderedMarks[index - 1] ?? [])) + .map((mark) => RICH_TEXT_DELIMITERS[mark]) + .join(""); + const close = marks + .slice(commonLength(marks, orderedMarks[index + 1] ?? [])) + .toReversed() + .map((mark) => RICH_TEXT_DELIMITERS[mark]) + .join(""); + const source = child.isText ? child.text! : readAtomSource(child); + const docLen = child.isText ? source.length : 1; + const mdText = open + source + close; + const collapsedLen = open.length + docLen + close.length; + acc.runs.push({ + kind: child.isText ? "text" : "token", + flatStart: acc.flat, + docLen, + collapsedLen, + mdLen: mdText.length, + openLen: open.length, + closeLen: close.length, + pmPos, + mdStart: acc.md, + collapsedStart: acc.collapsed, + ...(child.isText ? {} : { nodeName: child.type.name }), + }); + acc.value += mdText; + acc.flat += docLen; + acc.collapsed += collapsedLen; + acc.md += mdText.length; + }); + // Empty paragraphs have an editable position even though they emit no text. + if (children.length === 0) { + acc.runs.push({ + kind: "text", + flatStart: acc.flat, + docLen: 0, + collapsedLen: 0, + mdLen: 0, + openLen: 0, + closeLen: 0, + pmPos: contentStart, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + } +} + +function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { + let itemPos = listStart + 1; + let firstItem = true; + list.content.forEach((item) => { + // Sibling items are separated by one newline in every coordinate space. + if (!firstItem) pushBreakRun(acc); + firstItem = false; + const itemContentStart = itemPos + 1; + const first = item.firstChild; + const empty = first?.type.name === "paragraph" && first.content.childCount === 0; + const attrs = item.attrs as Record; + const indent = typeof attrs.indent === "string" ? attrs.indent : ""; + const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; + const contentSpace = + typeof attrs.contentSpace === "string" + ? attrs.contentSpace || (empty ? "" : " ") + : empty + ? "" + : " "; + const prefix = `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; + // The checkbox owns no document characters; every prefix offset clamps + // to the start of the item text, exactly like style markers. + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: prefix.length, + mdLen: prefix.length, + openLen: 0, + closeLen: 0, + pmPos: itemContentStart + 1, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += prefix; + acc.collapsed += prefix.length; + acc.md += prefix.length; + let childPos = itemContentStart; + let firstBlock = true; + item.content.forEach((child) => { + if (!firstBlock) pushBreakRun(acc); + firstBlock = false; + if (child.type.name === "taskList") { + walkTaskList(child, childPos, acc); + } else if (child.type.name === "paragraph") { + appendInlineRuns(child, childPos + 1, acc); + } + childPos += child.nodeSize; + }); + itemPos += item.nodeSize; + }); +} + +export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { + const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; + const blocks: ProseMirrorNode[] = []; + doc.content.forEach((node) => { + blocks.push(node); + }); + + let pmBlockStart = 0; + blocks.forEach((block, blockIndex) => { + if (blockIndex > 0) pushBreakRun(acc); + if (block.type.name === "taskList") { + walkTaskList(block, pmBlockStart, acc); + } else if (block.type.name === "paragraph") { + appendInlineRuns(block, pmBlockStart + 1, acc); + } + pmBlockStart += block.nodeSize; + }); + + return { + value: acc.value, + runs: acc.runs, + docLength: acc.flat, + contextIds: Array.from(new Set(collectInlineContextIds(acc.value))), + }; +} + +function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number { + const last = map.runs[map.runs.length - 1]; + if (!last) return 0; + return space === "collapsed" + ? last.collapsedStart + last.collapsedLen + : last.mdStart + last.mdLen; +} + +export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { + const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); + for (const run of map.runs) { + if (bounded < run.flatStart + run.docLen) { + if (run.kind === "text" || run.kind === "token") { + return run.collapsedStart + run.openLen + (bounded - run.flatStart); + } + return run.collapsedStart + (bounded - run.flatStart); + } + } + return lastRunEnd(map, "collapsed"); +} + +export function flatToMarkdown(map: RichDocMap, flatOffset: number): number { + const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); + for (const run of map.runs) { + if (bounded < run.flatStart + run.docLen) { + if (run.kind === "text" || run.kind === "token") { + return run.mdStart + run.openLen + (bounded - run.flatStart); + } + return run.mdStart + (bounded - run.flatStart); + } + } + return lastRunEnd(map, "md"); +} + +export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): number { + for (const run of map.runs) { + if (collapsedOffset < run.collapsedStart + run.collapsedLen) { + // Checkbox prefixes and style markers are shown, never edited: every + // offset inside them clamps to the adjacent document position. + if (run.kind === "prefix") return run.flatStart; + if (run.kind === "text" || run.kind === "token") { + const within = collapsedOffset - run.collapsedStart; + // Marker characters clamp to the styled edge: they are shown, never edited. + if (within <= run.openLen) return run.flatStart; + if (within >= run.openLen + run.docLen) return run.flatStart + run.docLen; + return run.flatStart + (within - run.openLen); + } + return run.flatStart + (collapsedOffset - run.collapsedStart); + } + } + return map.docLength; +} + +export function flatToPm(map: RichDocMap, flatOffset: number): number { + const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); + for (const run of map.runs) { + if (bounded < run.flatStart + run.docLen) { + return run.pmPos + (bounded - run.flatStart); + } + } + const last = map.runs[map.runs.length - 1]; + if (!last) return 1; + return last.pmPos + last.docLen; +} + +export function pmToFlat(map: RichDocMap, pmPos: number): number { + for (const run of map.runs) { + if (pmPos >= run.pmPos && pmPos <= run.pmPos + run.docLen) { + // A position on a chip's trailing edge belongs after the chip. + if (run.kind === "token" && pmPos === run.pmPos + run.docLen) { + return run.flatStart + run.docLen; + } + return run.flatStart + Math.min(pmPos - run.pmPos, run.docLen); + } + } + // A paragraph boundary position belongs to the newline between paragraphs. + let best = 0; + for (const run of map.runs) { + if (run.pmPos <= pmPos) best = run.flatStart + run.docLen; + } + return Math.max(0, Math.min(best, map.docLength)); +} diff --git a/apps/web/src/composer-rich-text.test.ts b/apps/web/src/composer-rich-text.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..81c83373d9c0d8abda88f7418469c6b3eed661b9 --- /dev/null +++ b/apps/web/src/composer-rich-text.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseInlineMarkdown } from "./composer-rich-text"; + +describe("composer rich text markdown", () => { + it("parses bold markers into styled spans", () => { + expect(parseInlineMarkdown("hello **bold** world")).toEqual([ + { text: "hello ", marks: [] }, + { text: "bold", marks: ["bold"] }, + { text: " world", marks: [] }, + ]); + }); + + it("preserves unmatched markers, escaped markers, and identifiers", () => { + for (const text of [ + "plain text", + "snake_case", + "unmatched **", + "** spaced **", + "\\*literal\\*", + ]) { + expect(parseInlineMarkdown(text)).toEqual([{ text, marks: [] }]); + } + }); + + it("renders triple markers and nested styles", () => { + expect(parseInlineMarkdown("***both***")).toEqual([ + { text: "both", marks: ["bold", "italic"] }, + ]); + expect(parseInlineMarkdown("*a **b** c*")).toEqual([ + { text: "a ", marks: ["italic"] }, + { text: "b", marks: ["italic", "bold"] }, + { text: " c", marks: ["italic"] }, + ]); + expect(parseInlineMarkdown("**a `code` c**")).toEqual([ + { text: "a ", marks: ["bold"] }, + { text: "code", marks: ["bold", "code"] }, + { text: " c", marks: ["bold"] }, + ]); + }); + + it("preserves crossing italic and bold spans", () => { + expect(parseInlineMarkdown("*a**b*****c**")).toEqual([ + { text: "a", marks: ["italic"] }, + { text: "b", marks: ["italic", "bold"] }, + { text: "c", marks: ["bold"] }, + ]); + }); + + it("keeps repeated and alternate delimiters literal inside an active mark", () => { + const repeated = "*".repeat(10_000); + expect(parseInlineMarkdown(repeated + "text" + repeated)).toEqual([ + { text: repeated.slice(2) + "text", marks: ["bold"] }, + { text: repeated.slice(2), marks: [] }, + ]); + const alternate = "__".repeat(5_000); + expect(parseInlineMarkdown("**~~" + alternate + "text" + alternate + "~~**")).toEqual([ + { text: alternate + "text" + alternate, marks: ["bold", "strike"] }, + ]); + }); + + it("keeps code span contents literal", () => { + expect(parseInlineMarkdown("`**not bold**`")).toEqual([ + { text: "**not bold**", marks: ["code"] }, + ]); + }); +}); diff --git a/apps/web/src/composer-rich-text.ts b/apps/web/src/composer-rich-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcaee772e98807d86007a6f4bcd7919b4d1862e8 --- /dev/null +++ b/apps/web/src/composer-rich-text.ts @@ -0,0 +1,131 @@ +/** + * Inline markdown styling for the rich text composer. + * + * The composer's stored prompt stays plain markdown (`**bold**`), while the + * Tiptap surface renders styled text. These helpers translate between the two: + * parsing markdown into marked spans for the document, serializing marked + * spans back to markdown in the document serializer. + * + * Deliberately small: bold, italic, strikethrough, and inline code only. + * Unmatched markers stay literal text so nothing the user typed is ever lost. + */ + +export type RichTextMark = "bold" | "italic" | "strike" | "code"; + +interface RichTextSpan { + text: string; + marks: RichTextMark[]; +} + +export const RICH_TEXT_DELIMITERS: Record = { + bold: "**", + italic: "*", + strike: "~~", + code: "`", +}; + +function pushSpan(spans: RichTextSpan[], text: string, marks: RichTextMark[]): void { + if (!text) return; + const last = spans[spans.length - 1]; + if ( + last && + last.marks.length === marks.length && + last.marks.every((mark, index) => mark === marks[index]) + ) { + last.text += text; + } else { + spans.push({ text, marks }); + } +} + +/** Parse the supported inline styles, leaving unmatched and escaped markers literal. */ +export function parseInlineMarkdown(text: string): RichTextSpan[] { + const root: RichTextSpan[] = []; + const stack: { delimiter: string; mark: RichTextMark; spans: RichTextSpan[] }[] = []; + const current = () => stack.at(-1)?.spans ?? root; + let index = 0; + while (index < text.length) { + const char = text[index]!; + if (char === "\\") { + pushSpan(current(), text.slice(index, index + 2), []); + index += 2; + continue; + } + if (char === "`") { + // Multi-backtick code stays literal; a single-backtick span owns its contents. + const run = text.slice(index).match(/^`+/)![0]; + const close = text.indexOf(run, index + run.length); + if ( + run.length === 1 && + close > index + 1 && + !text.slice(index, close).includes("\n") && + text[close + 1] !== "`" + ) { + pushSpan(current(), text.slice(index + 1, close), ["code"]); + index = close + 1; + } else { + pushSpan(current(), run, []); + index += run.length; + } + continue; + } + if (char !== "*" && char !== "_" && char !== "~") { + pushSpan(current(), char, []); + index += 1; + continue; + } + let end = index; + while (text[end] === char) end += 1; + const before = text[index - 1] ?? ""; + const after = text[end] ?? ""; + const canClose = before !== "" && !/\s/.test(before) && (char !== "_" || !/\w/.test(after)); + const canOpen = after !== "" && !/\s/.test(after) && (char !== "_" || !/\w/.test(before)); + while (index < end) { + const top = stack.at(-1); + // Inside italic, a double marker opens bold before closing the single + // marker. Once bold is active, closing runs unwind both styles. + const opensNestedBold = + top?.mark === "italic" && + end - index === 2 && + canOpen && + (char === "*" || char === "_") && + !stack.some((frame) => frame.mark === "bold"); + if ( + !opensNestedBold && + canClose && + top && + text.startsWith(top.delimiter, index) && + index + top.delimiter.length <= end + ) { + stack.pop(); + for (const span of top.spans) pushSpan(current(), span.text, [top.mark, ...span.marks]); + index += top.delimiter.length; + } else if (canOpen && (char !== "~" || end - index >= 2)) { + const length = char === "~" || end - index >= 2 ? 2 : 1; + const mark = char === "~" ? "strike" : length === 2 ? "bold" : "italic"; + // A mark cannot nest inside itself, including alternate delimiters. + // This bounds the stack to the supported styles for arbitrary input. + if (stack.some((frame) => frame.mark === mark)) { + pushSpan(current(), text.slice(index, end), []); + index = end; + continue; + } + stack.push({ + delimiter: char.repeat(length), + mark, + spans: [], + }); + index += length; + } else { + pushSpan(current(), text.slice(index, end), []); + index = end; + } + } + } + while (stack.length > 0) { + const frame = stack.pop()!; + pushSpan(current(), frame.delimiter, []); + for (const span of frame.spans) pushSpan(current(), span.text, span.marks); + } + return root; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e7c5d919a211036a5f2b1d232456997df4d498ed..562c2f970d9b027677983639c2ef062640be3c55 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2120,3 +2120,105 @@ code { visibility: visible; } } + +/* Rich text composer (Tiptap). The stored prompt stays markdown; only the + surface renders styled text. Markers (** * ` ~~) appear as non-editable + widgets while the caret is on styled text. */ +.composer-tiptap { + position: relative; +} + +.composer-tiptap p { + margin: 0; +} + +.composer-tiptap:focus, +.composer-tiptap .ProseMirror:focus { + outline: none; +} + +.composer-tiptap code { + font-family: var(--font-mono); + font-size: 0.92em; + background: color-mix(in oklab, var(--muted-foreground) 12%, transparent); + border-radius: 0.3em; + padding: 0.05em 0.25em; +} + +.composer-rich-marker { + color: var(--muted-foreground); + opacity: 0.75; + font-weight: 400; + font-style: normal; + text-decoration: none; + user-select: none; + pointer-events: none; +} + +/* Selected chips (node selection from arrowing into or clicking a chip). */ +.composer-tiptap .ProseMirror-selectednode { + outline: 2px solid var(--ring); + outline-offset: 1px; + border-radius: 6px; +} + +.composer-tiptap .composer-chip-range-selected { + position: relative; +} + +.composer-tiptap.ProseMirror-focused .composer-chip-range-selected::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 6px; + background: Highlight; + opacity: 0.3; + pointer-events: none; +} + +/* Rich-text task lists render as real checkboxes. Tiptap's TaskItem NodeView + builds the
  • imperatively with only a data-checked attribute (no + data-type), so items are targeted that way. Indentation comes from list + nesting (two spaces per level from Tab), matching the stored Markdown. */ +.composer-tiptap ul[data-type="taskList"] { + list-style: none; + margin: 0; + padding: 0; +} + +.composer-tiptap ul[data-type="taskList"] ul[data-type="taskList"] { + padding-left: 1.375rem; +} + +.composer-tiptap li[data-checked] { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin: 0; + padding: 0; +} + +.composer-tiptap li[data-checked] > label { + display: flex; + align-items: center; + flex: none; + height: 1lh; + cursor: pointer; +} + +.composer-tiptap li[data-checked] > label input[type="checkbox"] { + display: block; + margin: 0; + width: 0.95rem; + height: 0.95rem; + cursor: pointer; +} + +.composer-tiptap li[data-checked] > div { + flex: 1 1 auto; + min-width: 0; +} + +.composer-tiptap li[data-checked] > div > p { + margin: 0; +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d335c68869bb32a44e96268b14397e71f03dd903..3bec6007f7070485b0a77308c5c433bb75501bf6 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -14,6 +14,7 @@ import { import { formatShortcutLabel, isDiffToggleShortcut, + isRichTextBoldShortcut, modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, isOpenFavoriteEditorShortcut, @@ -962,6 +963,21 @@ describe("isTerminalClearShortcut", () => { }); }); +describe("isRichTextBoldShortcut", () => { + it("matches Mod+B without extra modifiers", () => { + assert.isTrue(isRichTextBoldShortcut(event({ key: "b", metaKey: true }))); + assert.isTrue(isRichTextBoldShortcut(event({ key: "B", ctrlKey: true }))); + }); + + it("ignores shifted, alted, bare, and non-keydown presses", () => { + assert.isFalse(isRichTextBoldShortcut(event({ key: "b", metaKey: true, shiftKey: true }))); + assert.isFalse(isRichTextBoldShortcut(event({ key: "b", metaKey: true, altKey: true }))); + assert.isFalse(isRichTextBoldShortcut(event({ key: "b" }))); + assert.isFalse(isRichTextBoldShortcut(event({ key: "i", metaKey: true }))); + assert.isFalse(isRichTextBoldShortcut(event({ type: "keyup", key: "b", metaKey: true }))); + }); +}); + describe("terminalDeleteShortcutData", () => { it("maps Cmd+Backspace on macOS to delete-to-line-start", () => { assert.strictEqual( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 8683ad3c68a55c9e1c1a18fbe70e9b4806e5ade5..6b5556a1669d14929bd5b99120a5b52bd9d63f28 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -406,6 +406,23 @@ export function isOpenFavoriteEditorShortcut( return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options); } +/** + * Whether the keypress is the rich-text bold chord (Mod+B without extra + * modifiers). Tiptap binds the same chord, so app shortcuts captured ahead + * of the editor must yield when the rich-text composer is focused. + */ +export function isRichTextBoldShortcut(event: ShortcutEventLike): boolean { + if (event.type !== undefined && event.type !== "keydown") { + return false; + } + return ( + event.key.toLowerCase() === "b" && + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey + ); +} + export function isTerminalClearShortcut( event: ShortcutEventLike, platform = navigator.platform, diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md new file mode 100644 index 0000000000000000000000000000000000000000..6ed0a8c2f7bf93cada361953d71f47478a8515a3 --- /dev/null +++ b/docs/internals/composer-editors.md @@ -0,0 +1,27 @@ +# Composer coordinates and serialization + +The draft store owns Markdown, while Tiptap owns the editing document. The rich-text +setting changes the installed extensions, so toggling it remounts the editor from the +stored draft. Plain mode must leave formatting markers literal. Rich mode preserves +whitespace and chip sources, but canonicalizes supported delimiters (`__` to `**`, +`_` to `*`) and checkbox case (`[X]` to `[x]`). + +Store cursors are not ProseMirror positions: collapsed coordinates count a chip as one +character, expanded coordinates count its source text, and ProseMirror also counts +block boundaries. Keep conversion in [the document model](../../apps/web/src/composer-rich-text-doc.ts). +Empty paragraphs need real caret positions even though they contain no text. Markers +shown beside styled text are decorations, so offsets inside them clamp to the text edge. + +Only replace editor content when the controlled text changes. Replacing it for a cursor +move creates undo entries and regenerates citation identities. Pending citation popovers +must wait until the requested draft has reached the editor before locating their chip. + +Rich tasks split through native editor commands so marks and chips survive. Literal +lists use [store edits](../../apps/web/src/composer-list-continuation.ts). Newlines become +paragraph splits: trailing hard breaks otherwise appear to require two presses. +Programmatic moves must explicitly scroll the caret into view. + +Clipboard text must come from the Markdown serializer, not DOM text: chip labels omit +the source and marker decorations are not content. Structured context records accompany +that text when available. Paste completes trailing chip delimiters and adds a leading +boundary when inserting a chip directly after text. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 4a0309fe71859a5e553b79d56574441bb2ee2ed7..2f8a6b63b1f039893e7ebe9d4fe9569c8a772578 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -20,6 +20,19 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); +describe("ClientSettings rich text composer", () => { + it("enables rich text for new and existing settings without a saved preference", () => { + expect(decodeClientSettings({}).composerRichTextEnabled).toBe(true); + expect(decodeClientSettings({ sendShortcut: "mod-enter" }).composerRichTextEnabled).toBe(true); + }); + + it("preserves an explicit opt-out through patches and persistence", () => { + const preference = { composerRichTextEnabled: false }; + expect(decodeClientSettingsPatch(preference)).toEqual(preference); + expect(encodeClientSettings(decodeClientSettings(preference))).toMatchObject(preference); + }); +}); + describe("ServerSettings default permissions", () => { it("keeps full access for settings saved before a default was configured", () => { expect(decodeServerSettings({}).defaultRuntimeMode).toBe("full-access"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 236b100d7e5368d3f36e64165fb08261bfc66437..9e836309bdc42fe2b34691661787dec2132451bb 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -430,6 +430,8 @@ export const ClientSettingsSchema = Schema.Struct({ // Desktop resting composer: scrolling an existing thread's conversation // settles the composer into its single-line layout. Losing focus never does. composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Rich text is the default; users can opt out for literal Markdown editing. + composerRichTextEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sendShortcut: Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"]).pipe( Schema.withDecodingDefault(Effect.succeed("enter")), ), @@ -1505,6 +1507,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), + composerRichTextEnabled: Schema.optionalKey(Schema.Boolean), sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9446bf4e4dd5c38492825d612a3ed351a86c2b9c..905d4ac1de2d0b94c821e4c98894ab2c14306145 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -600,9 +600,6 @@ importers: '@legendapp/list': specifier: 'catalog:' version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@lexical/react': - specifier: ^0.41.0 - version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) '@noble/hashes': specifier: 'catalog:' version: 1.8.0 @@ -627,6 +624,24 @@ importers: '@tanstack/react-router': specifier: ^1.160.2 version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tiptap/core': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-task-item': + specifier: ^3.31.3 + 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)) + '@tiptap/extension-task-list': + specifier: ^3.31.3 + 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)) + '@tiptap/pm': + specifier: ^3.31.3 + version: 3.31.3 + '@tiptap/react': + specifier: ^3.31.3 + 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) + '@tiptap/starter-kit': + specifier: ^3.31.3 + version: 3.31.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -654,9 +669,6 @@ importers: jszip: specifier: 3.10.1 version: 3.10.1 - lexical: - specifier: ^0.41.0 - version: 0.41.0 lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.6) @@ -777,7 +789,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.76 - version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f) + version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) @@ -2877,12 +2889,6 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react@0.27.19': - resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} - peerDependencies: - react: '>=17.0.0' - react-dom: '>=17.0.0' - '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} @@ -3304,80 +3310,6 @@ packages: react-native: optional: true - '@lexical/clipboard@0.41.0': - resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==} - - '@lexical/code@0.41.0': - resolution: {integrity: sha512-0hoNi1KC9/N3SBOGcOcFqnT0OpwmcRRAhfxTKMGqfCtCvAMzULVwZ8RWc9/NV9bKYESgBTW5D9xkDANP2mspHg==} - - '@lexical/devtools-core@0.41.0': - resolution: {integrity: sha512-FzJtluBhBc8bKS11TUZe72KoZN/hnzIyiiM0SPJAsPwGpoXuM01jqpXQGybWf/1bWB+bmmhOae7O4Nywi/Csuw==} - peerDependencies: - react: '>=17.x' - react-dom: '>=17.x' - - '@lexical/dragon@0.41.0': - resolution: {integrity: sha512-gBEqkk8Q6ZPruvDaRcOdF1EK9suCVBODzOCcR+EnoJTaTjfDkCM7pkPAm4w90Wa1wCZEtFHvCfas+jU9MDSumg==} - - '@lexical/extension@0.41.0': - resolution: {integrity: sha512-sF4SPiP72yXvIGchmmIZ7Yg2XZTxNLOpFEIIzdqG7X/1fa1Ham9P/T7VbrblWpF6Ei5LJtK9JgNVB0hb4l3o1g==} - - '@lexical/hashtag@0.41.0': - resolution: {integrity: sha512-tFWM74RW4KU0E/sj2aowfWl26vmLUTp331CgVESnhQKcZBfT40KJYd57HEqBDTfQKn4MUhylQCCA0hbpw6EeFQ==} - - '@lexical/history@0.41.0': - resolution: {integrity: sha512-kGoVWsiOn62+RMjRolRa+NXZl8jFwxav6GNDiHH8yzivtoaH8n1SwUfLJELXCzeqzs81HySqD4q30VLJVTGoDg==} - - '@lexical/html@0.41.0': - resolution: {integrity: sha512-3RyZy+H/IDKz2D66rNN/NqYx87xVFrngfEbyu1OWtbY963RUFnopiVHCQvsge/8kT04QSZ7U/DzjVFqeNS6clg==} - - '@lexical/link@0.41.0': - resolution: {integrity: sha512-Rjtx5cGWAkKcnacncbVsZ1TqRnUB2Wm4eEVKpaAEG41+kHgqghzM2P+UGT15yROroxJu8KvAC9ISiYFiU4XE1w==} - - '@lexical/list@0.41.0': - resolution: {integrity: sha512-RXvB+xcbzVoQLGRDOBRCacztG7V+bI95tdoTwl8pz5xvgPtAaRnkZWMDP+yMNzMJZsqEChdtpxbf0NgtMkun6g==} - - '@lexical/mark@0.41.0': - resolution: {integrity: sha512-UO5WVs9uJAYIKHSlYh4Z1gHrBBchTOi21UCYBIZ7eAs4suK84hPzD+3/LAX5CB7ZltL6ke5Sly3FOwNXv/wfpA==} - - '@lexical/markdown@0.41.0': - resolution: {integrity: sha512-bzI73JMXpjGFhqUWNV6KqfjWcgAWzwFT+J3RHtbCF5rysC8HLldBYojOgAAtPfXqfxyv2mDzsY7SoJ75s9uHZA==} - - '@lexical/offset@0.41.0': - resolution: {integrity: sha512-2RHBXZqC8gm3X9C0AyRb0M8w7zJu5dKiasrif+jSKzsxPjAUeF1m95OtIOsWs1XLNUgASOSUqGovDZxKJslZfA==} - - '@lexical/overflow@0.41.0': - resolution: {integrity: sha512-Iy6ZiJip8X14EBYt1zKPOrXyQ4eG9JLBEoPoSVBTiSbVd+lYicdUvaOThT0k0/qeVTN9nqTaEltBjm56IrVKCQ==} - - '@lexical/plain-text@0.41.0': - resolution: {integrity: sha512-HIsGgmFUYRUNNyvckun33UQfU7LRzDlxymHUq67+Bxd5bXqdZOrStEKJXuDX+LuLh/GXZbaWNbDLqwLBObfbQg==} - - '@lexical/react@0.41.0': - resolution: {integrity: sha512-7+GUdZUm6sofWm+zdsWAs6cFBwKNsvsHezZTrf6k8jrZxL461ZQmbz/16b4DvjCGL9r5P1fR7md9/LCmk8TiCg==} - peerDependencies: - react: '>=17.x' - react-dom: '>=17.x' - - '@lexical/rich-text@0.41.0': - resolution: {integrity: sha512-yUcr7ZaaVTZNi8bow4CK1M8jy2qyyls1Vr+5dVjwBclVShOL/F/nFyzBOSb6RtXXRbd3Ahuk9fEleppX/RNIdw==} - - '@lexical/selection@0.41.0': - resolution: {integrity: sha512-1s7/kNyRzcv5uaTwsUL28NpiisqTf5xZ1zNukLsCN1xY+TWbv9RE9OxIv+748wMm4pxNczQe/UbIBODkbeknLw==} - - '@lexical/table@0.41.0': - resolution: {integrity: sha512-d3SPThBAr+oZ8O74TXU0iXM3rLbrAVC7/HcOnSAq7/AhWQW8yMutT51JQGN+0fMLP9kqoWSAojNtkdvzXfU/+A==} - - '@lexical/text@0.41.0': - resolution: {integrity: sha512-gGA+Anc7ck110EXo4KVKtq6Ui3M7Vz3OpGJ4QE6zJHWW8nV5h273koUGSutAMeoZgRVb6t01Izh3ORoFt/j1CA==} - - '@lexical/utils@0.41.0': - resolution: {integrity: sha512-Wlsokr5NQCq83D+7kxZ9qs5yQ3dU3Qaf2M+uXxLRoPoDaXqW8xTWZq1+ZFoEzsHzx06QoPa4Vu/40BZR91uQPg==} - - '@lexical/yjs@0.41.0': - resolution: {integrity: sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w==} - peerDependencies: - yjs: '>=13.5.22' - '@libsql/client@0.17.3': resolution: {integrity: sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==} @@ -4273,9 +4205,6 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@preact/signals-core@1.14.2': - resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==} - '@prisma/debug@7.2.0': resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} @@ -5334,6 +5263,166 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.31.3': + resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} + peerDependencies: + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-blockquote@3.31.3': + resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-bold@3.31.3': + resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-bubble-menu@3.31.3': + resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-bullet-list@3.31.3': + resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-code-block@3.31.3': + resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-code@3.31.3': + resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-document@3.31.3': + resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-dropcursor@3.31.3': + resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} + peerDependencies: + '@tiptap/extensions': 3.31.3 + + '@tiptap/extension-floating-menu@3.31.3': + resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-gapcursor@3.31.3': + resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} + peerDependencies: + '@tiptap/extensions': 3.31.3 + + '@tiptap/extension-hard-break@3.31.3': + resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-heading@3.31.3': + resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-horizontal-rule@3.31.3': + resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-italic@3.31.3': + resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-link@3.31.3': + resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-list-item@3.31.3': + resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-list-keymap@3.31.3': + resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-list@3.31.3': + resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-ordered-list@3.31.3': + resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-paragraph@3.31.3': + resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-strike@3.31.3': + resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-task-item@3.31.3': + resolution: {integrity: sha512-gCWvvXsCzi9tVFXPqKlkbU81XxtIdfjGy7FM9QFRKiWAvq5uv+Q5nvwrCyMVyP4bA7zRGWFaPzwjL68djgzoBw==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-task-list@3.31.3': + resolution: {integrity: sha512-3WgVzmfEnDbmxbjUBlvTiKpT53KSBnnYhXUVQxM0nz1vpoWG8QifhsnCVsNI6TZmhgR0x/jY+UW9Hc/WLF8DkQ==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-text@3.31.3': + resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-underline@3.31.3': + resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extensions@3.31.3': + resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/pm@3.31.3': + resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} + + '@tiptap/react@3.31.3': + resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.31.3': + resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -5474,6 +5563,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -7659,6 +7751,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.2: + resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -8258,9 +8354,6 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} - isomorphic.js@0.2.5: - resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} - jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} @@ -8397,14 +8490,6 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} - lexical@0.41.0: - resolution: {integrity: sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==} - - lib0@0.2.117: - resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} - engines: {node: '>=16'} - hasBin: true - libsodium-wrappers@0.8.4: resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==} @@ -8500,6 +8585,9 @@ packages: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} @@ -9249,6 +9337,9 @@ packages: resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} engines: {node: '>=20'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -9609,6 +9700,45 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + prosemirror-changeset@2.4.2: + resolution: {integrity: sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==} + + prosemirror-commands@1.7.2: + resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==} + + prosemirror-dropcursor@1.8.3: + resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.11: + resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-transform@1.12.1: + resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==} + + prosemirror-view@1.42.3: + resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -9682,11 +9812,6 @@ packages: peerDependencies: react: ^19.2.6 - react-error-boundary@6.1.2: - resolution: {integrity: sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-freeze@1.0.4: resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} engines: {node: '>=10'} @@ -10091,6 +10216,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -10488,9 +10616,6 @@ packages: resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} hasBin: true - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -11113,6 +11238,9 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -11313,10 +11441,6 @@ packages: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} - yjs@13.6.31: - resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} - engines: {node: '>=16.0.0', npm: '>=8.0.0'} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -13727,14 +13851,6 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@floating-ui/react@0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@floating-ui/utils': 0.2.11 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - tabbable: 6.4.0 - '@floating-ui/utils@0.2.11': {} '@formkit/auto-animate@0.9.0': {} @@ -14047,165 +14163,6 @@ snapshots: optionalDependencies: react-dom: 19.2.6(react@19.2.6) - '@lexical/clipboard@0.41.0': - dependencies: - '@lexical/html': 0.41.0 - '@lexical/list': 0.41.0 - '@lexical/selection': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/code@0.41.0': - dependencies: - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - prismjs: 1.30.0 - - '@lexical/devtools-core@0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@lexical/html': 0.41.0 - '@lexical/link': 0.41.0 - '@lexical/mark': 0.41.0 - '@lexical/table': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - - '@lexical/dragon@0.41.0': - dependencies: - '@lexical/extension': 0.41.0 - lexical: 0.41.0 - - '@lexical/extension@0.41.0': - dependencies: - '@lexical/utils': 0.41.0 - '@preact/signals-core': 1.14.2 - lexical: 0.41.0 - - '@lexical/hashtag@0.41.0': - dependencies: - '@lexical/text': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/history@0.41.0': - dependencies: - '@lexical/extension': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/html@0.41.0': - dependencies: - '@lexical/selection': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/link@0.41.0': - dependencies: - '@lexical/extension': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/list@0.41.0': - dependencies: - '@lexical/extension': 0.41.0 - '@lexical/selection': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/mark@0.41.0': - dependencies: - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/markdown@0.41.0': - dependencies: - '@lexical/code': 0.41.0 - '@lexical/link': 0.41.0 - '@lexical/list': 0.41.0 - '@lexical/rich-text': 0.41.0 - '@lexical/text': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/offset@0.41.0': - dependencies: - lexical: 0.41.0 - - '@lexical/overflow@0.41.0': - dependencies: - lexical: 0.41.0 - - '@lexical/plain-text@0.41.0': - dependencies: - '@lexical/clipboard': 0.41.0 - '@lexical/dragon': 0.41.0 - '@lexical/selection': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/react@0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)': - dependencies: - '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@lexical/devtools-core': 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@lexical/dragon': 0.41.0 - '@lexical/extension': 0.41.0 - '@lexical/hashtag': 0.41.0 - '@lexical/history': 0.41.0 - '@lexical/link': 0.41.0 - '@lexical/list': 0.41.0 - '@lexical/mark': 0.41.0 - '@lexical/markdown': 0.41.0 - '@lexical/overflow': 0.41.0 - '@lexical/plain-text': 0.41.0 - '@lexical/rich-text': 0.41.0 - '@lexical/table': 0.41.0 - '@lexical/text': 0.41.0 - '@lexical/utils': 0.41.0 - '@lexical/yjs': 0.41.0(yjs@13.6.31) - lexical: 0.41.0 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-error-boundary: 6.1.2(react@19.2.6) - transitivePeerDependencies: - - yjs - - '@lexical/rich-text@0.41.0': - dependencies: - '@lexical/clipboard': 0.41.0 - '@lexical/dragon': 0.41.0 - '@lexical/selection': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/selection@0.41.0': - dependencies: - lexical: 0.41.0 - - '@lexical/table@0.41.0': - dependencies: - '@lexical/clipboard': 0.41.0 - '@lexical/extension': 0.41.0 - '@lexical/utils': 0.41.0 - lexical: 0.41.0 - - '@lexical/text@0.41.0': - dependencies: - lexical: 0.41.0 - - '@lexical/utils@0.41.0': - dependencies: - '@lexical/selection': 0.41.0 - lexical: 0.41.0 - - '@lexical/yjs@0.41.0(yjs@13.6.31)': - dependencies: - '@lexical/offset': 0.41.0 - '@lexical/selection': 0.41.0 - lexical: 0.41.0 - yjs: 13.6.31 - '@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@libsql/core': 0.17.3 @@ -14877,8 +14834,6 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@preact/signals-core@1.14.2': {} - '@prisma/debug@7.2.0': {} '@prisma/dev@0.20.0(typescript@6.0.3)': @@ -15848,6 +15803,187 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + optional: true + + '@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))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@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))': + dependencies: + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@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)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + optional: true + + '@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))': + dependencies: + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + linkifyjs: 4.3.3 + + '@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))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@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))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@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))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@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))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@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))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/pm@3.31.3': + dependencies: + prosemirror-changeset: 2.4.2 + prosemirror-commands: 1.7.2 + prosemirror-dropcursor: 1.8.3 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 + + '@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)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@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) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.31.3': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@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)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@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)) + '@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)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@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)) + '@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)) + '@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)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -16017,6 +16153,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/webidl-conversions@7.0.3': optional: true @@ -16493,7 +16631,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f): + alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e): dependencies: '@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) '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) @@ -18361,6 +18499,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -19072,8 +19212,6 @@ snapshots: isexe@4.0.0: {} - isomorphic.js@0.2.5: {} - jake@10.9.4: dependencies: async: 3.2.6 @@ -19209,12 +19347,6 @@ snapshots: leven@3.1.0: {} - lexical@0.41.0: {} - - lib0@0.2.117: - dependencies: - isomorphic.js: 0.2.5 - libsodium-wrappers@0.8.4: dependencies: libsodium: 0.8.4 @@ -19298,6 +19430,8 @@ snapshots: lilconfig@2.1.0: {} + linkifyjs@4.3.3: {} + locate-path@3.0.0: dependencies: p-locate: 3.0.0 @@ -20474,6 +20608,8 @@ snapshots: stdin-discarder: 0.3.2 string-width: 8.2.1 + orderedmap@2.1.1: {} + outvariant@1.4.3: optional: true @@ -20866,6 +21002,80 @@ snapshots: property-information@7.2.0: {} + prosemirror-changeset@2.4.2: + dependencies: + prosemirror-transform: 1.12.1 + + prosemirror-commands@1.7.2: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + prosemirror-dropcursor@1.8.3: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.3 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.11: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.3 + + prosemirror-transform@1.12.1: + dependencies: + prosemirror-model: 1.25.11 + + prosemirror-view@1.42.3: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -20944,10 +21154,6 @@ snapshots: react: 19.2.6 scheduler: 0.27.0 - react-error-boundary@6.1.2(react@19.2.6): - dependencies: - react: 19.2.6 - react-freeze@1.0.4(react@19.2.3): dependencies: react: 19.2.3 @@ -21586,6 +21792,8 @@ snapshots: fsevents: 2.3.3 optional: true + rope-sequence@1.3.4: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -22090,8 +22298,6 @@ snapshots: transitivePeerDependencies: - encoding - tabbable@6.4.0: {} - tagged-tag@1.0.0: {} tailwind-merge@3.6.0: {} @@ -22693,6 +22899,8 @@ snapshots: vscode-uri@3.1.0: {} + w3c-keyname@2.2.8: {} + walk-up-path@4.0.0: {} walker@1.0.8: @@ -22880,10 +23088,6 @@ snapshots: dependencies: pend: 1.2.0 - yjs@13.6.31: - dependencies: - lib0: 0.2.117 - yocto-queue@0.1.0: {} yocto-queue@1.2.2: {}