Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/ui/src/components/chat/MarkdownContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ describe('MarkdownContent', () => {
expect(container.querySelector('pre code')?.textContent).toContain('{"ok":true}');
});

it('renders untyped fenced code as a block rather than inline code', async () => {
const container = document.createElement('div');
const root = createRoot(container);

await act(async () => {
root.render(<MarkdownContent content={'```\nplain block\n```\n\nInline `value`.'} />);
});

expect(container.querySelector('pre code')?.className).toContain('font-mono');
expect(container.querySelector('pre code')?.className).not.toContain('rounded-[4px]');
expect(container.querySelector('p code')?.className).toContain('rounded-[4px]');
});

it('renders GFM tables and removes unsafe links', async () => {
const container = document.createElement('div');
const root = createRoot(container);
Expand Down
41 changes: 23 additions & 18 deletions packages/ui/src/components/chat/MarkdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';

const SAFE_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
const CodeBlockContext = React.createContext(false);

/**
* Keep agent-authored URLs useful without allowing navigation to executable
Expand Down Expand Up @@ -79,6 +80,22 @@ function useStreamingContent(content: string, streaming: boolean): string {
* is intentional. GFM adds the table, task-list, strike-through, and URL
* behaviours people expect from a research answer.
*/
const MarkdownCode: NonNullable<Components['code']> = ({ className, children, ...props }) => {
const isBlock = React.useContext(CodeBlockContext) || Boolean(className?.includes('language-'));
return (
<code
className={
isBlock
? 'font-mono text-[11.5px] leading-relaxed text-foreground/82'
: 'rounded-[4px] bg-foreground/[0.07] px-1 py-0.5 font-mono text-[11.5px] text-foreground/82'
}
{...props}
>
{isBlock ? String(children).replace(/\n$/, '') : children}
</code>
);
};

const components: Components = {
h1: ({ children }) => (
<h1 className="mb-3 mt-1 text-[18px] font-bold tracking-tight text-foreground">{children}</h1>
Expand Down Expand Up @@ -116,25 +133,13 @@ const components: Components = {
</a>
);
},
code: ({ className, children, ...props }) => {
const isBlock = Boolean(className?.includes('language-'));
return (
<code
className={
isBlock
? 'font-mono text-[11.5px] leading-relaxed text-foreground/82'
: 'rounded-[4px] bg-foreground/[0.07] px-1 py-0.5 font-mono text-[11.5px] text-foreground/82'
}
{...props}
>
{isBlock ? String(children).replace(/\n$/, '') : children}
</code>
);
},
code: MarkdownCode,
pre: ({ children }) => (
<pre className="my-2 max-w-full overflow-x-auto rounded-[8px] border mac-section-divider bg-foreground/[0.045] p-3">
{children}
</pre>
<CodeBlockContext.Provider value>
<pre className="my-2 max-w-full overflow-x-auto rounded-[8px] border mac-section-divider bg-foreground/[0.045] p-3">
{children}
</pre>
</CodeBlockContext.Provider>
),
table: ({ children }) => (
<div className="my-2 max-w-full overflow-x-auto">
Expand Down