feat(dashboard): render mailbox messages as markdown
Adds GitHub-flavored markdown rendering to message bodies in both MailboxView and MailboxModal via a shared MailboxMessageContent component (ReactMarkdown + remark-gfm). Plain-text messages render unchanged. Raw HTML is not executed. - New MailboxMessageContent component with mailbox-scoped pre/table/link overrides; links open in a new tab with noopener noreferrer. - Wired into the full message body and conversation thread bodies in both Mailbox surfaces (parity preserved). - CSS for .mailbox-markdown, .mailbox-markdown-pre, .mailbox-markdown-table with horizontal scroll for code blocks/tables; removed white-space: pre-wrap on bodies since markdown owns its own whitespace. - 10 new unit tests cover headings, lists, bold/italic, inline code, fenced code blocks, link target/rel, GFM tables, plain-text passthrough, raw-HTML safety, and testId forwarding. Existing 108 mailbox tests continue to pass.
This commit is contained in:
5
.changeset/mailbox-markdown-rendering.md
Normal file
5
.changeset/mailbox-markdown-rendering.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Render mailbox message bodies as GitHub-flavored markdown. Headings, lists, bold/italic, links, inline code, fenced code blocks, and tables now display formatted in both the Mailbox view and Mailbox modal. Plain-text messages render unchanged. Raw HTML is not executed.
|
||||||
60
packages/dashboard/app/components/MailboxMessageContent.tsx
Normal file
60
packages/dashboard/app/components/MailboxMessageContent.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import type { Components } from "react-markdown";
|
||||||
|
|
||||||
|
const mailboxMarkdownComponents: Components = {
|
||||||
|
pre: ({ children, ...props }) => (
|
||||||
|
<pre {...props} className="mailbox-markdown-pre">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
table: ({ children, ...props }) => (
|
||||||
|
<table {...props} className="mailbox-markdown-table">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
),
|
||||||
|
// Open links in a new tab. ReactMarkdown does not allow raw HTML by default,
|
||||||
|
// so the rendered output here is safe.
|
||||||
|
a: ({ children, ...props }) => (
|
||||||
|
<a {...props} target="_blank" rel="noopener noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MailboxMessageContentProps {
|
||||||
|
/** Raw message body. Rendered as GitHub-flavored markdown. */
|
||||||
|
content: string;
|
||||||
|
/** Optional extra class for the wrapper. */
|
||||||
|
className?: string;
|
||||||
|
/** Optional data-testid for test selectors. */
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a mailbox message body as GitHub-flavored markdown.
|
||||||
|
*
|
||||||
|
* Uses ReactMarkdown defaults (no raw HTML) so untrusted message content is
|
||||||
|
* safe. Plain-text messages render unchanged (markdown is a strict superset
|
||||||
|
* for the formatting we care about — bold, lists, code, links, tables).
|
||||||
|
*
|
||||||
|
* Memoized because mailbox detail panes can re-render on selection / SSE
|
||||||
|
* updates while the underlying message body is unchanged.
|
||||||
|
*/
|
||||||
|
export const MailboxMessageContent = memo(function MailboxMessageContent({
|
||||||
|
content,
|
||||||
|
className,
|
||||||
|
testId,
|
||||||
|
}: MailboxMessageContentProps) {
|
||||||
|
const wrapperClass = className
|
||||||
|
? `mailbox-markdown ${className}`
|
||||||
|
: "mailbox-markdown";
|
||||||
|
return (
|
||||||
|
<div className={wrapperClass} data-testid={testId}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mailboxMarkdownComponents}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -309,10 +309,59 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-base, 0.9rem);
|
font-size: var(--font-size-base, 0.9rem);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown rendering inside message bodies. ReactMarkdown produces standard
|
||||||
|
block elements (p, ul, h*, code, table, ...). Reset margins so message
|
||||||
|
prose still feels compact, and let pre/table scroll horizontally. */
|
||||||
|
.mailbox-markdown > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-markdown > * + * {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-markdown ul,
|
||||||
|
.mailbox-markdown ol {
|
||||||
|
padding-left: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-markdown :not(pre) > code {
|
||||||
|
padding: 0 var(--space-xs);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--surface) 55%, transparent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-markdown-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--surface) 65%, transparent);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
max-width: 100%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-markdown-table {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-markdown-table th,
|
||||||
|
.mailbox-markdown-table td {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-reply-context-wrapper {
|
.mailbox-reply-context-wrapper {
|
||||||
margin-bottom: var(--space-xs);
|
margin-bottom: var(--space-xs);
|
||||||
}
|
}
|
||||||
@@ -404,7 +453,6 @@
|
|||||||
.mailbox-conversation-msg-body {
|
.mailbox-conversation-msg-body {
|
||||||
font-size: var(--font-size-sm, 0.85rem);
|
font-size: var(--font-size-sm, 0.85rem);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
type AgentMailboxResponse,
|
type AgentMailboxResponse,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { MessageComposer } from "./MessageComposer";
|
import { MessageComposer } from "./MessageComposer";
|
||||||
|
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||||
import type { Agent } from "../api";
|
import type { Agent } from "../api";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
@@ -664,7 +665,10 @@ export function MailboxModal({
|
|||||||
testId={`mailbox-reply-context-${msg.id}`}
|
testId={`mailbox-reply-context-${msg.id}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="mailbox-conversation-msg-body">{msg.content}</div>
|
<MailboxMessageContent
|
||||||
|
content={msg.content}
|
||||||
|
className="mailbox-conversation-msg-body"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -682,9 +686,11 @@ export function MailboxModal({
|
|||||||
testId="mailbox-selected-reply-context"
|
testId="mailbox-selected-reply-context"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="mailbox-message-body" data-testid="mailbox-message-body">
|
<MailboxMessageContent
|
||||||
{selectedMessage.content}
|
content={selectedMessage.content}
|
||||||
</div>
|
className="mailbox-message-body"
|
||||||
|
testId="mailbox-message-body"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
type AgentMailboxResponse,
|
type AgentMailboxResponse,
|
||||||
type Agent,
|
type Agent,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||||
import { MessageComposer } from "./MessageComposer";
|
import { MessageComposer } from "./MessageComposer";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
@@ -480,7 +481,10 @@ export function MailboxView({
|
|||||||
↪ Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
|
↪ Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mailbox-conversation-msg-body">{msg.content}</div>
|
<MailboxMessageContent
|
||||||
|
content={msg.content}
|
||||||
|
className="mailbox-conversation-msg-body"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -493,9 +497,11 @@ export function MailboxView({
|
|||||||
↪ Replying to message {selectedMessage.metadata.replyTo.messageId}
|
↪ Replying to message {selectedMessage.metadata.replyTo.messageId}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mailbox-message-body" data-testid="mailbox-message-body">
|
<MailboxMessageContent
|
||||||
{selectedMessage.content}
|
content={selectedMessage.content}
|
||||||
</div>
|
className="mailbox-message-body"
|
||||||
|
testId="mailbox-message-body"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { render, cleanup, screen } from "@testing-library/react";
|
||||||
|
import { MailboxMessageContent } from "../MailboxMessageContent";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MailboxMessageContent", () => {
|
||||||
|
it("renders plain-text messages unchanged", () => {
|
||||||
|
render(<MailboxMessageContent content="Hello, this is plain text." />);
|
||||||
|
expect(screen.getByText("Hello, this is plain text.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders headings as semantic heading elements", () => {
|
||||||
|
render(<MailboxMessageContent content={"# Status Update\n\nDetails below."} />);
|
||||||
|
const heading = screen.getByRole("heading", { level: 1, name: "Status Update" });
|
||||||
|
expect(heading).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders bold and italic emphasis", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MailboxMessageContent content="This is **bold** and *italic*." />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("strong")?.textContent).toBe("bold");
|
||||||
|
expect(container.querySelector("em")?.textContent).toBe("italic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders unordered lists", () => {
|
||||||
|
render(<MailboxMessageContent content={"- one\n- two\n- three"} />);
|
||||||
|
const items = screen.getAllByRole("listitem");
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
expect(items[0]?.textContent).toBe("one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders inline code with code element", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MailboxMessageContent content="Run `pnpm test` to verify." />,
|
||||||
|
);
|
||||||
|
const code = container.querySelector("code");
|
||||||
|
expect(code?.textContent).toBe("pnpm test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders fenced code blocks inside the markdown <pre> wrapper", () => {
|
||||||
|
const content = "```\nnpm install\n```";
|
||||||
|
const { container } = render(<MailboxMessageContent content={content} />);
|
||||||
|
const pre = container.querySelector("pre.mailbox-markdown-pre");
|
||||||
|
expect(pre).not.toBeNull();
|
||||||
|
expect(pre?.textContent).toContain("npm install");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders links with target=_blank and noopener noreferrer", () => {
|
||||||
|
render(
|
||||||
|
<MailboxMessageContent content="See [docs](https://example.com/docs)." />,
|
||||||
|
);
|
||||||
|
const link = screen.getByRole("link", { name: "docs" });
|
||||||
|
expect(link.getAttribute("href")).toBe("https://example.com/docs");
|
||||||
|
expect(link.getAttribute("target")).toBe("_blank");
|
||||||
|
expect(link.getAttribute("rel")).toContain("noopener");
|
||||||
|
expect(link.getAttribute("rel")).toContain("noreferrer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders GFM tables with the mailbox-scoped class", () => {
|
||||||
|
const content = ["| col a | col b |", "| --- | --- |", "| 1 | 2 |"].join("\n");
|
||||||
|
const { container } = render(<MailboxMessageContent content={content} />);
|
||||||
|
const table = container.querySelector("table.mailbox-markdown-table");
|
||||||
|
expect(table).not.toBeNull();
|
||||||
|
expect(table?.querySelectorAll("th")).toHaveLength(2);
|
||||||
|
expect(table?.querySelectorAll("tbody td")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT execute raw HTML in messages", () => {
|
||||||
|
const content = "<script>window.__pwned = true;</script>Hello";
|
||||||
|
const { container } = render(<MailboxMessageContent content={content} />);
|
||||||
|
// ReactMarkdown defaults disallow raw HTML — the <script> tag should be
|
||||||
|
// rendered as escaped text, not as a real script element.
|
||||||
|
expect(container.querySelector("script")).toBeNull();
|
||||||
|
expect(
|
||||||
|
(globalThis as unknown as { __pwned?: boolean }).__pwned,
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(container.textContent).toContain("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards testId to the wrapper", () => {
|
||||||
|
render(<MailboxMessageContent content="x" testId="mailbox-message-body" />);
|
||||||
|
expect(screen.getByTestId("mailbox-message-body")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user