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
@@ -133,3 +133,17 @@ describe("botMessagesToHarness with files", () => {
).toEqual([{ role: "user", content: `[Attached earlier: "shot.png"]` }]);
});
});
describe("botMessagesToHarness with mentions", () => {
test("names the bots and workspaces a user message mentions", () => {
expect(
botMessagesToHarness({
messages: [message("user", "ask [bot:3]"), message("bot", "[bot:3] is on it")],
refs: { bots: new Map([[3, { name: "Ada" }]]), workspaces: new Map() },
}),
).toEqual([
{ role: "user", content: "ask [bot:3]\n\n[Mentions: [bot:3] is Ada]" },
{ role: "assistant", content: "[bot:3] is on it" },
]);
});
});
+13 -3
View File
@@ -1,5 +1,6 @@
import type { HarnessContentPart, HarnessMessage } from "@/src/harness/types";
import type { BotMessageWithAttachments } from "@/src/types";
import describeMessageRefs, { type MessageRefNames } from "@/src/utils/describe-message-refs";
type Params = {
messages: Pick<
@@ -11,6 +12,11 @@ type Params = {
* messages are only named.
*/
parts?: Map<number, HarnessContentPart[]>;
/**
* Names of the bots and workspaces the user's messages mention
* (`grabMessageRefNames`), told to the bot after each one.
*/
refs?: MessageRefNames;
};
type Turn = { role: "user" | "assistant"; parts: HarnessContentPart[] };
@@ -22,12 +28,12 @@ type Turn = { role: "user" | "assistant"; parts: HarnessContentPart[] };
* repeated roles. Leading bot messages are dropped so the conversation
* starts with the user. A turn that is only text stays a string.
*/
export default function botMessagesToHarness({ messages, parts }: Params): HarnessMessage[] {
export default function botMessagesToHarness({ messages, parts, refs }: Params): HarnessMessage[] {
const turns: Turn[] = [];
for (const message of messages) {
const role = message.author == "user" ? "user" : "assistant";
const content = messageParts({ message, role, parts });
const content = messageParts({ message, role, parts, refs });
if (!content.length) continue;
if (!turns.length && role == "assistant") continue;
@@ -59,12 +65,16 @@ function messageParts({
message,
role,
parts,
refs,
}: {
message: Params["messages"][number];
role: Turn["role"];
parts?: Params["parts"];
refs?: Params["refs"];
}): HarnessContentPart[] {
const text = replyContext({ message, text: message.text?.trim() || "" });
const own = message.text?.trim() || "";
const mentions = role == "user" && refs && own ? describeMessageRefs({ text: own, names: refs }) : "";
const text = replyContext({ message, text: mentions ? `${own}\n\n${mentions}` : own });
const content: HarnessContentPart[] = text ? [{ type: "text", text }] : [];
if (role == "assistant" || !message.attachments?.length) return content;
+5
View File
@@ -17,6 +17,7 @@ import composeComputersNote from "./compose-computers-note";
import grabSessionCarryOver from "./grab-session-carry-over";
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import loadAttachmentParts from "./load-attachment-parts";
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
@@ -124,6 +125,10 @@ export default async function runBotReply({
messages: botMessagesToHarness({
messages: history,
parts: await loadAttachmentParts({ messages: history }),
refs: await grabMessageRefNames({
texts: history.filter((m) => m.author == "user").map((m) => m.text || ""),
user_id,
}),
}),
tools: [...tools, ...extensions.tools],
max_steps: AppData.BotReplyMaxToolSteps,