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
+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;