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,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import describeMessageRefs, { type MessageRefNames } from "./describe-message-refs";
|
||||
import messageRefIds from "./message-ref-ids";
|
||||
|
||||
const names: MessageRefNames = {
|
||||
bots: new Map([[3, { name: "Ada", role: "Researcher" }], [4, { name: "Bo" }]]),
|
||||
workspaces: new Map([[2, { name: "Launch" }]]),
|
||||
};
|
||||
|
||||
describe("messageRefIds", () => {
|
||||
test("collects each bot and workspace once, links aside", () => {
|
||||
expect(
|
||||
messageRefIds({ texts: ["[bot:3] and [bot:3]", "[workspace:2] [bot:9](https://x.y)"] }),
|
||||
).toEqual({ bot_ids: [3], workspace_ids: [2] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeMessageRefs", () => {
|
||||
test("names what a message mentions", () => {
|
||||
expect(describeMessageRefs({ text: "Ask [bot:3] and [bot:4] in [workspace:2]", names })).toBe(
|
||||
'[Mentions: [bot:3] is Ada (Researcher); [bot:4] is Bo; [workspace:2] is the workspace "Launch"]',
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves out refs the user can't reach", () => {
|
||||
expect(describeMessageRefs({ text: "[bot:8] and [workspace:7]", names })).toBe("");
|
||||
expect(describeMessageRefs({ text: "no refs", names })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import messageRefIds from "./message-ref-ids";
|
||||
|
||||
/**
|
||||
* What a ref stands for: a bot's name and role, or a workspace's name.
|
||||
*/
|
||||
export type MessageRefNames = {
|
||||
bots: Map<number, { name: string; role?: string | null }>;
|
||||
workspaces: Map<number, { name: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A line telling the bot who and what a user message mentions, e.g.
|
||||
* `[Mentions: [bot:3] is Ada (Researcher); [workspace:2] is the workspace "Launch"]`.
|
||||
* Refs missing from `names` (the user can't reach them) are left out;
|
||||
* nothing is returned when none are known.
|
||||
*/
|
||||
export default function describeMessageRefs({ text, names }: { text: string; names: MessageRefNames }) {
|
||||
const { bot_ids, workspace_ids } = messageRefIds({ texts: [text] });
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const id of bot_ids) {
|
||||
const bot = names.bots.get(id);
|
||||
|
||||
if (bot) parts.push(`[bot:${id}] is ${bot.name}${bot.role ? ` (${bot.role})` : ""}`);
|
||||
}
|
||||
|
||||
for (const id of workspace_ids) {
|
||||
const workspace = names.workspaces.get(id);
|
||||
|
||||
if (workspace) parts.push(`[workspace:${id}] is the workspace "${workspace.name}"`);
|
||||
}
|
||||
|
||||
return parts.length ? `[Mentions: ${parts.join("; ")}]` : "";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import botRef from "./bot-ref";
|
||||
import workspaceRef from "./workspace-ref";
|
||||
import computerRef from "./computer-ref";
|
||||
import linkComputerRefs from "./link-computer-refs";
|
||||
import takeOverRef from "./take-over-ref";
|
||||
@@ -34,4 +35,12 @@ describe("linkComputerRefs", () => {
|
||||
}),
|
||||
).toBe("[bot 4](#bot-4) is on it. See [bot:x] and [bot:5](https://example.com).");
|
||||
});
|
||||
|
||||
test("links every workspace a message names", () => {
|
||||
expect(
|
||||
linkComputerRefs({
|
||||
text: `Added to ${workspaceRef({ id: 2 })}. Not [workspace:x] or [workspace:3](https://example.com).`,
|
||||
}),
|
||||
).toBe("Added to [workspace 2](#workspace-2). Not [workspace:x] or [workspace:3](https://example.com).");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,9 +18,16 @@ export const TakeOverRefHrefPrefix = "#take-over-";
|
||||
*/
|
||||
export const BotRefHrefPrefix = "#bot-";
|
||||
|
||||
/**
|
||||
* The link target a workspace ref (`workspaceRef`) becomes, e.g.
|
||||
* `#workspace-2`. The chat renders these as links to that workspace.
|
||||
*/
|
||||
export const WorkspaceRefHrefPrefix = "#workspace-";
|
||||
|
||||
/**
|
||||
* Turns every computer ref (`[computer:3]`) and take-over ref
|
||||
* (`[take-over:3:browser]`) and bot ref (`[bot:3]`) in a message into a markdown link, so it
|
||||
* (`[take-over:3:browser]`), bot ref (`[bot:3]`) and workspace ref
|
||||
* (`[workspace:2]`) in a message into a markdown link, so it
|
||||
* survives markdown rendering as a link the chat can swap for a button.
|
||||
*/
|
||||
export default function linkComputerRefs({ text }: { text: string }) {
|
||||
@@ -33,5 +40,9 @@ export default function linkComputerRefs({ text }: { text: string }) {
|
||||
/\[take-over:(\d+):([a-z]+)\](?!\()/g,
|
||||
(_, id: string, mode: string) => `[take over](${TakeOverRefHrefPrefix}${id}-${mode})`,
|
||||
)
|
||||
.replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => `[bot ${id}](${BotRefHrefPrefix}${id})`);
|
||||
.replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => `[bot ${id}](${BotRefHrefPrefix}${id})`)
|
||||
.replace(
|
||||
/\[workspace:(\d+)\](?!\()/g,
|
||||
(_, id: string) => `[workspace ${id}](${WorkspaceRefHrefPrefix}${id})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("messagePreview", () => {
|
||||
|
||||
test("names bots plainly", () => {
|
||||
expect(messagePreview({ text: "[bot:4] is on it." })).toBe("a bot is on it.");
|
||||
expect(messagePreview({ text: "Moved to [workspace:2]." })).toBe("Moved to a workspace.");
|
||||
});
|
||||
|
||||
test("keeps code text without its fences", () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* A bot message as one line of plain text, for the rail's preview: no
|
||||
* markdown syntax, links as their text, computer refs (`computerRef`) as
|
||||
* "a computer", bot refs (`botRef`) as "a bot", whitespace collapsed.
|
||||
* "a computer", bot refs (`botRef`) as "a bot", workspace refs
|
||||
* (`workspaceRef`) as "a workspace", whitespace collapsed.
|
||||
*/
|
||||
export default function messagePreview({ text }: { text: string }) {
|
||||
return text
|
||||
.replace(/```[^\n]*\n?/g, "")
|
||||
.replace(/\[computer:\d+\](?!\()/g, "a computer")
|
||||
.replace(/\[bot:\d+\](?!\()/g, "a bot")
|
||||
.replace(/\[workspace:\d+\](?!\()/g, "a workspace")
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/^\s{0,3}(#{1,6}\s+|>\s?|[-*+]\s+|\d+\.\s+)/gm, "")
|
||||
.replace(/(\*\*|__|~~|`)/g, "")
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The bots (`[bot:ID]`) and workspaces (`[workspace:ID]`) some messages
|
||||
* name, each id once. Refs that are markdown links (`[bot:3](...)`) don't
|
||||
* count.
|
||||
*/
|
||||
export default function messageRefIds({ texts }: { texts: string[] }) {
|
||||
const bot_ids = new Set<number>();
|
||||
const workspace_ids = new Set<number>();
|
||||
|
||||
for (const text of texts) {
|
||||
for (const [, kind, id] of text.matchAll(/\[(bot|workspace):(\d+)\](?!\()/g)) {
|
||||
(kind == "bot" ? bot_ids : workspace_ids).add(Number(id));
|
||||
}
|
||||
}
|
||||
|
||||
return { bot_ids: [...bot_ids], workspace_ids: [...workspace_ids] };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* How a message names a workspace: `[workspace:2]`. The chat shows it as a
|
||||
* link to that workspace.
|
||||
*/
|
||||
export default function workspaceRef({ id }: { id: number | string }) {
|
||||
return `[workspace:${id}]`;
|
||||
}
|
||||
Reference in New Issue
Block a user