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:
@@ -7,6 +7,7 @@ import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
|
||||
import { QuotableAttribute } from "../(hooks)/use-message-selection";
|
||||
import GroupTimestamp from "./group-timestamp";
|
||||
import MessageActions from "./message-actions";
|
||||
import MessageReactions from "./message-reactions";
|
||||
@@ -29,7 +30,7 @@ type Props = {
|
||||
* rendered). Computers it names (`computerRef`) show as buttons that
|
||||
* switch the bot to them. A quoted reply sits above it, its reactions
|
||||
* below, and a row under it replies, reacts, reads it aloud and shows
|
||||
* why it needs you.
|
||||
* why it needs you. Its text can be selected and quoted.
|
||||
*/
|
||||
export default function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
const { onReact } = useContext(BotMessageActionsContext);
|
||||
@@ -43,7 +44,10 @@ export default function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
bot_name={bot_name}
|
||||
/>
|
||||
) : null}
|
||||
<Stack className="w-full gap-2.5! items-start break-words">
|
||||
<Stack
|
||||
{...{ [QuotableAttribute]: "" }}
|
||||
className="w-full gap-2.5! items-start break-words"
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={MessageMarkdownComponents}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EditorContent, type Editor } from "@tiptap/react";
|
||||
import ComposerFormatBubble from "./composer-format-bubble";
|
||||
|
||||
type Props = {
|
||||
editor: Editor | null;
|
||||
invalid?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's input (`useComposerEditor`), with the formatting bubble
|
||||
* over a selection. It fills the row between the attach and voice
|
||||
* buttons.
|
||||
*/
|
||||
export default function ComposerEditor({ editor, invalid, title }: Props) {
|
||||
return (
|
||||
<>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
title={title}
|
||||
className="grow min-w-0 self-center"
|
||||
/>
|
||||
{editor ? <ComposerFormatBubble editor={editor} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import ComposerFormatButton from "./composer-format-button";
|
||||
import { ComposerFormats } from "../(data)/composer-formats";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatting for the selected text, floating over it: bold, italic,
|
||||
* strike, code, link, quote and lists. The same marks have their usual
|
||||
* shortcuts (Mod+B, Mod+I, ...) and markdown typing (`**`, `- `, `> `).
|
||||
*/
|
||||
export default function ComposerFormatBubble({ editor }: Props) {
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={({ editor, from, to }) =>
|
||||
from != to && !editor.isActive("codeBlock") && !editor.isActive("mention")
|
||||
}
|
||||
>
|
||||
<Row className="gap-0.5! flex-nowrap items-center p-1 rounded-default border border-solid border-hairline bg-surface shadow-popover">
|
||||
{ComposerFormats.map((format) => (
|
||||
<ComposerFormatButton
|
||||
key={format.name}
|
||||
title={format.title}
|
||||
active={editor.isActive(format.name)}
|
||||
onClick={() => format.toggle({ editor })}
|
||||
>
|
||||
<format.Icon size={15} aria-hidden />
|
||||
</ComposerFormatButton>
|
||||
))}
|
||||
</Row>
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* One formatting toggle in the composer's selection bubble.
|
||||
*/
|
||||
export default function ComposerFormatButton({ title, active, onClick, children }: Props) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-pressed={active}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={`shrink-0 p-1.5! rounded-default-xs ${active ? "bg-sunken text-ink" : "text-ink-secondary hover:text-ink"}`}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useContext } from "react";
|
||||
import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
|
||||
|
||||
/**
|
||||
* A bot or workspace mentioned in the composer, shown like the chips in
|
||||
* the thread (`BotRefChip`, `WorkspaceRefChip`) but not a link. One the
|
||||
* user can't see any more keeps its ref's words.
|
||||
*/
|
||||
export default function ComposerMentionChip({ node, selected }: ReactNodeViewProps) {
|
||||
const bots = useContext(CrewBotsContext);
|
||||
const workspaces = useContext(CrewWorkspacesContext);
|
||||
const id = Number(node.attrs.id);
|
||||
const is_workspace = node.attrs.mentionSuggestionChar == "#";
|
||||
const bot = is_workspace ? undefined : bots.find((b) => b.id == id);
|
||||
const workspace = is_workspace ? workspaces.find((w) => w.id == id) : undefined;
|
||||
const name = bot?.name || workspace?.name || node.attrs.label || `${is_workspace ? "workspace" : "bot"} ${id}`;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
as="span"
|
||||
data-mention=""
|
||||
className={`inline-flex h-6 gap-1.5 items-center px-1.5 mx-px align-middle rounded-default-xs bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap ${selected ? "ring-2 ring-ink" : ""}`}
|
||||
>
|
||||
{bot ? (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
label={bot.name || ""}
|
||||
size={18}
|
||||
/>
|
||||
) : null}
|
||||
{workspace ? <WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" /> : null}
|
||||
<Span>{name}</Span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ComposerMentionMenu } from "../(hooks)/use-composer-editor";
|
||||
import ComposerMentionOption from "./composer-mention-option";
|
||||
|
||||
type Props = {
|
||||
menu: ComposerMentionMenu;
|
||||
};
|
||||
|
||||
/**
|
||||
* The bots (`@`) or workspaces (`#`) matching what's typed, just above
|
||||
* the composer's input. Up and Down move, Enter or Tab picks, Escape
|
||||
* closes; a click picks too.
|
||||
*/
|
||||
export default function ComposerMentionList({ menu }: Props) {
|
||||
return (
|
||||
<Stack
|
||||
role="listbox"
|
||||
aria-label={menu.char == "#" ? "Workspaces" : "Bots"}
|
||||
className="w-full max-w-container mx-auto gap-0.5! items-stretch px-3 md:px-6 pt-3"
|
||||
>
|
||||
<Stack className="gap-0.5! items-stretch p-1 rounded-default border border-solid border-hairline bg-surface shadow-popover">
|
||||
{menu.items.length ? (
|
||||
menu.items.map((item, index) => (
|
||||
<ComposerMentionOption
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={index == menu.index}
|
||||
onPick={menu.select}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Span className="px-2 py-1.5 text-xs leading-4 text-ink-secondary">
|
||||
{menu.char == "#" ? "No workspace by that name" : "No bot by that name"}
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { ComposerMentionItem } from "../(utils)/search-mention-items";
|
||||
|
||||
type Props = {
|
||||
item: ComposerMentionItem;
|
||||
active: boolean;
|
||||
onPick: (item: ComposerMentionItem) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One bot or workspace in the mention menu, with its role or description.
|
||||
*/
|
||||
export default function ComposerMentionOption({ item, active, onPick }: Props) {
|
||||
const { bot, workspace } = item;
|
||||
const detail = bot?.role || workspace?.description || "";
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
title={item.label}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onPick(item);
|
||||
}}
|
||||
className={`w-full justify-start! gap-2.5! px-2! py-1.5! rounded-default-sm text-left text-ink ${active ? "bg-sunken" : "hover:bg-sunken"}`}
|
||||
>
|
||||
{bot ? (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
label={bot.name || ""}
|
||||
size={22}
|
||||
/>
|
||||
) : null}
|
||||
{workspace ? <WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" /> : null}
|
||||
<Span className="shrink-0 text-sm font-bold leading-5">{item.label}</Span>
|
||||
{detail ? (
|
||||
<Span className="min-w-0 truncate text-xs leading-4 text-ink-secondary">{detail}</Span>
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import type {
|
||||
BotExtensionsPayload,
|
||||
@@ -9,20 +8,34 @@ import type {
|
||||
BotVoicePayload,
|
||||
BotWithLastMessage,
|
||||
} from "@/src/types";
|
||||
import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
|
||||
import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import transcribeVoice from "../(functions)/transcribe-voice";
|
||||
import useComposerAttachments from "../(hooks)/use-composer-attachments";
|
||||
import useComposerDraft from "../(hooks)/use-composer-draft";
|
||||
import useAutoGrowTextarea from "../(hooks)/use-auto-grow-textarea";
|
||||
import useComposerEditor from "../(hooks)/use-composer-editor";
|
||||
import useVoiceRecorder from "../(hooks)/use-voice-recorder";
|
||||
import useOnlineStatus from "@/src/hooks/use-online-status";
|
||||
import ComposerAttachButton from "./composer-attach-button";
|
||||
import ComposerAttachments from "./composer-attachments";
|
||||
import ComposerEditor from "./composer-editor";
|
||||
import ComposerMentionList from "./composer-mention-list";
|
||||
import ComposerReplyPreview from "./composer-reply-preview";
|
||||
import ComposerSendButton from "./composer-send-button";
|
||||
import ComposerTools from "./composer-tools";
|
||||
import ComposerVoiceButton from "./composer-voice-button";
|
||||
|
||||
/**
|
||||
* What the conversation can do to the composer from outside it.
|
||||
*/
|
||||
export type ComposerHandle = {
|
||||
/**
|
||||
* Quotes text from the thread at the cursor.
|
||||
*/
|
||||
insertQuote: (params: { text: string }) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
@@ -43,10 +56,13 @@ type Props = {
|
||||
reply_to?: BotMessageWithAttachments | null;
|
||||
onCancelReply?: () => void;
|
||||
onSent: (params: { message: BotMessageWithAttachments }) => void;
|
||||
handle_ref?: Ref<ComposerHandle>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enter sends, Shift+Enter adds a line. Grows to eight lines, then scrolls.
|
||||
* A Tiptap editor (`useComposerEditor`) writing markdown: Enter sends,
|
||||
* Shift+Enter adds a line, a selection can be formatted, `@` mentions a
|
||||
* bot and `#` a workspace. Grows to eight lines, then scrolls.
|
||||
* Its top rule lines up with the rail's user-row rule; picked files sit
|
||||
* just above it. A click anywhere on the row focuses the input. Files can
|
||||
* also be pasted or dropped in. Unsent text is kept per conversation
|
||||
@@ -62,21 +78,53 @@ export default function Composer({
|
||||
reply_to,
|
||||
onCancelReply,
|
||||
onSent,
|
||||
handle_ref,
|
||||
}: Props) {
|
||||
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
|
||||
const draft = useComposerDraft({ bot_id: bot.id, workspace_id });
|
||||
const { text, setText } = draft;
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
|
||||
const online = useOnlineStatus();
|
||||
const input_ref = useRef<HTMLTextAreaElement>(null);
|
||||
const bots = useContext(CrewBotsContext);
|
||||
const workspaces = useContext(CrewWorkspacesContext);
|
||||
const send_ref = useRef<() => void>(() => {});
|
||||
|
||||
useAutoGrowTextarea({ ref: input_ref, value: text });
|
||||
const input = useComposerEditor({
|
||||
placeholder: `Message ${bot.name}`,
|
||||
bots,
|
||||
workspaces,
|
||||
onChange: ({ markdown }) => setText(markdown),
|
||||
onSubmit: () => send_ref.current(),
|
||||
onFiles: files.add,
|
||||
});
|
||||
const editor = input.editor;
|
||||
|
||||
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
|
||||
|
||||
/**
|
||||
* The saved draft goes into the editor once per conversation, after
|
||||
* it's read; typing saves it back from then on.
|
||||
*/
|
||||
const applied_key = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !draft.loaded || applied_key.current == draft.key) return;
|
||||
|
||||
applied_key.current = draft.key;
|
||||
|
||||
/** Out of React's render, since mention chips mount as it loads. */
|
||||
queueMicrotask(() => {
|
||||
input.setMarkdown({ markdown: text });
|
||||
editor.commands.focus("end");
|
||||
});
|
||||
}, [editor, draft.loaded, draft.key]);
|
||||
|
||||
/**
|
||||
* Picking a message to reply to puts the cursor back in the input.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (reply_to) input_ref.current?.focus();
|
||||
if (reply_to) editor?.commands.focus();
|
||||
}, [reply_to]);
|
||||
|
||||
const recorder = useVoiceRecorder({
|
||||
@@ -90,7 +138,7 @@ export default function Composer({
|
||||
return;
|
||||
}
|
||||
|
||||
insertText({ insert: res.singleRes.text });
|
||||
input.insertText({ text: res.singleRes.text });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -101,30 +149,6 @@ export default function Composer({
|
||||
recorder.state == "idle" &&
|
||||
online;
|
||||
|
||||
/**
|
||||
* Puts dictated text at the cursor (or the end), spaced from what's
|
||||
* around it, and leaves the cursor after it.
|
||||
*/
|
||||
function insertText({ insert }: { insert: string }) {
|
||||
const el = input_ref.current;
|
||||
const start = el ? el.selectionStart : text.length;
|
||||
const end = el ? el.selectionEnd : text.length;
|
||||
const before = text.slice(0, start);
|
||||
const after = text.slice(end);
|
||||
const piece =
|
||||
(before && !/\s$/.test(before) ? " " : "") +
|
||||
insert +
|
||||
(after && !/^\s/.test(after) ? " " : "");
|
||||
const cursor = before.length + piece.length;
|
||||
|
||||
setText(before + piece + after);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
input_ref.current?.focus();
|
||||
input_ref.current?.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!can_send || !bot.id) return;
|
||||
|
||||
@@ -147,18 +171,12 @@ export default function Composer({
|
||||
}
|
||||
|
||||
setText("");
|
||||
input.clear();
|
||||
files.clear();
|
||||
onSent({ message: res.singleRes });
|
||||
}
|
||||
|
||||
function handleKeyDown(
|
||||
e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}
|
||||
send_ref.current = send;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -167,7 +185,8 @@ export default function Composer({
|
||||
if (e.dataTransfer.types.includes("Files")) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!e.dataTransfer.files.length) return;
|
||||
/** Dropped on the editor, which took the files already. */
|
||||
if (e.defaultPrevented || !e.dataTransfer.files.length) return;
|
||||
|
||||
e.preventDefault();
|
||||
files.add({ files: [...e.dataTransfer.files] });
|
||||
@@ -185,49 +204,22 @@ export default function Composer({
|
||||
onCancel={onCancelReply}
|
||||
/>
|
||||
) : null}
|
||||
{input.menu ? <ComposerMentionList menu={input.menu} /> : null}
|
||||
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target.closest("button, textarea")) return;
|
||||
if (target.closest("button, [contenteditable]")) return;
|
||||
|
||||
e.preventDefault();
|
||||
input_ref.current?.focus();
|
||||
editor?.commands.focus();
|
||||
}}
|
||||
>
|
||||
<ComposerAttachButton
|
||||
disabled={files.full}
|
||||
onFiles={files.add}
|
||||
/>
|
||||
<Textarea
|
||||
name="message"
|
||||
componentRef={input_ref}
|
||||
aria-label={`Message ${bot.name}`}
|
||||
placeholder={`Message ${bot.name}`}
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
const pasted = [...e.clipboardData.files];
|
||||
|
||||
if (!pasted.length) return;
|
||||
|
||||
e.preventDefault();
|
||||
files.add({ files: pasted });
|
||||
}}
|
||||
aria-invalid={error ? true : undefined}
|
||||
title={error}
|
||||
className="field-sizing-content min-h-5 max-h-[160px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
|
||||
wrapperWrapperProps={{
|
||||
className: "grow min-w-0 self-center z-auto! hover:z-auto!",
|
||||
}}
|
||||
wrapperProps={{
|
||||
className:
|
||||
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
|
||||
{recorder.supported ? (
|
||||
<ComposerVoiceButton
|
||||
state={recorder.state}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
|
||||
import { BotRefHrefPrefix, ComputerRefHrefPrefix, TakeOverRefHrefPrefix } from "@/src/utils/link-computer-refs";
|
||||
import {
|
||||
BotRefHrefPrefix,
|
||||
ComputerRefHrefPrefix,
|
||||
TakeOverRefHrefPrefix,
|
||||
WorkspaceRefHrefPrefix,
|
||||
} from "@/src/utils/link-computer-refs";
|
||||
import BotRefChip from "./bot-ref-chip";
|
||||
import ComputerRefChip from "./computer-ref-chip";
|
||||
import TakeOverChip from "./take-over-chip";
|
||||
import WorkspaceRefChip from "./workspace-ref-chip";
|
||||
|
||||
/**
|
||||
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
|
||||
* becomes its switch button, a take-over request a button that opens the
|
||||
* computer, a bot it named a link to that bot's conversation; anything else opens in a new tab.
|
||||
* computer, a bot it named a link to that bot's conversation, a workspace
|
||||
* a link to that workspace; anything else opens in a new tab.
|
||||
*/
|
||||
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (href?.startsWith(ComputerRefHrefPrefix)) {
|
||||
@@ -39,6 +46,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (bot_id) return <BotRefChip bot_id={bot_id} />;
|
||||
}
|
||||
|
||||
if (href?.startsWith(WorkspaceRefHrefPrefix)) {
|
||||
const workspace_id = Number(href.slice(WorkspaceRefHrefPrefix.length));
|
||||
|
||||
if (workspace_id) return <WorkspaceRefChip workspace_id={workspace_id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { Children, isValidElement, type ReactNode } from "react";
|
||||
import List from "@moduletrace/twui/layout/List";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
children?: ReactNode;
|
||||
ordered?: boolean;
|
||||
start?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A list in a bot message, through twui's `List` (which takes items, not
|
||||
* `li` children): each item's content is lifted out of its `li`.
|
||||
*/
|
||||
export default function MarkdownList({ children, ordered, start }: Props) {
|
||||
export default function MarkdownList({ children, ordered, start, className }: Props) {
|
||||
const items = Children.toArray(children)
|
||||
.filter(isValidElement)
|
||||
.map((item) => ({
|
||||
@@ -23,7 +25,10 @@ export default function MarkdownList({ children, ordered, start }: Props) {
|
||||
ordered={ordered}
|
||||
start={start}
|
||||
items={items}
|
||||
className={`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`}
|
||||
className={twMerge(
|
||||
`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`,
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Quote } from "lucide-react";
|
||||
import type { MessageSelection } from "../(hooks)/use-message-selection";
|
||||
|
||||
type Props = {
|
||||
selection: MessageSelection;
|
||||
onQuote: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Quote", floating over text selected in a message: it puts the text in
|
||||
* the composer as a quote. Pressing it keeps the selection, so the text
|
||||
* it quotes is the one shown.
|
||||
*/
|
||||
export default function QuoteSelectionButton({ selection, onQuote }: Props) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el) return;
|
||||
|
||||
computePosition({ getBoundingClientRect: () => selection.rect }, el, {
|
||||
strategy: "fixed",
|
||||
placement: "top",
|
||||
middleware: [offset(8), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
});
|
||||
}, [selection.rect]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
type="button"
|
||||
color="secondary"
|
||||
title="Quote in your message"
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onQuote}
|
||||
className="fixed left-0 top-0 z-50 gap-1.5! px-2.5! py-1.5! rounded-default-sm border border-solid border-hairline bg-surface shadow-popover text-xs font-bold text-ink hover:bg-sunken"
|
||||
>
|
||||
<Quote size={13} aria-hidden />
|
||||
Quote
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { UserMarkdownComponents } from "../(data)/user-markdown-components";
|
||||
import { QuotableAttribute } from "../(hooks)/use-message-selection";
|
||||
|
||||
/**
|
||||
* Ink bubble with paper text; inverts in dark mode.
|
||||
* Ink bubble with paper text; inverts in dark mode. The text is markdown
|
||||
* (GFM; raw HTML is never rendered) as the composer writes it, a single
|
||||
* line break kept as one, so older plain messages read as typed. Bots
|
||||
* and workspaces it mentions show as chips.
|
||||
*/
|
||||
export default function UserBubble({ text }: { text: string }) {
|
||||
return (
|
||||
<P noMargin className="max-w-[80%] rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 whitespace-pre-wrap break-words">
|
||||
{text}
|
||||
</P>
|
||||
<Stack
|
||||
{...{ [QuotableAttribute]: "" }}
|
||||
className="max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 break-words"
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={UserMarkdownComponents}>
|
||||
{linkComputerRefs({ text })}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import MarkdownLink from "./markdown-link";
|
||||
|
||||
/**
|
||||
* A link in the user's own message: refs (bots, workspaces) as their
|
||||
* chips (`MarkdownLink`), anything else underlined in the bubble's ink.
|
||||
*/
|
||||
export default function UserMarkdownLink(props: ComponentProps<"a">) {
|
||||
if (props.href?.startsWith("#")) return <MarkdownLink {...props} />;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={props.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-on-ink underline underline-offset-2 hover:opacity-80 break-words"
|
||||
>
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useContext } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
|
||||
|
||||
type Props = {
|
||||
workspace_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A workspace named in a message (`workspaceRef`): a link to its
|
||||
* dashboard. One the user isn't in is shown by name only.
|
||||
*/
|
||||
export default function WorkspaceRefChip({ workspace_id }: Props) {
|
||||
const workspaces = useContext(CrewWorkspacesContext);
|
||||
const workspace = workspaces.find((w) => w.id == workspace_id);
|
||||
|
||||
if (!workspace) {
|
||||
return <Span className="font-bold">{`workspace ${workspace_id}`}</Span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/workspaces/${workspace.id}`}
|
||||
title={`Open ${workspace.name}`}
|
||||
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
|
||||
>
|
||||
<WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" />
|
||||
{workspace.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user