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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user