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
@@ -1,11 +1,14 @@
import { useRef, type RefObject } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type {
BotMessageWithAttachments,
BotWithLastMessage,
} from "@/src/types";
import useMessageSelection from "../(hooks)/use-message-selection";
import groupMessages from "../(utils)/group-messages";
import BotThinkingRow from "./bot-thinking-row";
import MessageGroupBlock from "./message-group-block";
import QuoteSelectionButton from "./quote-selection-button";
type Props = {
bot: BotWithLastMessage;
@@ -22,19 +25,27 @@ type Props = {
* This session has an active task.
*/
has_task?: boolean;
/**
* Quotes text selected in a message in the composer.
*/
onQuote?: (params: { text: string }) => void;
};
/**
* Bottom-anchored: the stack sits 24px above the composer rule and grows
* upward. Empty space belongs at the top. New messages announce politely.
* While the bot works on a reply or a task, a status row sits under the
* last one.
* last one. Text selected in a message can be quoted in the composer.
*/
export default function MessageThread({ bot, messages, progress, reply_progress, has_task }: Props) {
export default function MessageThread({ bot, messages, progress, reply_progress, has_task, onQuote }: Props) {
const groups = groupMessages({ messages });
const ref = useRef<HTMLDivElement>(null);
const { selection, clear } = useMessageSelection({ ref });
return (
<Stack className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
<Stack
componentRef={ref as RefObject<HTMLDivElement>}
className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
<Stack
role="log"
aria-live="polite"
@@ -56,6 +67,15 @@ export default function MessageThread({ bot, messages, progress, reply_progress,
has_task={has_task}
/>
</Stack>
{selection && onQuote ? (
<QuoteSelectionButton
selection={selection}
onQuote={() => {
onQuote({ text: selection.text });
clear();
}}
/>
) : null}
</Stack>
);
}