render markdown in your own chat bubbles, and let you copy them

Your own messages were plain `whitespace-pre-wrap`, so pasting a fenced block
gave you back the backticks and none of the formatting — the one place in the
app where markdown was written and never read.

The usual reason to leave user text alone is that rendering changes what was
typed: `a_b_c` becomes italic, a leading `#` becomes a heading. That is real,
and the answer is the Copy button beside it, which copies the RAW text. The
original is one click away and nothing you wrote can be lost to a renderer.

Same remark plugins, components and sanitize schema as the assistant's,
deliberately — a code block should look identical whoever wrote it, and the
schema already strips script, iframe, object, embed and form, so pasted HTML is
inert either way.

The copy button appears on hover rather than always, unlike the assistant's: a
reply is something you copy out of, while your own message is something you
already have, so the control earns its place only when reached for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 14:24:00 +00:00
co-authored by Claude Opus 5
parent 177394fb76
commit 9c6534c746
@@ -140,13 +140,40 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
return (
<>
{metadata && <CollapsibleBlock label="Frontmatter" content={metadata} />}
<div className="flex justify-end">
<div className="flex justify-end group">
<div className="max-w-[80%] min-w-0 rounded-2xl rounded-tr-sm bg-duck-yellow/10 border border-duck-yellow/20 px-4 py-2.5 text-sm text-foreground">
{message.images?.map((img, i) => (
<img key={i} src={img.dataUrl} alt={img.filename} className="max-w-full max-h-64 rounded-lg mb-2" />
))}
{/* break-words, or a pasted URL or base64 blob pushes the bubble past max-w and out of the pane. */}
<div className="whitespace-pre-wrap break-words">{text}</div>
{/* YOUR OWN MESSAGE IS RENDERED AS MARKDOWN TOO.
It used to be `whitespace-pre-wrap` plain text, so pasting a fenced block gave you the
backticks and none of the formatting — the one place in the app where markdown was
written and not read.
The reason this is usually left plain is that rendering changes what you typed:
`a_b_c` becomes italic, a leading `#` becomes a heading. That is real, and the answer
is the Copy button below, which copies the RAW text — so the original is never more
than one click away and nothing you wrote can be lost to a renderer.
Same plugins, components and sanitize schema as the assistant's, deliberately: a code
block should look identical whoever wrote it. `break-words` stays, or a pasted URL or
base64 blob pushes the bubble past max-w and out of the pane. */}
<div className="chat-md break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
{text}
</ReactMarkdown>
</div>
{/* Hidden until hover, unlike the assistant's. A reply is something you copy out of; your
own message is something you already have, so the control earns its place only when
reached for. */}
<div className="flex justify-end -mb-1 -mr-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<CopyButton text={text} />
</div>
</div>
</div>
</>