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,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);
}