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
@@ -0,0 +1,72 @@
import type { Editor } from "@tiptap/react";
import { Bold, Code, Italic, Link2, List, ListOrdered, Quote, Strikethrough, type LucideIcon } from "lucide-react";
type ComposerFormat = {
/**
* The mark or node it toggles, for `editor.isActive`.
*/
name: string;
title: string;
Icon: LucideIcon;
toggle: (params: { editor: Editor }) => void;
};
/**
* The formatting the composer's selection bubble offers, in order.
*/
export const ComposerFormats: ComposerFormat[] = [
{ name: "bold", title: "Bold", Icon: Bold, toggle: ({ editor }) => editor.chain().focus().toggleBold().run() },
{ name: "italic", title: "Italic", Icon: Italic, toggle: ({ editor }) => editor.chain().focus().toggleItalic().run() },
{
name: "strike",
title: "Strikethrough",
Icon: Strikethrough,
toggle: ({ editor }) => editor.chain().focus().toggleStrike().run(),
},
{ name: "code", title: "Code", Icon: Code, toggle: ({ editor }) => editor.chain().focus().toggleCode().run() },
{ name: "link", title: "Link", Icon: Link2, toggle: toggleLink },
{
name: "blockquote",
title: "Quote",
Icon: Quote,
toggle: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
},
{
name: "bulletList",
title: "Bulleted list",
Icon: List,
toggle: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
},
{
name: "orderedList",
title: "Numbered list",
Icon: ListOrdered,
toggle: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
},
];
/**
* Removes the selection's link, or asks for one to set: web and mail
* links only, `https://` added when there's no scheme.
*/
function toggleLink({ editor }: { editor: Editor }) {
if (editor.isActive("link")) {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
const href = window.prompt("Link address")?.trim();
if (!href) return;
const has_scheme = /^[a-z][a-z0-9+.-]*:/i.test(href);
if (has_scheme && !/^(https?|mailto):/i.test(href)) return;
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: has_scheme ? href : `https://${href}` })
.run();
}
@@ -0,0 +1,18 @@
/**
* What the composer can mention, by the character that starts it: `@` a
* bot (`[bot:ID]`), `#` a workspace (`[workspace:ID]`).
*/
export const ComposerMentionKinds = {
"@": "bot",
"#": "workspace",
} as const;
export type ComposerMentionChar = keyof typeof ComposerMentionKinds;
export type ComposerMentionKind = (typeof ComposerMentionKinds)[ComposerMentionChar];
/**
* The trigger character of a kind.
*/
export function composerMentionChar({ kind }: { kind: ComposerMentionKind }): ComposerMentionChar {
return kind == "workspace" ? "#" : "@";
}
@@ -0,0 +1,8 @@
import React from "react";
import type { WorkspaceWithActivity } from "@/src/types";
/**
* The live workspaces the user is in, so a workspace named in a message
* links to it and the composer can mention it.
*/
export const CrewWorkspacesContext = React.createContext<WorkspaceWithActivity[]>([]);
@@ -0,0 +1,51 @@
import type { Components } from "react-markdown";
import P from "@moduletrace/twui/layout/P";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import MarkdownList from "../(partials)/markdown-list";
import UserMarkdownLink from "../(partials)/user-markdown-link";
import { MessageMarkdownComponents } from "./message-markdown-components";
const Heading: Components["h1"] = ({ children }) => (
<P noMargin className="text-sm leading-5 font-bold text-on-ink">
{children}
</P>
);
/**
* The user's own message markdown, on the ink bubble: the chat's
* components (`MessageMarkdownComponents`) in the bubble's colours, and
* headings no bigger than the text. Code blocks and ref chips keep their
* own surface.
*/
export const UserMarkdownComponents: Components = {
...MessageMarkdownComponents,
p: ({ children }) => (
<P noMargin className="text-sm leading-5 text-on-ink">
{children}
</P>
),
h1: Heading,
h2: Heading,
h3: Heading,
h4: Heading,
h5: Heading,
h6: Heading,
a: UserMarkdownLink,
ul: ({ children }) => <MarkdownList className="text-on-ink">{children}</MarkdownList>,
ol: ({ children, start }) => (
<MarkdownList ordered start={start} className="text-on-ink">
{children}
</MarkdownList>
),
code: ({ children }) => (
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-on-ink/15 text-on-ink break-words">
{children}
</Span>
),
blockquote: ({ children }) => (
<Stack className="gap-2! items-stretch pl-3 border-0 border-l-2 border-solid border-on-ink/40 opacity-80">
{children}
</Stack>
),
};
@@ -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);
},
};
}
@@ -7,6 +7,7 @@ import type { BotMessageWithAttachments } from "@/src/types";
import linkComputerRefs from "@/src/utils/link-computer-refs";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
import { QuotableAttribute } from "../(hooks)/use-message-selection";
import GroupTimestamp from "./group-timestamp";
import MessageActions from "./message-actions";
import MessageReactions from "./message-reactions";
@@ -29,7 +30,7 @@ type Props = {
* rendered). Computers it names (`computerRef`) show as buttons that
* switch the bot to them. A quoted reply sits above it, its reactions
* below, and a row under it replies, reacts, reads it aloud and shows
* why it needs you.
* why it needs you. Its text can be selected and quoted.
*/
export default function BotMessageLine({ message, bot_name, at }: Props) {
const { onReact } = useContext(BotMessageActionsContext);
@@ -43,7 +44,10 @@ export default function BotMessageLine({ message, bot_name, at }: Props) {
bot_name={bot_name}
/>
) : null}
<Stack className="w-full gap-2.5! items-start break-words">
<Stack
{...{ [QuotableAttribute]: "" }}
className="w-full gap-2.5! items-start break-words"
>
<Markdown
remarkPlugins={[remarkGfm]}
components={MessageMarkdownComponents}
@@ -0,0 +1,27 @@
import { EditorContent, type Editor } from "@tiptap/react";
import ComposerFormatBubble from "./composer-format-bubble";
type Props = {
editor: Editor | null;
invalid?: boolean;
title?: string;
};
/**
* The composer's input (`useComposerEditor`), with the formatting bubble
* over a selection. It fills the row between the attach and voice
* buttons.
*/
export default function ComposerEditor({ editor, invalid, title }: Props) {
return (
<>
<EditorContent
editor={editor}
aria-invalid={invalid ? true : undefined}
title={title}
className="grow min-w-0 self-center"
/>
{editor ? <ComposerFormatBubble editor={editor} /> : null}
</>
);
}
@@ -0,0 +1,38 @@
import type { Editor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import Row from "@moduletrace/twui/layout/Row";
import ComposerFormatButton from "./composer-format-button";
import { ComposerFormats } from "../(data)/composer-formats";
type Props = {
editor: Editor;
};
/**
* Formatting for the selected text, floating over it: bold, italic,
* strike, code, link, quote and lists. The same marks have their usual
* shortcuts (Mod+B, Mod+I, ...) and markdown typing (`**`, `- `, `> `).
*/
export default function ComposerFormatBubble({ editor }: Props) {
return (
<BubbleMenu
editor={editor}
shouldShow={({ editor, from, to }) =>
from != to && !editor.isActive("codeBlock") && !editor.isActive("mention")
}
>
<Row className="gap-0.5! flex-nowrap items-center p-1 rounded-default border border-solid border-hairline bg-surface shadow-popover">
{ComposerFormats.map((format) => (
<ComposerFormatButton
key={format.name}
title={format.title}
active={editor.isActive(format.name)}
onClick={() => format.toggle({ editor })}
>
<format.Icon size={15} aria-hidden />
</ComposerFormatButton>
))}
</Row>
</BubbleMenu>
);
}
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import Button from "@moduletrace/twui/layout/Button";
type Props = {
title: string;
active: boolean;
onClick: () => void;
children: ReactNode;
};
/**
* One formatting toggle in the composer's selection bubble.
*/
export default function ComposerFormatButton({ title, active, onClick, children }: Props) {
return (
<Button
type="button"
variant="ghost"
color="text"
title={title}
aria-label={title}
aria-pressed={active}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={`shrink-0 p-1.5! rounded-default-xs ${active ? "bg-sunken text-ink" : "text-ink-secondary hover:text-ink"}`}
>
{children}
</Button>
);
}
@@ -0,0 +1,43 @@
import { useContext } from "react";
import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import { CrewBotsContext } from "../(data)/crew-bots-context";
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
/**
* A bot or workspace mentioned in the composer, shown like the chips in
* the thread (`BotRefChip`, `WorkspaceRefChip`) but not a link. One the
* user can't see any more keeps its ref's words.
*/
export default function ComposerMentionChip({ node, selected }: ReactNodeViewProps) {
const bots = useContext(CrewBotsContext);
const workspaces = useContext(CrewWorkspacesContext);
const id = Number(node.attrs.id);
const is_workspace = node.attrs.mentionSuggestionChar == "#";
const bot = is_workspace ? undefined : bots.find((b) => b.id == id);
const workspace = is_workspace ? workspaces.find((w) => w.id == id) : undefined;
const name = bot?.name || workspace?.name || node.attrs.label || `${is_workspace ? "workspace" : "bot"} ${id}`;
return (
<NodeViewWrapper
as="span"
data-mention=""
className={`inline-flex h-6 gap-1.5 items-center px-1.5 mx-px align-middle rounded-default-xs bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap ${selected ? "ring-2 ring-ink" : ""}`}
>
{bot ? (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
label={bot.name || ""}
size={18}
/>
) : null}
{workspace ? <WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" /> : null}
<Span>{name}</Span>
</NodeViewWrapper>
);
}
@@ -0,0 +1,40 @@
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import type { ComposerMentionMenu } from "../(hooks)/use-composer-editor";
import ComposerMentionOption from "./composer-mention-option";
type Props = {
menu: ComposerMentionMenu;
};
/**
* The bots (`@`) or workspaces (`#`) matching what's typed, just above
* the composer's input. Up and Down move, Enter or Tab picks, Escape
* closes; a click picks too.
*/
export default function ComposerMentionList({ menu }: Props) {
return (
<Stack
role="listbox"
aria-label={menu.char == "#" ? "Workspaces" : "Bots"}
className="w-full max-w-container mx-auto gap-0.5! items-stretch px-3 md:px-6 pt-3"
>
<Stack className="gap-0.5! items-stretch p-1 rounded-default border border-solid border-hairline bg-surface shadow-popover">
{menu.items.length ? (
menu.items.map((item, index) => (
<ComposerMentionOption
key={item.id}
item={item}
active={index == menu.index}
onPick={menu.select}
/>
))
) : (
<Span className="px-2 py-1.5 text-xs leading-4 text-ink-secondary">
{menu.char == "#" ? "No workspace by that name" : "No bot by that name"}
</Span>
)}
</Stack>
</Stack>
);
}
@@ -0,0 +1,51 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { ComposerMentionItem } from "../(utils)/search-mention-items";
type Props = {
item: ComposerMentionItem;
active: boolean;
onPick: (item: ComposerMentionItem) => void;
};
/**
* One bot or workspace in the mention menu, with its role or description.
*/
export default function ComposerMentionOption({ item, active, onPick }: Props) {
const { bot, workspace } = item;
const detail = bot?.role || workspace?.description || "";
return (
<Button
type="button"
variant="ghost"
color="text"
role="option"
aria-selected={active}
title={item.label}
onMouseDown={(e) => {
e.preventDefault();
onPick(item);
}}
className={`w-full justify-start! gap-2.5! px-2! py-1.5! rounded-default-sm text-left text-ink ${active ? "bg-sunken" : "hover:bg-sunken"}`}
>
{bot ? (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
label={bot.name || ""}
size={22}
/>
) : null}
{workspace ? <WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" /> : null}
<Span className="shrink-0 text-sm font-bold leading-5">{item.label}</Span>
{detail ? (
<Span className="min-w-0 truncate text-xs leading-4 text-ink-secondary">{detail}</Span>
) : null}
</Button>
);
}
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Textarea from "@moduletrace/twui/form/Textarea";
import P from "@moduletrace/twui/layout/P";
import type {
BotExtensionsPayload,
@@ -9,20 +8,34 @@ import type {
BotVoicePayload,
BotWithLastMessage,
} from "@/src/types";
import { CrewBotsContext } from "../(data)/crew-bots-context";
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
import sendBotMessage from "../(functions)/send-bot-message";
import transcribeVoice from "../(functions)/transcribe-voice";
import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft";
import useAutoGrowTextarea from "../(hooks)/use-auto-grow-textarea";
import useComposerEditor from "../(hooks)/use-composer-editor";
import useVoiceRecorder from "../(hooks)/use-voice-recorder";
import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments";
import ComposerEditor from "./composer-editor";
import ComposerMentionList from "./composer-mention-list";
import ComposerReplyPreview from "./composer-reply-preview";
import ComposerSendButton from "./composer-send-button";
import ComposerTools from "./composer-tools";
import ComposerVoiceButton from "./composer-voice-button";
/**
* What the conversation can do to the composer from outside it.
*/
export type ComposerHandle = {
/**
* Quotes text from the thread at the cursor.
*/
insertQuote: (params: { text: string }) => void;
};
type Props = {
bot: BotWithLastMessage;
/**
@@ -43,10 +56,13 @@ type Props = {
reply_to?: BotMessageWithAttachments | null;
onCancelReply?: () => void;
onSent: (params: { message: BotMessageWithAttachments }) => void;
handle_ref?: Ref<ComposerHandle>;
};
/**
* Enter sends, Shift+Enter adds a line. Grows to eight lines, then scrolls.
* A Tiptap editor (`useComposerEditor`) writing markdown: Enter sends,
* Shift+Enter adds a line, a selection can be formatted, `@` mentions a
* bot and `#` a workspace. Grows to eight lines, then scrolls.
* Its top rule lines up with the rail's user-row rule; picked files sit
* just above it. A click anywhere on the row focuses the input. Files can
* also be pasted or dropped in. Unsent text is kept per conversation
@@ -62,21 +78,53 @@ export default function Composer({
reply_to,
onCancelReply,
onSent,
handle_ref,
}: Props) {
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
const draft = useComposerDraft({ bot_id: bot.id, workspace_id });
const { text, setText } = draft;
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
const online = useOnlineStatus();
const input_ref = useRef<HTMLTextAreaElement>(null);
const bots = useContext(CrewBotsContext);
const workspaces = useContext(CrewWorkspacesContext);
const send_ref = useRef<() => void>(() => {});
useAutoGrowTextarea({ ref: input_ref, value: text });
const input = useComposerEditor({
placeholder: `Message ${bot.name}`,
bots,
workspaces,
onChange: ({ markdown }) => setText(markdown),
onSubmit: () => send_ref.current(),
onFiles: files.add,
});
const editor = input.editor;
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
/**
* The saved draft goes into the editor once per conversation, after
* it's read; typing saves it back from then on.
*/
const applied_key = useRef<string | null>(null);
useEffect(() => {
if (!editor || !draft.loaded || applied_key.current == draft.key) return;
applied_key.current = draft.key;
/** Out of React's render, since mention chips mount as it loads. */
queueMicrotask(() => {
input.setMarkdown({ markdown: text });
editor.commands.focus("end");
});
}, [editor, draft.loaded, draft.key]);
/**
* Picking a message to reply to puts the cursor back in the input.
*/
useEffect(() => {
if (reply_to) input_ref.current?.focus();
if (reply_to) editor?.commands.focus();
}, [reply_to]);
const recorder = useVoiceRecorder({
@@ -90,7 +138,7 @@ export default function Composer({
return;
}
insertText({ insert: res.singleRes.text });
input.insertText({ text: res.singleRes.text });
},
});
@@ -101,30 +149,6 @@ export default function Composer({
recorder.state == "idle" &&
online;
/**
* Puts dictated text at the cursor (or the end), spaced from what's
* around it, and leaves the cursor after it.
*/
function insertText({ insert }: { insert: string }) {
const el = input_ref.current;
const start = el ? el.selectionStart : text.length;
const end = el ? el.selectionEnd : text.length;
const before = text.slice(0, start);
const after = text.slice(end);
const piece =
(before && !/\s$/.test(before) ? " " : "") +
insert +
(after && !/^\s/.test(after) ? " " : "");
const cursor = before.length + piece.length;
setText(before + piece + after);
requestAnimationFrame(() => {
input_ref.current?.focus();
input_ref.current?.setSelectionRange(cursor, cursor);
});
}
async function send() {
if (!can_send || !bot.id) return;
@@ -147,18 +171,12 @@ export default function Composer({
}
setText("");
input.clear();
files.clear();
onSent({ message: res.singleRes });
}
function handleKeyDown(
e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
) {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
send();
}
}
send_ref.current = send;
return (
<Stack
@@ -167,7 +185,8 @@ export default function Composer({
if (e.dataTransfer.types.includes("Files")) e.preventDefault();
}}
onDrop={(e) => {
if (!e.dataTransfer.files.length) return;
/** Dropped on the editor, which took the files already. */
if (e.defaultPrevented || !e.dataTransfer.files.length) return;
e.preventDefault();
files.add({ files: [...e.dataTransfer.files] });
@@ -185,49 +204,22 @@ export default function Composer({
onCancel={onCancelReply}
/>
) : null}
{input.menu ? <ComposerMentionList menu={input.menu} /> : null}
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (target.closest("button, textarea")) return;
if (target.closest("button, [contenteditable]")) return;
e.preventDefault();
input_ref.current?.focus();
editor?.commands.focus();
}}
>
<ComposerAttachButton
disabled={files.full}
onFiles={files.add}
/>
<Textarea
name="message"
componentRef={input_ref}
aria-label={`Message ${bot.name}`}
placeholder={`Message ${bot.name}`}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
const pasted = [...e.clipboardData.files];
if (!pasted.length) return;
e.preventDefault();
files.add({ files: pasted });
}}
aria-invalid={error ? true : undefined}
title={error}
className="field-sizing-content min-h-5 max-h-[160px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{
className: "grow min-w-0 self-center z-auto! hover:z-auto!",
}}
wrapperProps={{
className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
}}
autoFocus
/>
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
{recorder.supported ? (
<ComposerVoiceButton
state={recorder.state}
@@ -1,15 +1,22 @@
import type { ComponentProps } from "react";
import Link from "@moduletrace/twui/layout/Link";
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
import { BotRefHrefPrefix, ComputerRefHrefPrefix, TakeOverRefHrefPrefix } from "@/src/utils/link-computer-refs";
import {
BotRefHrefPrefix,
ComputerRefHrefPrefix,
TakeOverRefHrefPrefix,
WorkspaceRefHrefPrefix,
} from "@/src/utils/link-computer-refs";
import BotRefChip from "./bot-ref-chip";
import ComputerRefChip from "./computer-ref-chip";
import TakeOverChip from "./take-over-chip";
import WorkspaceRefChip from "./workspace-ref-chip";
/**
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
* becomes its switch button, a take-over request a button that opens the
* computer, a bot it named a link to that bot's conversation; anything else opens in a new tab.
* computer, a bot it named a link to that bot's conversation, a workspace
* a link to that workspace; anything else opens in a new tab.
*/
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (href?.startsWith(ComputerRefHrefPrefix)) {
@@ -39,6 +46,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (bot_id) return <BotRefChip bot_id={bot_id} />;
}
if (href?.startsWith(WorkspaceRefHrefPrefix)) {
const workspace_id = Number(href.slice(WorkspaceRefHrefPrefix.length));
if (workspace_id) return <WorkspaceRefChip workspace_id={workspace_id} />;
}
return (
<Link
href={href}
@@ -1,17 +1,19 @@
import { Children, isValidElement, type ReactNode } from "react";
import List from "@moduletrace/twui/layout/List";
import { twMerge } from "tailwind-merge";
type Props = {
children?: ReactNode;
ordered?: boolean;
start?: number;
className?: string;
};
/**
* A list in a bot message, through twui's `List` (which takes items, not
* `li` children): each item's content is lifted out of its `li`.
*/
export default function MarkdownList({ children, ordered, start }: Props) {
export default function MarkdownList({ children, ordered, start, className }: Props) {
const items = Children.toArray(children)
.filter(isValidElement)
.map((item) => ({
@@ -23,7 +25,10 @@ export default function MarkdownList({ children, ordered, start }: Props) {
ordered={ordered}
start={start}
items={items}
className={`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`}
className={twMerge(
`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`,
className,
)}
/>
);
}
@@ -1,11 +1,14 @@
import { useRef, type RefObject } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type {
BotMessageWithAttachments,
BotWithLastMessage,
} from "@/src/types";
import useMessageSelection from "../(hooks)/use-message-selection";
import groupMessages from "../(utils)/group-messages";
import BotThinkingRow from "./bot-thinking-row";
import MessageGroupBlock from "./message-group-block";
import QuoteSelectionButton from "./quote-selection-button";
type Props = {
bot: BotWithLastMessage;
@@ -22,19 +25,27 @@ type Props = {
* This session has an active task.
*/
has_task?: boolean;
/**
* Quotes text selected in a message in the composer.
*/
onQuote?: (params: { text: string }) => void;
};
/**
* Bottom-anchored: the stack sits 24px above the composer rule and grows
* upward. Empty space belongs at the top. New messages announce politely.
* While the bot works on a reply or a task, a status row sits under the
* last one.
* last one. Text selected in a message can be quoted in the composer.
*/
export default function MessageThread({ bot, messages, progress, reply_progress, has_task }: Props) {
export default function MessageThread({ bot, messages, progress, reply_progress, has_task, onQuote }: Props) {
const groups = groupMessages({ messages });
const ref = useRef<HTMLDivElement>(null);
const { selection, clear } = useMessageSelection({ ref });
return (
<Stack className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
<Stack
componentRef={ref as RefObject<HTMLDivElement>}
className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
<Stack
role="log"
aria-live="polite"
@@ -56,6 +67,15 @@ export default function MessageThread({ bot, messages, progress, reply_progress,
has_task={has_task}
/>
</Stack>
{selection && onQuote ? (
<QuoteSelectionButton
selection={selection}
onQuote={() => {
onQuote({ text: selection.text });
clear();
}}
/>
) : null}
</Stack>
);
}
@@ -0,0 +1,50 @@
import { useLayoutEffect, useRef } from "react";
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
import Button from "@moduletrace/twui/layout/Button";
import { Quote } from "lucide-react";
import type { MessageSelection } from "../(hooks)/use-message-selection";
type Props = {
selection: MessageSelection;
onQuote: () => void;
};
/**
* "Quote", floating over text selected in a message: it puts the text in
* the composer as a quote. Pressing it keeps the selection, so the text
* it quotes is the one shown.
*/
export default function QuoteSelectionButton({ selection, onQuote }: Props) {
const ref = useRef<HTMLButtonElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
computePosition({ getBoundingClientRect: () => selection.rect }, el, {
strategy: "fixed",
placement: "top",
middleware: [offset(8), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
}, [selection.rect]);
return (
<Button
ref={ref}
type="button"
color="secondary"
title="Quote in your message"
onPointerDown={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
onClick={onQuote}
className="fixed left-0 top-0 z-50 gap-1.5! px-2.5! py-1.5! rounded-default-sm border border-solid border-hairline bg-surface shadow-popover text-xs font-bold text-ink hover:bg-sunken"
>
<Quote size={13} aria-hidden />
Quote
</Button>
);
}
@@ -1,12 +1,26 @@
import P from "@moduletrace/twui/layout/P";
import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import Stack from "@moduletrace/twui/layout/Stack";
import linkComputerRefs from "@/src/utils/link-computer-refs";
import { UserMarkdownComponents } from "../(data)/user-markdown-components";
import { QuotableAttribute } from "../(hooks)/use-message-selection";
/**
* Ink bubble with paper text; inverts in dark mode.
* Ink bubble with paper text; inverts in dark mode. The text is markdown
* (GFM; raw HTML is never rendered) as the composer writes it, a single
* line break kept as one, so older plain messages read as typed. Bots
* and workspaces it mentions show as chips.
*/
export default function UserBubble({ text }: { text: string }) {
return (
<P noMargin className="max-w-[80%] rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 whitespace-pre-wrap break-words">
{text}
</P>
<Stack
{...{ [QuotableAttribute]: "" }}
className="max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 break-words"
>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={UserMarkdownComponents}>
{linkComputerRefs({ text })}
</Markdown>
</Stack>
);
}
@@ -0,0 +1,22 @@
import type { ComponentProps } from "react";
import Link from "@moduletrace/twui/layout/Link";
import MarkdownLink from "./markdown-link";
/**
* A link in the user's own message: refs (bots, workspaces) as their
* chips (`MarkdownLink`), anything else underlined in the bubble's ink.
*/
export default function UserMarkdownLink(props: ComponentProps<"a">) {
if (props.href?.startsWith("#")) return <MarkdownLink {...props} />;
return (
<Link
href={props.href}
target="_blank"
rel="noopener noreferrer"
className="text-on-ink underline underline-offset-2 hover:opacity-80 break-words"
>
{props.children}
</Link>
);
}
@@ -0,0 +1,33 @@
import { useContext } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
type Props = {
workspace_id: number;
};
/**
* A workspace named in a message (`workspaceRef`): a link to its
* dashboard. One the user isn't in is shown by name only.
*/
export default function WorkspaceRefChip({ workspace_id }: Props) {
const workspaces = useContext(CrewWorkspacesContext);
const workspace = workspaces.find((w) => w.id == workspace_id);
if (!workspace) {
return <Span className="font-bold">{`workspace ${workspace_id}`}</Span>;
}
return (
<Link
href={`/admin/workspaces/${workspace.id}`}
title={`Open ${workspace.name}`}
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
>
<WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" />
{workspace.name}
</Link>
);
}
@@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import type {
@@ -9,8 +9,10 @@ import { AppContext } from "@/src/pages/__root";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import useLiveBots from "@/src/hooks/use-live-bots";
import useServerEvent from "@/src/hooks/use-server-event";
import useLiveWorkspaces from "@/src/layouts/bots/(hooks)/use-live-workspaces";
import { BotComputersContext } from "../(data)/bot-computers-context";
import { CrewBotsContext } from "../(data)/crew-bots-context";
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import { SpeakMessageContext } from "../(data)/speak-message-context";
import reactToBotMessage from "../(functions)/react-to-bot-message";
@@ -21,7 +23,7 @@ import useReplyCatchUp from "../(hooks)/use-reply-catch-up";
import useSpeakMessage from "../(hooks)/use-speak-message";
import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread";
import Composer from "../(partials)/composer";
import Composer, { type ComposerHandle } from "../(partials)/composer";
import ComputerViewerModal from "../(partials)/computer-viewer-modal";
/**
@@ -31,7 +33,8 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
* The bot's computers are shared with the header's modal and the computer
* buttons in the thread, so a switch from either shows in both; either
* can open one in the viewer. Bot messages can be read aloud, one at a
* time.
* time. Text selected in the thread can be quoted in the composer, which
* mentions the crew and workspaces the user sees.
*/
export default function Conversation() {
const { pageProps } = useContext(AppContext);
@@ -59,6 +62,11 @@ export default function Conversation() {
workspace_id,
});
const live_bot = bots.find((b) => b.id == bot?.id) || bot;
const workspaces = useLiveWorkspaces({
workspaces: pageProps?.workspaces || [],
open_workspace_id: workspace_id || undefined,
});
const composer = useRef<ComposerHandle>(null);
const bot_computers = useBotComputers({
bot_id: bot?.id || 0,
workspace_id,
@@ -158,6 +166,7 @@ export default function Conversation() {
return (
<CrewBotsContext.Provider value={bots}>
<CrewWorkspacesContext.Provider value={workspaces}>
<BotComputersContext.Provider value={bot_computers}>
<SpeakMessageContext.Provider value={speak}>
<BotMessageActionsContext.Provider
@@ -179,8 +188,10 @@ export default function Conversation() {
progress={progress.text}
reply_progress={reply_progress}
has_task={Boolean(progress.task_id)}
onQuote={({ text }) => composer.current?.insertQuote({ text })}
/>
<Composer
handle_ref={composer}
bot={bot}
workspace_id={workspace_id}
extensions={pageProps?.bot_extensions}
@@ -208,6 +219,7 @@ export default function Conversation() {
<ComputerViewerModal bot={live_bot} />
</SpeakMessageContext.Provider>
</BotComputersContext.Provider>
</CrewWorkspacesContext.Provider>
</CrewBotsContext.Provider>
);
}
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import Mention from "@tiptap/extension-mention";
import { MarkdownManager } from "@tiptap/markdown";
import StarterKit from "@tiptap/starter-kit";
import { MentionMarkdown, mentionRef } from "./mention-markdown";
const markdown = new MarkdownManager({
extensions: [StarterKit.configure({ heading: false }), Mention.extend(MentionMarkdown)],
});
const roundTrip = (text: string) => markdown.serialize(markdown.parse(text)).trim();
describe("mention markdown", () => {
test("reads bot and workspace refs as mentions", () => {
const doc = markdown.parse("Ask [bot:3] about [workspace:2]");
const nodes = doc.content?.[0]?.content || [];
const mentions = nodes.filter((node) => node.type == "mention");
expect(mentions.map((node) => [node.attrs?.id, node.attrs?.mentionSuggestionChar])).toEqual([
["3", "@"],
["2", "#"],
]);
});
test("writes them back as refs, marks staying on the text around them", () => {
expect(roundTrip("Ask [bot:3] about **[workspace:2] now**")).toBe("Ask [bot:3] about [workspace:2] **now**");
expect(roundTrip("_Ask_ [bot:3] `soon`")).toBe("*Ask* [bot:3] `soon`");
expect(roundTrip("> quoted\n\nreply to [bot:4]")).toBe("> quoted\n\nreply to [bot:4]");
});
test("leaves markdown links alone", () => {
const doc = markdown.parse("[bot:3](https://example.com)");
const nodes = doc.content?.[0]?.content || [];
expect(nodes.some((node) => node.type == "mention")).toBe(false);
});
test("mentionRef defaults to a bot", () => {
expect(mentionRef({ id: 5, char: "#" })).toBe("[workspace:5]");
expect(mentionRef({ id: 5 })).toBe("[bot:5]");
});
});
@@ -0,0 +1,59 @@
import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from "@tiptap/core";
import {
composerMentionChar,
ComposerMentionKinds,
type ComposerMentionChar,
} from "../(data)/composer-mention-kinds";
const RefPattern = /^\[(bot|workspace):(\d+)\](?!\()/;
/**
* How the composer's mention node reads and writes markdown: a bot as
* `[bot:3]` (`botRef`), a workspace as `[workspace:2]` (`workspaceRef`),
* so a draft or a sent message holds the same refs bots write. A ref
* followed by `(` is a markdown link, not a mention.
*/
export const MentionMarkdown = {
markdownTokenizer: {
name: "mention",
level: "inline" as const,
start(src: string) {
const match = src.match(/\[(bot|workspace):\d+\](?!\()/);
return match?.index ?? -1;
},
tokenize(src: string) {
const match = src.match(RefPattern);
if (!match) return undefined;
const kind = match[1] as "bot" | "workspace";
return {
type: "mention",
raw: match[0],
attributes: { id: match[2], mentionSuggestionChar: composerMentionChar({ kind }) },
};
},
},
parseMarkdown(token: MarkdownToken, h: MarkdownParseHelpers) {
return h.createNode("mention", { ...token.attributes, label: null });
},
renderMarkdown(node: JSONContent) {
return mentionRef({
id: node.attrs?.id,
char: node.attrs?.mentionSuggestionChar,
});
},
};
/**
* The ref a mention writes, `[bot:3]` unless it's a workspace's.
*/
export function mentionRef({ id, char }: { id?: string | number | null; char?: string | null }) {
const kind = ComposerMentionKinds[(char || "@") as ComposerMentionChar] || "bot";
return `[${kind}:${id}]`;
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import searchMentionItems from "./search-mention-items";
const bots = [
{ id: 1, name: "Cobble" },
{ id: 2, name: "Researcher" },
{ id: 3, name: "Archive reader" },
] as BotWithLastMessage[];
const workspaces = [
{ id: 1, name: "Launch", unread: 0 },
{ id: 2, name: "Old launch", archived: 1, unread: 0 },
] as WorkspaceWithActivity[];
describe("searchMentionItems", () => {
test("puts names starting with the query first", () => {
const items = searchMentionItems({ kind: "bot", query: "r", bots, workspaces });
expect(items.map((item) => item.id)).toEqual([2, 3]);
});
test("offers every bot for an empty query", () => {
expect(searchMentionItems({ kind: "bot", query: "", bots, workspaces })).toHaveLength(3);
});
test("leaves archived workspaces out", () => {
const items = searchMentionItems({ kind: "workspace", query: "launch", bots, workspaces });
expect(items.map((item) => item.label)).toEqual(["Launch"]);
});
});
@@ -0,0 +1,44 @@
import { AppData } from "@/src/data/app-data";
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import type { ComposerMentionKind } from "../(data)/composer-mention-kinds";
/**
* One thing the mention menu offers.
*/
export type ComposerMentionItem = {
id: number;
label: string;
bot?: BotWithLastMessage;
workspace?: WorkspaceWithActivity;
};
type Params = {
kind: ComposerMentionKind;
query: string;
bots: BotWithLastMessage[];
workspaces: WorkspaceWithActivity[];
};
/**
* The bots or workspaces whose name has what was typed, names starting
* with it first, at most `AppData.ComposerMentionMaxItems`.
*/
export default function searchMentionItems({ kind, query, bots, workspaces }: Params): ComposerMentionItem[] {
const items: ComposerMentionItem[] =
kind == "bot"
? bots
.filter((bot) => bot.id && bot.name)
.map((bot) => ({ id: Number(bot.id), label: String(bot.name), bot }))
: workspaces
.filter((workspace) => workspace.id && workspace.name && !workspace.archived)
.map((workspace) => ({ id: Number(workspace.id), label: String(workspace.name), workspace }));
const q = query.trim().toLowerCase();
return items
.map((item) => ({ item, at: item.label.toLowerCase().indexOf(q) }))
.filter(({ at }) => at >= 0)
.sort((a, b) => Number(a.at != 0) - Number(b.at != 0) || a.item.label.localeCompare(b.item.label))
.slice(0, AppData.ComposerMentionMaxItems)
.map(({ item }) => item);
}