+ );
+
+ // Open the overflow menu on the first message (m1, authored by u1), then
+ // the "Copy as" submenu, and pick Markdown.
+ const [firstOverflow] = screen.getAllByRole('button', {
+ name: 'Message actions',
+ });
+ await user.click(firstOverflow);
+ await user.click(screen.getByRole('menuitem', { name: 'Copy as' }));
+ await user.click(
+ await screen.findByRole('menuitem', { name: 'Copy as Markdown' })
+ );
+
+ expect(writeText).toHaveBeenCalledWith(conversation.thread[0].text);
+ vi.unstubAllGlobals();
+ });
+
it('renders AI content blocks of type code', () => {
const withCode: SuperChatConversation = {
...conversation,
diff --git a/src/components/SuperChat/SuperChat.tsx b/src/components/SuperChat/SuperChat.tsx
index 5ac13fc2..02fae710 100644
--- a/src/components/SuperChat/SuperChat.tsx
+++ b/src/components/SuperChat/SuperChat.tsx
@@ -31,6 +31,7 @@ import type {
ComposerAttachment,
Participant,
SuperChatConversation,
+ SuperChatCopyFormat,
SuperChatLinkBuilder,
SuperChatRef,
SuperChatRenderPlugin,
@@ -74,6 +75,12 @@ export interface SuperChatProps {
virtualized?: boolean;
/** Build hrefs for `ref` thread items. */
linkBuilder?: SuperChatLinkBuilder;
+ /**
+ * Format for a message's default copy action (Ctrl/Cmd-click on the footer
+ * copy button). All formats stay reachable per message via the copy menus.
+ * Defaults to `'rich'`.
+ */
+ defaultCopyFormat?: SuperChatCopyFormat;
/** Additional class name. */
className?: string;
@@ -121,6 +128,7 @@ export function SuperChat({
order = 'asc',
virtualized = false,
linkBuilder,
+ defaultCopyFormat,
className,
onMessageSent,
onMessageEdited,
@@ -291,6 +299,7 @@ export function SuperChat({
onReferenceClick={onReferenceClick}
editable={editable}
onMessageEdited={handleMessageEdited}
+ defaultCopyFormat={defaultCopyFormat}
order={order}
conversationId={conversation.id}
containerProps={{
@@ -329,6 +338,7 @@ export function SuperChat({
onReferenceClick={onReferenceClick}
editable={editable}
onMessageEdited={handleMessageEdited}
+ defaultCopyFormat={defaultCopyFormat}
/>
))}
diff --git a/src/components/SuperChat/SuperChatInbox.tsx b/src/components/SuperChat/SuperChatInbox.tsx
index 0ddc674f..265b9580 100644
--- a/src/components/SuperChat/SuperChatInbox.tsx
+++ b/src/components/SuperChat/SuperChatInbox.tsx
@@ -19,6 +19,7 @@ import type {
AttachmentKind,
ComposerAttachment,
SuperChatConversation,
+ SuperChatCopyFormat,
SuperChatLinkBuilder,
SuperChatRef,
SuperChatRenderPlugin,
@@ -58,6 +59,8 @@ export interface SuperChatInboxProps {
showSidebar?: boolean;
/** Build hrefs for `ref` thread items. */
linkBuilder?: SuperChatLinkBuilder;
+ /** Format for a message's default copy action (Ctrl/Cmd-click on copy). */
+ defaultCopyFormat?: SuperChatCopyFormat;
/** Additional class name. */
className?: string;
@@ -101,6 +104,7 @@ export function SuperChatInbox({
virtualized,
showSidebar = true,
linkBuilder,
+ defaultCopyFormat,
className,
onMessageSent,
onMessageEdited,
@@ -165,6 +169,7 @@ export function SuperChatInbox({
order={order}
virtualized={virtualized}
linkBuilder={linkBuilder}
+ defaultCopyFormat={defaultCopyFormat}
onMessageSent={onMessageSent}
onMessageEdited={onMessageEdited}
onConversationClosed={onConversationClosed}
diff --git a/src/components/SuperChat/VirtualThread.tsx b/src/components/SuperChat/VirtualThread.tsx
index 18cdf796..92655fe2 100644
--- a/src/components/SuperChat/VirtualThread.tsx
+++ b/src/components/SuperChat/VirtualThread.tsx
@@ -18,6 +18,7 @@ import { MessageRow } from './parts';
import type {
AIRenderTextContent,
Participant,
+ SuperChatCopyFormat,
SuperChatLinkBuilder,
SuperChatMessage,
SuperChatRef,
@@ -40,6 +41,8 @@ export interface VirtualThreadProps {
editable?: boolean;
/** Save handler for an inline message edit (bound to the message's id). */
onMessageEdited?: (messageId: string, text: string) => void;
+ /** Format for a message's default copy action (Ctrl/Cmd-click on copy). */
+ defaultCopyFormat?: SuperChatCopyFormat;
/**
* Thread ordering. `'asc'` anchors new messages to the bottom; `'desc'`
* anchors them to the top (feed style).
@@ -62,6 +65,7 @@ export function VirtualThread({
onReferenceClick,
editable,
onMessageEdited,
+ defaultCopyFormat,
order,
conversationId,
containerProps,
@@ -134,6 +138,7 @@ export function VirtualThread({
onReferenceClick={onReferenceClick}
editable={editable}
onMessageEdited={onMessageEdited}
+ defaultCopyFormat={defaultCopyFormat}
/>
diff --git a/src/components/SuperChat/index.ts b/src/components/SuperChat/index.ts
index 329fe649..ee9188ea 100644
--- a/src/components/SuperChat/index.ts
+++ b/src/components/SuperChat/index.ts
@@ -42,6 +42,7 @@ export type {
SuperChatMessage,
SuperChatItemType,
SuperChatChannel,
+ SuperChatCopyFormat,
SuperChatRef,
SuperChatLinkBuilder,
ComposerAttachment,
diff --git a/src/components/SuperChat/parts.tsx b/src/components/SuperChat/parts.tsx
index 7290f6dd..2910124d 100644
--- a/src/components/SuperChat/parts.tsx
+++ b/src/components/SuperChat/parts.tsx
@@ -11,12 +11,13 @@ import { cva } from 'class-variance-authority';
import {
Check as CheckIcon,
Clipboard as ClipboardIcon,
+ Ellipsis as EllipsisIcon,
Pencil as PencilIcon,
} from 'lucide-react';
import { cn } from '../../utils/cn';
import { Avatar } from '../Avatar';
import { Badge } from '../Badge';
-import { Dropdown, DropdownItem } from '../Dropdown';
+import { Dropdown, DropdownItem, DropdownSubmenu } from '../Dropdown';
import { MCPToolCallDisplay } from '../AI/MCPToolCall';
import { ChatBubble, AITypingIndicator } from '../AI/AIMessage';
import { SparklesIcon } from '../AI/icons';
@@ -26,6 +27,7 @@ import type {
ComposerAttachment,
Participant,
SuperChatConversation,
+ SuperChatCopyFormat,
SuperChatLinkBuilder,
SuperChatMessage,
SuperChatRef,
@@ -295,26 +297,68 @@ export function filesToComposerAttachments(
);
}
-interface CopyMenuProps {
- /** Aligns the popover to the outer margin (right for self, left otherwise). */
- isSelf: boolean;
+// ============================================================================
+// Message actions (footer bar + sticky overflow menu)
+// ============================================================================
+
+/**
+ * Reveal-on-hover/focus for message action affordances. Hover doesn't exist on
+ * touch devices, so coarse pointers always show the actions.
+ */
+const actionRevealClass =
+ 'opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 [@media(pointer:coarse)]:opacity-100';
+
+const actionButtonClass =
+ 'rounded p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200';
+
+interface MessageActionItem {
+ id: string;
+ label: string;
+ /** Secondary line shown under the label in the overflow menu. */
+ description?: string;
+ onSelect: () => void;
+}
+
+/**
+ * One per-message action, rendered twice from the same definition: as an icon
+ * button in the footer bar and as an item in the sticky overflow (⋯) menu.
+ */
+interface MessageAction {
+ id: string;
+ /** Accessible name (footer button) and menu label. */
+ label: string;
+ icon: React.ReactNode;
+ /** Default behavior (footer click / top-level menu item). */
+ onSelect: () => void;
+ /** Explicit variants, rendered as a flyout submenu in the overflow menu. */
+ submenu?: { label: string; items: MessageActionItem[] };
+}
+
+interface UseMessageCopyOptions {
/** Markdown source for this message (plain-text / Markdown copy). */
markdown: string;
/** Read the rendered bubble HTML at copy time (rich-text copy). */
getHtml: () => string;
/** Read the rendered bubble plain text at copy time. */
getText: () => string;
+ /** One-click format for the default copy action. */
+ defaultFormat: SuperChatCopyFormat;
}
/**
- * Per-message copy control. The primary **Copy** writes *both* a rich-text
+ * Per-message clipboard writes. The **rich** format writes *both* a rich-text
* (`text/html`) and a Markdown (`text/plain`) representation in a single
* clipboard write, so the paste target decides: rich editors get formatting,
- * plain editors get Markdown. Explicit "as Markdown" / "as plain text" options
- * are also offered.
+ * plain editors get Markdown. Explicit "as Markdown" / "as plain text"
+ * variants are also exposed, and `copyDefault` honors the host-configured
+ * {@link SuperChatCopyFormat}.
*/
-function CopyMenu({ isSelf, markdown, getHtml, getText }: CopyMenuProps) {
- const [open, setOpen] = React.useState(false);
+function useMessageCopy({
+ markdown,
+ getHtml,
+ getText,
+ defaultFormat,
+}: UseMessageCopyOptions) {
const [copied, setCopied] = React.useState(false);
const copiedTimer = React.useRef | undefined>(
undefined
@@ -361,25 +405,195 @@ function CopyMenu({ isSelf, markdown, getHtml, getText }: CopyMenuProps) {
};
const run = (fn: () => Promise) => {
- setOpen(false);
// Only flash "Copied" when the write actually succeeds; swallow failures
// (insecure context / denied permission) instead of leaking an unhandled
// rejection or showing a false success.
void fn().then(flash, () => {});
};
+ const copyRich = () => run(writeBoth);
+ const copyMarkdown = () => run(() => writeText(markdown || getText()));
+ const copyPlain = () => run(() => writeText(getText()));
+ const copyDefault =
+ defaultFormat === 'markdown'
+ ? copyMarkdown
+ : defaultFormat === 'plain'
+ ? copyPlain
+ : copyRich;
+
+ return { copied, copyDefault, copyRich, copyMarkdown, copyPlain };
+}
+
+interface MessageActionsBarProps {
+ actions: MessageAction[];
+ isSelf: boolean;
+}
+
+/** A submenu item as a Dropdown row, with an optional secondary line. */
+function ActionMenuItem({
+ item,
+ onSelect,
+}: {
+ item: MessageActionItem;
+ onSelect: (fn: () => void) => void;
+}) {
+ return (
+ onSelect(item.onSelect)}>
+ {item.description ? (
+
+ {item.label}
+
+ {item.description}
+
+
+ ) : (
+ item.label
+ )}
+
+ );
+}
+
+/**
+ * One footer-bar affordance. Plain actions run their default on click; actions
+ * with variants (e.g. copy formats) open a small menu of those variants, like
+ * the original per-message copy menu. Ctrl/Cmd-click skips the menu and runs
+ * the action's default (e.g. the host-configured {@link SuperChatCopyFormat}).
+ */
+function FooterActionButton({
+ action,
+ isSelf,
+}: {
+ action: MessageAction;
+ isSelf: boolean;
+}) {
+ const [open, setOpen] = React.useState(false);
+
+ // Capture-phase so it runs before the Dropdown's trigger onClick: a
+ // modifier click fires the default action instead of opening the menu.
+ const onClickCapture = action.submenu
+ ? (e: React.MouseEvent) => {
+ if (e.metaKey || e.ctrlKey) {
+ e.preventDefault();
+ e.stopPropagation();
+ action.onSelect();
+ }
+ }
+ : undefined;
+
+ const button = (
+
+ );
+
+ if (!action.submenu) return button;
+
+ return (
+
+ {action.submenu.items.map((item) => (
+ {
+ setOpen(false);
+ fn();
+ }}
+ />
+ ))}
+
+ );
+}
+
+/**
+ * The hover-revealed row of action icon buttons under a message bubble. Each
+ * button runs its action's default behavior; explicit variants live in the
+ * sticky overflow menu ({@link MessageOverflowMenu}).
+ */
+const MessageActionsBar = React.forwardRef<
+ HTMLDivElement,
+ MessageActionsBarProps
+>(function MessageActionsBar({ actions, isSelf }, ref) {
+ return (
+
+ {actions.map((action) => (
+
+ ))}
+
+ );
+});
+
+interface MessageOverflowMenuProps {
+ /** Aligns the popover to the outer margin (right for self, left otherwise). */
+ isSelf: boolean;
+ actions: MessageAction[];
+ /**
+ * Whether the footer action bar is currently in view. While it is, the
+ * overflow trigger hides on fine pointers (the footer already offers the
+ * actions); coarse pointers always show both.
+ */
+ footerVisible: boolean;
+}
+
+/**
+ * The sticky overflow (⋯) control beside the bubble. On long messages it
+ * follows the scroll (sticky within the thread) so actions stay reachable, and
+ * hands off to the footer bar once the message end scrolls into view.
+ */
+function MessageOverflowMenu({
+ isSelf,
+ actions,
+ footerVisible,
+}: MessageOverflowMenuProps) {
+ const [open, setOpen] = React.useState(false);
+
+ const select = (fn: () => void) => {
+ setOpen(false);
+ fn();
+ };
+
return (
- {copied ? (
-
- ) : (
-
- )}
+
}
>
- run(writeBoth)}>
-
- Copy
-
- Rich text + Markdown
-
-
-
- run(() => writeText(markdown || getText()))}
- >
- Copy as Markdown
-
- run(() => writeText(getText()))}>
- Copy as plain text
-
+ {actions.map((action) =>
+ action.submenu ? (
+ // Actions with variants collapse to just their submenu — a
+ // top-level default item would duplicate the flyout's options.
+
+ {action.submenu.items.map((item) => (
+
+ ))}
+
+ ) : (
+ select(action.onSelect)}
+ >
+ {action.label}
+
+ )
+ )}
);
@@ -433,6 +645,9 @@ interface MessageRowProps {
editable?: boolean;
/** Save handler for an inline message edit (bound to this message's id). */
onMessageEdited?: (messageId: string, text: string) => void;
+ /** Format for the default copy action — Ctrl/Cmd-click on the footer copy
+ * button (defaults to `'rich'`). */
+ defaultCopyFormat?: SuperChatCopyFormat;
}
/**
@@ -453,6 +668,7 @@ export const MessageRow = React.memo(function MessageRow({
onReferenceClick,
editable,
onMessageEdited,
+ defaultCopyFormat = 'rich',
}: MessageRowProps) {
const streaming = message.status === 'streaming';
const hasBody = !!message.text || (message.content?.length ?? 0) > 0;
@@ -479,6 +695,72 @@ export const MessageRow = React.memo(function MessageRow({
autosize();
}, [isEditing, autosize]);
+ // --- Message actions (copy / edit) ---------------------------------------
+ // Computed before the early returns below so the hooks run unconditionally
+ // (system/ref rows simply never render the action surfaces).
+
+ // Inline editing applies only to the local user's own plain-text messages
+ // (rich content blocks / streaming messages are not inline-editable).
+ const canEdit =
+ !!editable &&
+ isSelf &&
+ !streaming &&
+ typeof message.text === 'string' &&
+ !message.content?.length;
+
+ // The Markdown source for copying: prefer the raw `text`, otherwise assemble
+ // it from the message's text/code content blocks.
+ const markdownSource =
+ typeof message.text === 'string' && message.text
+ ? message.text
+ : (message.content
+ ?.map((block) => {
+ if (block.type === 'code' && block.text) {
+ return `\`\`\`${block.language ?? ''}\n${block.text}\n\`\`\``;
+ }
+ if (
+ (block.type === 'text' || block.type === 'thinking') &&
+ block.text
+ ) {
+ return block.text;
+ }
+ return '';
+ })
+ .filter(Boolean)
+ .join('\n\n') ?? '');
+
+ // A copy affordance appears on every message that has a body (not while it
+ // is being edited).
+ const canCopy =
+ !isEditing &&
+ (!!message.content?.length ||
+ (typeof message.text === 'string' && message.text.length > 0));
+
+ const hasActions = canCopy || (canEdit && !isEditing);
+
+ const copy = useMessageCopy({
+ markdown: markdownSource,
+ getHtml: () => bubbleRef.current?.innerHTML ?? '',
+ getText: () => bubbleRef.current?.textContent ?? '',
+ defaultFormat: defaultCopyFormat,
+ });
+
+ // Hand-off between the footer action bar and the sticky overflow (⋯): the
+ // overflow only shows (on fine pointers) while the footer bar is scrolled
+ // out of view. Clipping by the scrollable thread counts as "not
+ // intersecting", so the default viewport root is sufficient.
+ const actionsBarRef = React.useRef(null);
+ const [footerVisible, setFooterVisible] = React.useState(false);
+ React.useEffect(() => {
+ const el = actionsBarRef.current;
+ if (!el || typeof globalThis.IntersectionObserver === 'undefined') return;
+ const observer = new globalThis.IntersectionObserver(([entry]) => {
+ setFooterVisible(entry?.isIntersecting ?? false);
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [hasActions]);
+
if (message.type === 'system') {
return (
{
setDraft(message.text ?? '');
setIsEditing(true);
@@ -577,33 +850,55 @@ export const MessageRow = React.memo(function MessageRow({
});
};
- // The Markdown source for copying: prefer the raw `text`, otherwise assemble
- // it from the message's text/code content blocks.
- const markdownSource =
- typeof message.text === 'string' && message.text
- ? message.text
- : (message.content
- ?.map((block) => {
- if (block.type === 'code' && block.text) {
- return `\`\`\`${block.language ?? ''}\n${block.text}\n\`\`\``;
- }
- if (
- (block.type === 'text' || block.type === 'thinking') &&
- block.text
- ) {
- return block.text;
- }
- return '';
- })
- .filter(Boolean)
- .join('\n\n') ?? '');
-
- // A copy affordance appears on every message that has a body (not while it is
- // being edited).
- const canCopy =
- !isEditing &&
- (!!message.content?.length ||
- (typeof message.text === 'string' && message.text.length > 0));
+ // The same action definitions power both surfaces: the footer icon bar and
+ // the sticky overflow menu.
+ const actions: MessageAction[] = [
+ ...(canCopy
+ ? [
+ {
+ id: 'copy',
+ label: 'Copy message',
+ icon: copy.copied ? (
+
+ ) : (
+
+ ),
+ onSelect: copy.copyDefault,
+ submenu: {
+ label: 'Copy as',
+ items: [
+ {
+ id: 'copy-rich',
+ label: 'Copy as rich text',
+ description: 'Rich text + Markdown',
+ onSelect: copy.copyRich,
+ },
+ {
+ id: 'copy-markdown',
+ label: 'Copy as Markdown',
+ onSelect: copy.copyMarkdown,
+ },
+ {
+ id: 'copy-plain',
+ label: 'Copy as plain text',
+ onSelect: copy.copyPlain,
+ },
+ ],
+ },
+ } satisfies MessageAction,
+ ]
+ : []),
+ ...(canEdit && !isEditing
+ ? [
+ {
+ id: 'edit',
+ label: 'Edit message',
+ icon: ,
+ onSelect: startEdit,
+ } satisfies MessageAction,
+ ]
+ : []),
+ ];
return (