# Files changed · PR \#12160 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/12160/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(web\): enable rich text composer by default](https://git.cafe/pingdotgg/t3code/pull/12160.md)

Version: 31

Head commit: d6d6b70857619877a51ef9371e7a5e17c1d08bbd

Base commit: 394af73e02238e551db14f44d57f1469dd2942aa

Comparison base: dda44ed33280106aa46612fd9e1f1475bb5a0712

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/package.json

Status: modified; +6; −2

```diff
@@ -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",
```

## apps/web/src/components/AppSidebarLayout.tsx

Status: modified; +14; −1

```diff
@@ -11,7 +11,11 @@
 
 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 @@
       ) {
         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();
```

## apps/web/src/components/ComposerCitationNode.tsx

Status: deleted; +0; −223

```diff
@@ -1,223 +1,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 (
-    <span
-      className="inline-flex min-w-0 max-w-full"
-      contentEditable={false}
-      spellCheck={false}
-      data-composer-citation-chip="true"
-    >
-      <AssistantCitationChip
-        citation={props.citation}
-        composer
-        commentEditor={{
-          open: commentTarget !== null,
-          sourceAnchor: commentTarget?.sourceAnchor,
-          onOpenChange: (open) => {
-            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;
-          },
-        }}
-      />
-    </span>
-  );
-}
-
-export class ComposerCitationNode extends DecoratorNode<ReactElement> {
-  __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 <ComposerCitationDecorator citation={this.__citation} nodeKey={this.__key} />;
-  }
-}
-
-export function $createComposerCitationNode(
-  citation: AssistantCitation,
-  source: string,
-): ComposerCitationNode {
-  return $applyNodeReplacement(new ComposerCitationNode(citation, source));
-}
```

## apps/web/src/components/ComposerContextReferenceNode.test.ts

Status: deleted; +0; −92

```diff
@@ -1,92 +1,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}$/);
-  });
-});
```

## apps/web/src/components/ComposerContextReferenceNode.tsx

Status: deleted; +0; −133

```diff
@@ -1,133 +1,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<ReactElement> {
-  __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 (
-      <ComposerContextReferenceChip
-        kind={latest.__kind}
-        contextId={latest.__contextId}
-        label={latest.__label}
-      />
-    );
-  }
-}
-
-export function $createComposerContextReferenceNode(
-  reference: ComposerContextReference,
-  referenceId: string = randomUUID(),
-): ComposerContextReferenceNode {
-  return $applyNodeReplacement(new ComposerContextReferenceNode(reference, referenceId));
-}
```

## apps/web/src/components/ComposerPromptEditor.serialization.test.tsx

Status: deleted; +0; −183

```diff
@@ -1,183 +1,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<ComposerPromptEditorHandle>();
-
-function composer(value: string) {
-  return (
-    <ComposerPromptEditor
-      value={value}
-      cursor={collapseExpandedComposerCursor(value, value.length)}
-      contextRecords={new Map()}
-      skills={[]}
-      disabled={false}
-      placeholder="Write a prompt"
-      onChange={() => {}}
-      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<terminal_context>\n- Terminal 1 line 3:\n  3 | output\n</terminal_context>",
-    '@"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);
-  });
-});
```

## apps/web/src/components/ComposerPromptEditor.test.ts

Status: deleted; +0; −864

```diff
@@ -1,864 +1,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(`<mention:${path}>`),
-    createCitationNode: $createComposerCitationNode,
-    createContextReferenceNode: (reference) => $createTextNode(`<context:${reference.contextId}>`),
-    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<typeof createEditor>, 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<string, string> = {}) {
-    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(`<mention:${path}>`),
-      createCitationNode: $createComposerCitationNode,
-      createContextReferenceNode: (reference) =>
-        $createTextNode(`<context:${reference.contextId}>`),
-      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(
-      "<mention:.changeset/improve-deploy-error-logging.md> ",
-    );
-  });
-
-  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(`<mention:${path}>`),
-      createCitationNode: $createComposerCitationNode,
-      createContextReferenceNode: (reference) =>
-        $createTextNode(`<context:${reference.contextId}>`),
-      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(`<mention:${path}>`),
-      createCitationNode: $createComposerCitationNode,
-      createContextReferenceNode: (reference) =>
-        $createTextNode(`<context:${reference.contextId}>`),
-      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(
-      "<mention:@scope/pkg/sub> ",
-    );
-  });
-
-  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<mention:AGENTS.md> ${citationSource}`,
-      );
-      expect($citationNodes()).toHaveLength(2);
-    });
-
-    const trailingMentionEditor = createCitationEditor();
-    pasteText(trailingMentionEditor, `${citationSource} [AGENTS.md](AGENTS.md)`);
-
-    expect(trailingMentionEditor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
-      `${citationSource} <mention:AGENTS.md> `,
-    );
-  });
-
-  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(`<mention:${path}>`),
-        createCitationNode: $createComposerCitationNode,
-        createContextReferenceNode: (reference) =>
-          $createTextNode(`<context:${reference.contextId}>`),
-        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}<context:img-new> and <context:ctx-t>`,
-      );
-    },
-  );
-
-  it("converts a copied legacy element into a sendable annotation and rewrites its link", () => {
-    const copied = upgradeLegacyContextMessage(
-      [
-        "Fix this",
-        "",
-        "<element_context>",
-        "- <button>:",
-        "  url: https://example.com",
-        "  selector: #save",
-        "  html:",
-        "    <button>Save</button>",
-        "  styles:",
-        "    color: red;",
-        "</element_context>",
-      ].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<typeof previewAnnotationContextRecord>[] = [];
-    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: "<button>Save</button>",
-      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(`<mention:${path}>`),
-      createCitationNode: $createComposerCitationNode,
-      createContextReferenceNode: (reference) =>
-        $createTextNode(`<context:${reference.contextId}>`),
-      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(
-      `<context:${annotationId}>`,
-    );
-  });
-
-  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 <context:ctx-1> 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();
-    });
-  });
-});
```

## apps/web/src/components/ComposerPromptEditor.tsx

Status: modified; +12; −2132

```diff
@@ -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 {
-  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<string, string>(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 = (
-    <button
-      type="button"
-      onClick={() => actions.openMention(props.path)}
-      aria-label={`Preview ${props.path}`}
-      className={`${FILE_TAG_CHIP_CLASS_NAME} cursor-pointer focus-visible:outline-2`}
-      contentEditable={false}
-      spellCheck={false}
-      data-composer-mention-chip="true"
-    >
-      <FileTagChipContent path={props.path} label={basenameOfPath(props.path)} theme={theme} />
-    </button>
-  );
-
-  return (
-    <Tooltip>
-      <TooltipTrigger render={chip} />
-      <TooltipPopup side="top" className="max-w-120 whitespace-normal leading-tight wrap-anywhere">
-        {props.path}
-      </TooltipPopup>
-    </Tooltip>
-  );
-}
-
-class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
-  __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 <ComposerMentionDecorator path={this.__path} />;
-  }
-}
-
-function $createComposerMentionNode(path: string, source?: string): ComposerMentionNode {
-  return $applyNodeReplacement(new ComposerMentionNode(path, source));
-}
-
-function resolveSkillDescription(
-  skill: Pick<ServerProviderSkill, "shortDescription" | "description">,
-): 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<ServerProviderSkill>,
-): ReadonlyMap<string, ComposerSkillMetadata> {
-  return new Map(
-    skills.map((skill) => [
-      skill.name,
-      {
-        label: formatProviderSkillDisplayName(skill),
-        description: resolveSkillDescription(skill),
-      },
-    ]),
-  );
-}
-
-const ComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]);
-
-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 (
-    <ContextChipPopover
-      accessibleLabel={`Skill ${props.skillLabel}`}
-      triggerClassName={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}
-      chip={
-        <>
-          <span
-            aria-hidden="true"
-            className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
-            dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
-          />
-          <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{props.skillLabel}</span>
-        </>
-      }
-    >
-      <div className="space-y-3 p-2 text-sm">
-        <p className="font-medium">{props.skillLabel}</p>
-        <p>
-          {skill?.description ??
-            props.skillDescription ??
-            "No description is available for this skill."}
-        </p>
-        {skill?.path ? (
-          <Button variant="outline" size="sm" onClick={() => actions.openMention(skill.path)}>
-            View instructions
-          </Button>
-        ) : null}
-      </div>
-    </ContextChipPopover>
-  );
-}
-
-class ComposerSkillNode extends DecoratorNode<React.ReactElement> {
-  __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 (
-      <ComposerSkillDecorator
-        skillName={this.__skillName}
-        skillLabel={this.__skillLabel}
-        skillDescription={this.__skillDescription}
-      />
-    );
-  }
-}
-
-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<ServerProviderSkill>): 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<typeof $getSelection>): {
-  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<typeof $getSelection>): 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<string, ComposerSkillMetadata>,
-): 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>) => string | null)
-    | undefined;
-  /** Imports a structured paste's records; returns ids that changed. */
-  importContextFragment?:
-    | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap<string, string>)
-    | undefined;
-  skills: ReadonlyArray<ServerProviderSkill>;
-  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<HTMLElement>;
-  editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
-}
-
-/**
- * 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.
- */
-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<string>();
-    // 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<string>) => {
-      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<string>();
-      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]);
-
+import { ComposerPromptEditorTiptap } from "./ComposerPromptEditorTiptap";
+import type { ComposerPromptEditorProps } from "./ComposerPromptEditorTiptap";
-  return null;
-}
 
+export type {
+  ComposerCitationCommentRequest,
+  ComposerPromptEditorHandle,
+  ComposerPromptEditorProps,
+} from "./ComposerPromptEditorTiptap";
-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.
+ * 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 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<ServerProviderSkill> }) {
-  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<ComposerCitationCommentRequest | null>(null);
-  const [openCitationComment, setOpenCitationComment] =
-    useState<ComposerCitationCommentTarget | null>(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 (
-    <ComposerContextRecordsContext value={contextRecords}>
-      <ComposerCitationCommentContext value={citationCommentActions}>
-        <div
-          className={cn(
-            "relative [font-family:var(--font-composer,var(--font-sans))] [font-size:var(--font-size-prompt,0.875rem)] [@media(max-width:39.999rem)_and_(pointer:coarse)]:[font-size:max(var(--font-size-prompt,1rem),16px)]",
-            containerClassName,
-          )}
-        >
-          <PlainTextPlugin
-            contentEditable={
-              <ContentEditable
-                className={cn(
-                  // The wrapper owns the appearance preference; keep everything else here.
-                  "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"
-                aria-placeholder={placeholder}
-                placeholder={<span />}
-                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 : (
-                <div
-                  className={cn(
-                    "pointer-events-none absolute inset-0 leading-relaxed text-placeholder/75",
-                    placeholderClassName,
-                  )}
-                >
-                  {placeholder}
-                </div>
-              )
-            }
-            ErrorBoundary={LexicalErrorBoundary}
-          />
-          <OnChangePlugin onChange={handleEditorChange} />
-          <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />
-          <ComposerSurroundSelectionPlugin skills={skills} />
-          <ComposerHomeEndKeyPlugin />
-          <ComposerInlineTokenArrowPlugin />
-          <ComposerInlineTokenSelectionNormalizePlugin />
-          <ComposerInlineTokenBackspacePlugin />
-          <ComposerInlineTokenPastePlugin importContextFragment={importContextFragment} />
-          <ComposerContextClipboardPlugin
-            buildContextClipboardFragment={buildContextClipboardFragment}
-          />
-          <ComposerChipSelectionPlugin />
-          <HistoryPlugin />
-        </div>
-      </ComposerCitationCommentContext>
-    </ComposerContextRecordsContext>
-  );
-}
-
-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));
+export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
+  return <ComposerPromptEditorTiptap {...props} />;
-  const initialConfig = useMemo<InitialConfigType>(
-    () => ({
-      namespace: "t3tools-composer-editor",
-      editable: true,
-      nodes: [
-        ComposerMentionNode,
-        ComposerSkillNode,
-        ComposerCitationNode,
-        ComposerContextReferenceNode,
-      ],
-      editorState: () => {
-        $setComposerEditorPrompt(initialValueRef.current, initialSkillMetadataRef.current);
-      },
-      onError: (error) => {
-        throw error;
-      },
-    }),
-    [],
-  );
-
-  return (
-    <ComposerSkillsContext value={skills}>
-      <LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}>
-        <ComposerPromptEditorInner
-          value={value}
-          cursor={cursor}
-          contextRecords={contextRecords}
-          buildContextClipboardFragment={buildContextClipboardFragment}
-          importContextFragment={importContextFragment}
-          skills={skills}
-          disabled={disabled}
-          placeholder={placeholder}
-          {...(containerClassName ? { containerClassName } : {})}
-          onChange={onChange}
-          {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})}
-          onPaste={onPaste}
-          {...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})}
-          editorRef={editorRef}
-          {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
-          {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})}
-          {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})}
-          {...(onPageScrollRelease ? { onPageScrollRelease } : {})}
-          {...(className ? { className } : {})}
-          {...(placeholderClassName ? { placeholderClassName } : {})}
-        />
-      </LexicalComposer>
-    </ComposerSkillsContext>
-  );
 }
```

## apps/web/src/components/ComposerPromptEditorTiptap.tsx

Status: added; +1379; −0

```diff
@@ -1,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>) => string | null)
+    | undefined;
+  /** Imports a structured paste's records; returns ids that changed. */
+  importContextFragment?:
+    | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap<string, string>)
+    | undefined;
+  skills: ReadonlyArray<ServerProviderSkill>;
+  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<HTMLElement>;
+  editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
+}
+
+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<ReadonlyArray<ServerProviderSkill>>([]);
+
+const SURROUND_CLOSE: Record<string, string> = {
+  "(": ")",
+  "[": "]",
+  "{": "}",
+  "'": "'",
+  '"': '"',
+  "“": "”",
+  "`": "`",
+  "<": ">",
+  "«": "»",
+  "*": "*",
+  _: "_",
+};
+
+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 = (
+    <Button
+      variant="chip"
+      onClick={() => actions.openMention(path)}
+      aria-label={`Preview ${path}`}
+      className={`${FILE_TAG_CHIP_CLASS_NAME} cursor-pointer focus-visible:outline-2`}
+      contentEditable={false}
+      spellCheck={false}
+      data-composer-mention-chip="true"
+    >
+      <FileTagChipContent
+        path={path}
+        label={basenameOfPath(path)}
+        theme={resolvedThemeFromDocument()}
+      />
+    </Button>
+  );
+  return (
+    <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}>
+      <Tooltip>
+        <TooltipTrigger render={chip} />
+        <TooltipPopup
+          side="top"
+          className="max-w-120 whitespace-normal leading-tight wrap-anywhere"
+        >
+          {path}
+        </TooltipPopup>
+      </Tooltip>
+    </NodeViewWrapper>
+  );
+}
+
+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 (
+    <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}>
+      <ContextChipPopover
+        accessibleLabel={`Skill ${skillLabel}`}
+        triggerClassName={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}
+        chip={
+          <>
+            <span
+              aria-hidden="true"
+              className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
+              dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
+            />
+            <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{skillLabel}</span>
+          </>
+        }
+      >
+        <div className="space-y-3 p-2 text-sm">
+          <p className="font-medium">{skillLabel}</p>
+          <p>
+            {skill?.description ??
+              skillDescription ??
+              "No description is available for this skill."}
+          </p>
+          {skill?.path ? (
+            <Button variant="outline" size="sm" onClick={() => actions.openMention(skill.path)}>
+              View instructions
+            </Button>
+          ) : null}
+        </div>
+      </ContextChipPopover>
+    </NodeViewWrapper>
+  );
+}
+
+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 (
+    <NodeViewWrapper
+      as="span"
+      className="inline-flex min-w-0 max-w-full"
+      contentEditable={false}
+      spellCheck={false}
+      data-composer-citation-chip="true"
+    >
+      <AssistantCitationChip
+        citation={citation}
+        composer
+        commentEditor={{
+          open: commentTarget !== null,
+          sourceAnchor: commentTarget?.sourceAnchor,
+          onOpenChange: (open) => {
+            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;
+          },
+        }}
+      />
+    </NodeViewWrapper>
+  );
+}
+
+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 (
+    <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}>
+      <ComposerContextReferenceChip
+        kind={(node.attrs.kind as string) ?? ""}
+        contextId={(node.attrs.contextId as string) ?? ""}
+        label={(node.attrs.label as string) ?? ""}
+      />
+    </NodeViewWrapper>
+  );
+}
+
+// ── 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<ReturnType<typeof useEditor>>;
+
+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 (
+    <ComposerPromptEditorTiptapInner key={props.richTextEnabled ? "rich" : "plain"} {...props} />
+  );
+}
+
+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<TiptapEditor | null>(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<ComposerCitationCommentRequest | null>(null);
+  const [openCitation, setOpenCitation] = useState<OpenCitationComment | null>(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 (
+    <RichComposerSkillsContext value={skills}>
+      <ComposerContextRecordsContext value={contextRecords}>
+        <ComposerCitationCommentContext value={citationCommentActions}>
+          <div
+            className={cn(
+              "relative [font-family:var(--font-composer,var(--font-sans))] [font-size:var(--font-size-prompt,0.875rem)] [@media(max-width:39.999rem)_and_(pointer:coarse)]:[font-size:max(var(--font-size-prompt,1rem),16px)]",
+              containerClassName,
+            )}
+          >
+            <EditorContent
+              editor={editor}
+              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 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 ? (
+              <div
+                className={cn(
+                  "pointer-events-none absolute inset-0 leading-relaxed text-placeholder/75",
+                  placeholderClassName,
+                )}
+              >
+                {placeholder}
+              </div>
+            ) : null}
+          </div>
+        </ComposerCitationCommentContext>
+      </ComposerContextRecordsContext>
+    </RichComposerSkillsContext>
+  );
+}
+
+/**
+ * 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;
+}
```

## apps/web/src/components/chat/ChatComposer.tsx

Status: modified; +34; −2

```diff
@@ -79,6 +79,7 @@
   replaceTextRange,
 } from "../../composer-logic";
 import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
+import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation";
 import {
   deriveComposerSendState,
   getAntigravitySendBlockReason,
@@ -3264,7 +3265,7 @@
   }, [setIsComposerScrollCollapsed]);
 
   /**
+   * Payloads for chips the prompt no longer references. History undo restores the
-   * Payloads for chips the prompt no longer references. Lexical's history 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 @@
       replacement: string,
       options?: {
         expectedText?: string;
+        expandedCursorAfterReplace?: number;
         focusEditorAfterReplace?: boolean;
         citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor };
       },
@@ -3440,7 +3442,10 @@
         return false;
       }
       const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
+      const nextCursor = collapseExpandedComposerCursor(
-      const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
+        next.text,
+        options?.expandedCursorAfterReplace ?? next.cursor,
+      );
       const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor);
       if (options?.citationComment) {
         composerEditorRef.current?.requestCitationComment({
@@ -3927,6 +3932,7 @@
   const onComposerCommandKey = (
     key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
     event: KeyboardEvent,
+    isTaskItem = false,
   ) => {
     if (key === "Tab" && event.shiftKey) {
       if (!planModeUiEnabled) return false;
@@ -3970,6 +3976,31 @@
       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 @@
                 <ComposerContextActionsContext value={composerContextActions}>
                   <ComposerPromptEditor
                     editorRef={composerEditorRef}
+                    richTextEnabled={settings.composerRichTextEnabled}
                     value={
                       isComposerApprovalState
                         ? ""
```

## apps/web/src/components/chat/ComposerStashMenu.tsx

Status: modified; +1; −1

```diff
@@ -33,7 +33,7 @@
  * Attached banner listing the stashed prompts. Opened by the stash badge or ⌘S
  * when the empty composer cannot restore a single entry. Navigated with arrows,
  * restored with Enter, dismissed with Escape. The listener runs capture-phase
- * on window so it wins over the Lexical editor's handlers while the menu is open.
+ * on window so it wins over the composer's handlers while the menu is open.
  */
 export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
   entries: ReadonlyArray<PromptStashEntry>;
```

## apps/web/src/components/composerInlineTokenPaste.ts

Status: modified; +4; −136

```diff
@@ -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 @@
   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<string, string>;
-}
-
-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<DataTransfer, "getData">,
@@ -177,7 +43,9 @@
 /** Imports the same structured clipboard payload for focused paste and paste-to-focus. */
 export function importPastedComposerText(
   clipboardData: Pick<DataTransfer, "getData">,
-  importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"],
+  importContextFragment?: (
+    fragment: ComposerContextClipboardFragment,
+  ) => ReadonlyMap<string, string>,
 ): string {
   const pastedText = clipboardData.getData("text/plain");
   const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null;
```

## apps/web/src/components/settings/SettingsFontPreviews.tsx

Status: modified; +1; −1

```diff
@@ -10,7 +10,7 @@
 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.
```

## apps/web/src/components/settings/SettingsPanels.tsx

Status: modified; +32; −0

```diff
@@ -580,6 +580,9 @@
       ...(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"]
@@ -642,6 +645,7 @@
       settings.confirmThreadDelete,
       settings.confirmThreadUnpin,
       settings.composerCollapseOnScroll,
+      settings.composerRichTextEnabled,
       settings.sendShortcut,
       settings.followUpBehavior,
       settings.addProjectBaseDirectory,
@@ -756,6 +760,7 @@
       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,
@@ -2579,6 +2584,33 @@
         />
 
         <SettingsRow
+          {...searchableSetting("composer-rich-text")}
+          description="Show formatted Markdown as you type."
+          resetAction={
+            settings.composerRichTextEnabled !==
+            DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled ? (
+              <SettingResetButton
+                label="rich text composer"
+                onClick={() =>
+                  updateSettings({
+                    composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled,
+                  })
+                }
+              />
+            ) : null
+          }
+          control={
+            <Switch
+              checked={settings.composerRichTextEnabled}
+              onCheckedChange={(checked) =>
+                updateSettings({ composerRichTextEnabled: Boolean(checked) })
+              }
+              aria-label="Rich text composer"
+            />
+          }
+        />
+
+        <SettingsRow
           {...searchableSetting("composer-collapse")}
           description="Rest the composer of an existing thread into a single line when you scroll the conversation. Focus the composer or start typing to expand it again."
           resetAction={
```

## apps/web/src/components/settings/settingsSearch.ts

Status: modified; +6; −0

```diff
@@ -293,6 +293,12 @@
     searchTerms: ["command menu dollar $ slash /"],
   },
   {
+    id: "composer-rich-text",
+    title: "Rich text composer",
+    to: "/settings/general",
+    searchTerms: ["composer rich text tiptap bold italic markdown styled wysiwyg"],
+  },
+  {
     id: "composer-collapse",
     title: "Collapse composer on scroll",
     to: "/settings/general",
```

## apps/web/src/composer-list-continuation.test.ts

Status: added; +62; −0

```diff
@@ -1,0 +1,62 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { listContinuationForEnter, listIndentForTab } from "./composer-list-continuation";
+
+function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) {
+  return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end);
+}
+
+describe("composer list continuation", () => {
+  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();
+  });
+});
```

## apps/web/src/composer-list-continuation.ts

Status: added; +137; −0

```diff
@@ -1,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<typeof splitPromptIntoComposerSegments>[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: "  " };
+}
```

## apps/web/src/composer-rich-text-doc.test.ts

Status: added; +344; −0

```diff
@@ -1,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<string, { default: unknown }>) {
+  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);
+  });
+});
```

## apps/web/src/composer-rich-text-doc.ts

Status: added; +644; −0

```diff
@@ -1,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<RichTextMark, string> = {
+  bold: "bold",
+  italic: "italic",
+  strike: "strike",
+  code: "code",
+};
+
+const TIPTAP_TO_MARK: Record<string, RichTextMark> = {
+  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<string, unknown>;
+
+interface DocLine {
+  task: TaskLinePrefix | null;
+  inline: InlineJson[];
+}
+
+function atomJsonForSegment(
+  segment: Exclude<ReturnType<typeof splitPromptIntoComposerSegments>[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<string, unknown> {
+  const json: Record<string, unknown> = { 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<string, unknown>[] {
+  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<string, unknown>[] = [];
+  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<string, unknown>;
+  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<RichTextMark, number>();
+  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<string, unknown>;
+    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));
+}
```

## apps/web/src/composer-rich-text.test.ts

Status: added; +67; −0

```diff
@@ -1,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"] },
+    ]);
+  });
+});
```

[Next page](https://git.cafe/pingdotgg/t3code/pull/12160/diffs?after=ImFwcHMvd2ViL3NyYy9jb21wb3Nlci1yaWNoLXRleHQudGVzdC50cyI&expectedVersion=31&format=markdown)
