Replace the composer textarea with a Tiptap markdown editor

The composer now writes markdown with bold, italic, strike, code,
links, quotes and lists, from shortcuts, markdown typing or a bubble
over the selection. @ mentions a bot and # a workspace, shown as chips
and saved as [bot:ID] / [workspace:ID]. Text selected in a message can
be quoted into the composer. User messages render as markdown, and
replies tell the bot who and what the user mentioned.

Adds an ESM stand-in for use-sync-external-store, since bunext keeps
React external in page bundles and the CommonJS original requires it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-20 05:48:23 +01:00
co-authored by Claude Opus 5.5
parent e2093b0df6
commit 1208eac6b7
51 changed files with 1618 additions and 126 deletions
@@ -1,22 +0,0 @@
import { useLayoutEffect, type RefObject } from "react";
type Params = {
ref: RefObject<HTMLTextAreaElement | null>;
value: string;
};
/**
* Grows a textarea with its text where `field-sizing: content` isn't
* supported (Firefox). Its CSS `max-height` still caps it, then it scrolls.
* Does nothing where the CSS handles it.
*/
export default function useAutoGrowTextarea({ ref, value }: Params) {
useLayoutEffect(() => {
const el = ref.current;
if (!el || CSS.supports("field-sizing", "content")) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [ref, value]);
}
@@ -7,13 +7,14 @@ type Params = {
};
/**
* The composer's text, kept in `localStorage` per bot and workspace until
* it's sent, so a reload or leaving the conversation doesn't lose it.
* Read after mount, since the page renders on the server first.
* The composer's text (markdown), kept in `localStorage` per bot and
* workspace until it's sent, so a reload or leaving the conversation
* doesn't lose it. Read after mount, since the page renders on the server
* first; `loaded` turns true once it has been, for that `key`.
*/
export default function useComposerDraft({ bot_id, workspace_id }: Params) {
const key = composerDraftKey({ bot_id, workspace_id, part: "text" });
const [draft, setDraft] = useState({ key, text: "" });
const [draft, setDraft] = useState({ key, text: "", loaded: false });
useEffect(() => {
let text = "";
@@ -22,11 +23,11 @@ export default function useComposerDraft({ bot_id, workspace_id }: Params) {
text = localStorage.getItem(key) || "";
} catch {}
setDraft({ key, text });
setDraft({ key, text, loaded: true });
}, [key]);
const setText = (text: string) => {
setDraft({ key, text });
setDraft({ key, text, loaded: true });
try {
if (text) localStorage.setItem(key, text);
@@ -34,8 +35,12 @@ export default function useComposerDraft({ bot_id, workspace_id }: Params) {
} catch {}
};
const current = draft.key == key;
return {
text: draft.key == key ? draft.text : "",
key,
text: current ? draft.text : "",
loaded: current && draft.loaded,
setText,
};
}
@@ -0,0 +1,300 @@
import { useEffect, useRef, useState } from "react";
import { Extension, ReactNodeViewRenderer, useEditor, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "@tiptap/markdown";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import {
ComposerMentionKinds,
type ComposerMentionChar,
} from "../(data)/composer-mention-kinds";
import ComposerMentionChip from "../(partials)/composer-mention-chip";
import { MentionMarkdown, mentionRef } from "../(utils)/mention-markdown";
import searchMentionItems, { type ComposerMentionItem } from "../(utils)/search-mention-items";
/**
* The open mention menu: what matches what's typed after `@` or `#`, the
* highlighted one, and how to pick one.
*/
export type ComposerMentionMenu = {
char: ComposerMentionChar;
items: ComposerMentionItem[];
index: number;
select: (item: ComposerMentionItem) => void;
};
type Params = {
placeholder: string;
bots: BotWithLastMessage[];
workspaces: WorkspaceWithActivity[];
/**
* The message as markdown, on every change.
*/
onChange: (params: { markdown: string }) => void;
onSubmit: () => void;
/**
* Files pasted or dropped in.
*/
onFiles: (params: { files: File[] }) => void;
};
/**
* The composer's editor (Tiptap): markdown in and out, with bold, italic,
* strike, code, lists, quotes and links (no headings), and mentions of
* bots (`@`) and workspaces (`#`) written as `[bot:ID]` and
* `[workspace:ID]`. Enter sends and Shift+Enter breaks the line, except
* in a code block or list, where Enter is the editor's and Mod+Enter
* sends. Enter picks from an open mention menu. Pasted text comes in
* plain, without another site's styling; pasted or dropped files go to
* `onFiles`.
*/
export default function useComposerEditor({
placeholder,
bots,
workspaces,
onChange,
onSubmit,
onFiles,
}: Params) {
const latest = useRef({ bots, workspaces, onChange, onSubmit, onFiles });
latest.current = { bots, workspaces, onChange, onSubmit, onFiles };
const [menu, setMenu] = useState<ComposerMentionMenu | null>(null);
const menu_ref = useRef<ComposerMentionMenu | null>(null);
const showMenu = (next: ComposerMentionMenu | null) => {
menu_ref.current = next;
setMenu(next);
};
const suggestion = (char: ComposerMentionChar): Omit<SuggestionOptions, "editor"> => ({
char,
allowSpaces: false,
items: ({ query }) =>
searchMentionItems({
kind: ComposerMentionKinds[char],
query,
bots: latest.current.bots,
workspaces: latest.current.workspaces,
}),
render: () => {
const open = (props: SuggestionProps<ComposerMentionItem>) => {
const index = menu_ref.current?.char == char ? menu_ref.current.index : 0;
showMenu({
char,
items: props.items,
index: Math.min(index, Math.max(props.items.length - 1, 0)),
select: (item) => props.command({ id: String(item.id), label: item.label }),
});
};
return {
onStart: open,
onUpdate: open,
onExit: () => showMenu(null),
onKeyDown: ({ event }) => moveMenu({ event }),
};
},
});
const moveMenu = ({ event }: { event: KeyboardEvent }) => {
const current = menu_ref.current;
if (!current?.items.length) return false;
const count = current.items.length;
if (event.key == "ArrowDown" || event.key == "ArrowUp") {
const step = event.key == "ArrowDown" ? 1 : -1;
showMenu({ ...current, index: (current.index + step + count) % count });
return true;
}
const picked = current.items[current.index];
if ((event.key == "Enter" || event.key == "Tab") && picked) {
current.select(picked);
return true;
}
return false;
};
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: false,
horizontalRule: false,
underline: false,
link: { openOnClick: false, autolink: true },
}),
Placeholder.configure({ placeholder }),
Markdown,
Mention.extend({
...MentionMarkdown,
addNodeView() {
return ReactNodeViewRenderer(ComposerMentionChip, { as: "span" });
},
}).configure({
renderText: ({ node }) =>
node.attrs.label
? `${node.attrs.mentionSuggestionChar || "@"}${node.attrs.label}`
: mentionRef({ id: node.attrs.id, char: node.attrs.mentionSuggestionChar }),
suggestions: [suggestion("@"), suggestion("#")],
}),
Extension.create({
name: "composerKeys",
addKeyboardShortcuts() {
const editorOwnsEnter = () =>
this.editor.isActive("codeBlock") || this.editor.isActive("listItem");
return {
Enter: () => {
if (menu_ref.current?.items.length || this.editor.view.composing) return false;
if (editorOwnsEnter()) return false;
latest.current.onSubmit();
return true;
},
"Mod-Enter": () => {
latest.current.onSubmit();
return true;
},
};
},
}),
],
editorProps: {
attributes: {
role: "textbox",
"aria-multiline": "true",
"aria-label": placeholder,
class: ComposerEditorClassName,
},
handlePaste: (view, event) => {
const files = [...(event.clipboardData?.files || [])];
if (files.length) {
latest.current.onFiles({ files });
return true;
}
const html = event.clipboardData?.getData("text/html") || "";
const text = event.clipboardData?.getData("text/plain") || "";
/** Our own copies keep their formatting; other sites' don't. */
if (!html || html.includes("data-pm-slice") || !text) return false;
return view.pasteText(text, event);
},
handleDrop: (_view, event) => {
const files = [...(event.dataTransfer?.files || [])];
if (!files.length) return false;
event.preventDefault();
latest.current.onFiles({ files });
return true;
},
},
onUpdate: ({ editor }) => {
latest.current.onChange({ markdown: editorMarkdown({ editor }) });
},
});
/**
* Picking a mention with the pointer leaves the menu's state behind if
* the editor goes before the suggestion exits.
*/
useEffect(() => () => showMenu(null), [editor]);
/**
* Replaces the content with a draft, without reporting it as a change.
*/
const setMarkdown = ({ markdown }: { markdown: string }) => {
editor?.commands.setContent(markdown, { contentType: "markdown", emitUpdate: false });
};
const clear = () => {
editor?.commands.clearContent(true);
};
/**
* Puts dictated text at the cursor, spaced from what's around it.
*/
const insertText = ({ text }: { text: string }) => {
if (!editor) return;
const { from, to } = editor.state.selection;
const before = editor.state.doc.textBetween(Math.max(0, from - 1), from, "\n", " ");
const after = editor.state.doc.textBetween(to, Math.min(editor.state.doc.content.size, to + 1), "\n", " ");
const piece =
(before && !/\s$/.test(before) ? " " : "") + text + (after && !/^\s/.test(after) ? " " : "");
editor.chain().focus().insertContent(piece).run();
};
/**
* Quotes text (one blockquote, a paragraph per line) at the cursor,
* after any text selected in the editor (never over it), and leaves the
* cursor in a fresh line after it.
*/
const insertQuote = ({ text }: { text: string }) => {
if (!editor) return;
const lines = text.replace(/\r\n?/g, "\n").trim().split("\n");
if (!lines.join("").trim()) return;
editor
.chain()
.focus()
.setTextSelection(editor.state.selection.to)
.insertContent([
{
type: "blockquote",
content: lines.map((line) => ({
type: "paragraph",
content: line.trim() ? [{ type: "text", text: line }] : [],
})),
},
{ type: "paragraph" },
])
.run();
};
return { editor, menu, setMarkdown, clear, insertText, insertQuote };
}
/**
* The editor's markdown with Tiptap's empty-paragraph `&nbsp;` dropped and
* blank lines at the ends trimmed.
*/
function editorMarkdown({ editor }: { editor: Editor }) {
if (editor.isEmpty) return "";
return editor
.getMarkdown()
.replace(/^&nbsp;$/gm, "")
.replace(/^\s*\n|\n\s*$/g, "")
.trimEnd();
}
/**
* The editable area: the textarea's old type and growth (to 160px, then
* it scrolls), with the chat's markdown look for what's formatted.
*/
const ComposerEditorClassName = [
"min-h-5 max-h-[160px] overflow-y-auto outline-none text-sm leading-5 text-ink break-words",
"[&_p]:m-0 [&_strong]:font-bold [&_em]:italic [&_s]:line-through [&_a]:underline [&_a]:underline-offset-2",
"[&_code]:font-mono [&_code]:text-[13px] [&_code]:px-1 [&_code]:rounded-default-xs [&_code]:bg-sunken",
"[&_pre]:my-1 [&_pre]:p-3 [&_pre]:rounded-default-sm [&_pre]:bg-sunken [&_pre]:whitespace-pre-wrap [&_pre_code]:p-0 [&_pre_code]:bg-transparent",
"[&_ul]:my-0 [&_ul]:pl-5 [&_ul]:list-disc [&_ol]:my-0 [&_ol]:pl-5 [&_ol]:list-decimal",
"[&_blockquote]:my-1 [&_blockquote]:pl-3 [&_blockquote]:border-0 [&_blockquote]:border-l-2 [&_blockquote]:border-solid [&_blockquote]:border-hairline-strong [&_blockquote]:text-ink-secondary",
"[&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:text-ink-secondary",
].join(" ");
@@ -0,0 +1,69 @@
import { useEffect, useState, type RefObject } from "react";
/**
* Text selected inside one message, and where it sits on screen.
*/
export type MessageSelection = {
text: string;
rect: DOMRect;
};
/**
* Attribute on a message's text, marking what can be quoted.
*/
export const QuotableAttribute = "data-quotable";
type Params = {
ref: RefObject<HTMLElement | null>;
};
/**
* The text the user has selected in a message of the thread under `ref`,
* or null. A selection that runs across messages, or out of the thread,
* doesn't count.
*/
export default function useMessageSelection({ ref }: Params) {
const [selection, setSelection] = useState<MessageSelection | null>(null);
useEffect(() => {
const read = () => {
const picked = window.getSelection();
const text = picked?.toString().trim() || "";
if (!picked || picked.isCollapsed || !picked.rangeCount || !text) {
setSelection(null);
return;
}
const range = picked.getRangeAt(0);
const quotable = (node: Node | null) =>
(node instanceof Element ? node : node?.parentElement)?.closest(`[${QuotableAttribute}]`);
const start = quotable(range.startContainer);
if (!start || start != quotable(range.endContainer) || !ref.current?.contains(start)) {
setSelection(null);
return;
}
setSelection({ text, rect: range.getBoundingClientRect() });
};
document.addEventListener("selectionchange", read);
ref.current?.addEventListener("scroll", read, { passive: true });
const scroller = ref.current;
return () => {
document.removeEventListener("selectionchange", read);
scroller?.removeEventListener("scroll", read);
};
}, [ref]);
return {
selection,
clear: () => {
window.getSelection()?.removeAllRanges();
setSelection(null);
},
};
}