Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to this project will be documented in this file.

## [0.10.6] - 10/09/2026

- Tables in an AI answer now render as tables, instead of rows of pipes.
- Italics, `code` and headings no longer show their Markdown markers as text.

## [0.10.5] - 10/09/2026

- Fixed Repositories and their logos
Expand Down
52 changes: 52 additions & 0 deletions src/components/MessageMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,58 @@ describe("MessageMarkdown", () => {
expect(emptyParagraphs(singleBreak)).toBe(1);
});

it("renders a pipe table as a real table, with dataset pills inside its cells", () => {
const {container} = renderMarkdown(
`| # | Dataset | Why |\n|---|:-------:|----:|\n| 1 | [Ocean temps](${DATASET_URL}) | Sea surface |\n| 2 | ERA5-Land | Precipitation |`
);

expect(container.querySelectorAll("table")).toHaveLength(1);
// No pipes left over as literal text.
expect(container.textContent).not.toContain("|");
expect(container.textContent).not.toContain("---");

const headers = Array.from(container.querySelectorAll("th")).map(th => th.textContent);
expect(headers).toEqual(["#", "Dataset", "Why"]);
expect(container.querySelectorAll("tbody tr")).toHaveLength(2);

// Column alignment comes from the delimiter row.
expect(container.querySelectorAll("th")[1].className).toContain("text-center");
expect(container.querySelectorAll("th")[2].className).toContain("text-right");

// A cell is still inline Markdown, so a cited dataset keeps its pill.
const pill = screen.getByRole("link", {name: /Ocean temps/});
expect(pill.closest("td")).not.toBeNull();
});

it("pads a ragged row so its cells stay under the right columns", () => {
const {container} = renderMarkdown("| a | b | c |\n|---|---|---|\n| 1 | 2 |");
const cells = Array.from(container.querySelectorAll("tbody td")).map(td => td.textContent);
expect(cells).toEqual(["1", "2", ""]);
});

it("leaves a lone pipe line as prose: a table needs its delimiter row", () => {
const {container} = renderMarkdown("Use | as the separator |");
expect(container.querySelector("table")).toBeNull();
expect(container.textContent).toContain("Use | as the separator |");
});

it("renders italics, inline code and headings instead of printing their markers", () => {
const {container} = renderMarkdown("## Results\nan *italic* word and `t2m` code");
expect(screen.getByText("italic").tagName).toBe("EM");
expect(screen.getByText("t2m").tagName).toBe("CODE");
expect(screen.getByText("Results").className).toContain("font-semibold");
expect(container.textContent).not.toContain("*");
expect(container.textContent).not.toContain("#");
});

// `**bold**` must not be read as an empty italic wrapping a stray asterisk.
it("keeps bold bold when it sits next to italics, and leaves identifiers alone", () => {
renderMarkdown("**bold** and *thin*\nthe total_precipitation variable");
expect(screen.getByText("bold").tagName).toBe("STRONG");
expect(screen.getByText("thin").tagName).toBe("EM");
expect(screen.getByText(/total_precipitation/)).toBeInTheDocument();
});

it("hides a trailing partial link only while streaming", () => {
renderMarkdown("Found [Ocean te", true);
expect(screen.getByText("Found")).toBeInTheDocument();
Expand Down
215 changes: 179 additions & 36 deletions src/components/MessageMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,68 @@ interface MessageMarkdownProps {
}

/**
* Renders the subset of Markdown the assistant produces: links, bold, and
* ordered/unordered list lines. A link pointing at one of the thread's search
* hits becomes a numbered dataset citation; any other link stays an ordinary
* external link.
* A table is a header row, a delimiter row that fixes each column's alignment, and the
* body rows under it: `| a | b |` over `|---|---:|`. Both outer pipes are required and
* the delimiter row is mandatory, so an ordinary sentence containing a pipe is never
* mistaken for a table.
*/
const TABLE_ROW = /^\|.*\|$/;
const TABLE_DELIMITER = /^\|(?:\s*:?-+:?\s*\|)+$/;

type CellAlignment = 'left' | 'center' | 'right';

const ALIGNMENT_CLASS: Record<CellAlignment, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};

/** Splits `| a | b |` into its cells, honouring a `\|` escaped pipe inside one. */
const splitRow = (line: string): string[] => {
const body = line.trim().replace(/^\|/, '').replace(/\|$/, '');
const cells: string[] = [];
let cell = '';
for (let i = 0; i < body.length; i++) {
if (body[i] === '\\' && body[i + 1] === '|') {
cell += '|';
i++;
} else if (body[i] === '|') {
cells.push(cell);
cell = '';
} else {
cell += body[i];
}
}
cells.push(cell);
return cells.map(text => text.trim());
};

/** Reads `|:--|:-:|--:|` as the column alignments it declares. */
const parseAlignments = (delimiter: string): CellAlignment[] =>
splitRow(delimiter).map(cell => {
if (cell.startsWith(':') && cell.endsWith(':')) return 'center';
return cell.endsWith(':') ? 'right' : 'left';
});

/**
* Renders the subset of Markdown the assistant produces: tables, links, bold, italic,
* inline code, headings, rules, and ordered/unordered list lines. A link pointing at one
* of the thread's search hits becomes a numbered dataset citation; any other link stays
* an ordinary external link. Anything outside that subset is left as the literal text
* the model wrote, so an unsupported construct reads as plain prose rather than breaking.
*/
export const MessageMarkdown = ({text, datasets, streaming = false, citations = [], onCite}: MessageMarkdownProps) => {
const numbers = useMemo(
() => new Map(citations.map(citation => [citation.dataset, citation.number])),
[citations]
);

const renderInline = (line: string, lineIndex: number) => {
// Handles **[label](url)**, [label](url), and **bold** in a single pass.
const tokenRegex = /\*\*\[(.+?)]\((.+?)\)\*\*|\[(.+?)]\((.+?)\)|\*\*(.+?)\*\*/g;
const renderInline = (line: string, keyPrefix: string) => {
// Handles `code`, **[label](url)**, [label](url), **bold** and *italic* in a
// single pass. `**` is tried before `*` so bold never reads as empty italics.
// Italics are deliberately limited to `*word*`: `_word_` would mangle the
// identifiers this chat is full of (total_precipitation, ERA5_Land, …).
const tokenRegex = /`([^`]+)`|\*\*\[(.+?)]\((.+?)\)\*\*|\[(.+?)]\((.+?)\)|\*\*(.+?)\*\*|\*([^\s*](?:[^*]*[^\s*])?)\*/g;
const nodes: (string | JSX.Element)[] = [];
let lastIndex = 0;
let tokenIndex = 0;
Expand All @@ -42,10 +90,19 @@ export const MessageMarkdown = ({text, datasets, streaming = false, citations =
nodes.push(line.substring(lastIndex, match.index));
}

const [fullMatch, boldLinkText, boldLinkHref, linkText, linkHref, boldText] = match;
const [fullMatch, codeText, boldLinkText, boldLinkHref, linkText, linkHref, boldText, italicText] = match;
const label = boldLinkText || linkText;
const href = boldLinkHref || linkHref;
if (label && href) {
if (codeText) {
nodes.push(
<code
key={`md-${keyPrefix}-${tokenIndex++}`}
className="rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.85em] text-gray-800"
>
{codeText}
</code>
);
} else if (label && href) {
const safeHref = sanitizeLinkHref(href);
if (!safeHref) {
nodes.push(label);
Expand All @@ -55,15 +112,15 @@ export const MessageMarkdown = ({text, datasets, streaming = false, citations =
const dataset = lookupDataset(datasets, safeHref);
nodes.push(dataset ? (
<DatasetReference
key={`md-${lineIndex}-${tokenIndex++}`}
key={`md-${keyPrefix}-${tokenIndex++}`}
dataset={dataset}
label={label}
number={numbers.get(dataset)}
onJump={onCite}
/>
) : (
<a
key={`md-${lineIndex}-${tokenIndex++}`}
key={`md-${keyPrefix}-${tokenIndex++}`}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
Expand All @@ -74,10 +131,16 @@ export const MessageMarkdown = ({text, datasets, streaming = false, citations =
));
} else if (boldText) {
nodes.push(
<strong key={`md-${lineIndex}-${tokenIndex++}`} className="font-semibold text-gray-900">
<strong key={`md-${keyPrefix}-${tokenIndex++}`} className="font-semibold text-gray-900">
{boldText}
</strong>
);
} else if (italicText) {
nodes.push(
<em key={`md-${keyPrefix}-${tokenIndex++}`} className="italic">
{italicText}
</em>
);
} else {
nodes.push(fullMatch);
}
Expand All @@ -89,7 +152,7 @@ export const MessageMarkdown = ({text, datasets, streaming = false, citations =
nodes.push(line.substring(lastIndex));
}

return nodes.map((node, idx) => <Fragment key={`md-frag-${lineIndex}-${idx}`}>{node}</Fragment>);
return nodes.map((node, idx) => <Fragment key={`md-frag-${keyPrefix}-${idx}`}>{node}</Fragment>);
};

// Every line becomes its own `min-h-6` paragraph below, so a raw blank line is
Expand All @@ -100,27 +163,107 @@ export const MessageMarkdown = ({text, datasets, streaming = false, citations =
.replace(/\n{3,}/g, '\n\n')
.replace(/^\n+|\n+$/g, '');

return (
<>
{content.split('\n').map((rawLine, i) => {
const trimmedLeft = rawLine.trimStart();
const orderedMatch = trimmedLeft.match(/^(\d+)\.\s+(.*)$/);
const unorderedMatch = trimmedLeft.match(/^[*-]\s+(.*)$/);
const lineBody = orderedMatch?.[2] ?? unorderedMatch?.[1] ?? rawLine;

const isMetadata =
trimmedLeft.startsWith('**Creator:**') ||
trimmedLeft.startsWith('**Published:**') ||
trimmedLeft.startsWith('**Description:**');

return (
<p key={i} className={`leading-relaxed min-h-6 ${isMetadata ? 'text-gray-700 text-sm mt-1' : ''}`}>
{orderedMatch ? <span className="mr-2 font-medium text-gray-700">{orderedMatch[1]}.</span> : null}
{unorderedMatch ? <span className="mr-2 text-gray-500">•</span> : null}
{renderInline(lineBody, i)}
</p>
);
})}
</>
);
const renderParagraph = (rawLine: string, index: number) => {
const trimmedLeft = rawLine.trimStart();

// A run of dashes, asterisks or underscores on its own line is a section break.
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmedLeft.trimEnd())) {
return <hr key={index} className="my-3 border-gray-200"/>;
}

// Headings stay <p>: the levels the model picks are arbitrary and would pollute
// the page's outline, so they are styled rather than promoted to real headings.
const headingMatch = trimmedLeft.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
const level = headingMatch[1].length;
return (
<p
key={index}
className={`leading-relaxed mt-3 first:mt-0 font-semibold text-gray-900 ${level <= 2 ? 'text-base' : ''}`}
>
{renderInline(headingMatch[2], `${index}`)}
</p>
);
}

const orderedMatch = trimmedLeft.match(/^(\d+)\.\s+(.*)$/);
const unorderedMatch = trimmedLeft.match(/^[*-]\s+(.*)$/);
const lineBody = orderedMatch?.[2] ?? unorderedMatch?.[1] ?? rawLine;

const isMetadata =
trimmedLeft.startsWith('**Creator:**') ||
trimmedLeft.startsWith('**Published:**') ||
trimmedLeft.startsWith('**Description:**');

return (
<p key={index} className={`leading-relaxed min-h-6 ${isMetadata ? 'text-gray-700 text-sm mt-1' : ''}`}>
{orderedMatch ? <span className="mr-2 font-medium text-gray-700">{orderedMatch[1]}.</span> : null}
{unorderedMatch ? <span className="mr-2 text-gray-500">•</span> : null}
{renderInline(lineBody, `${index}`)}
</p>
);
};

const renderTable = (header: string[], alignments: CellAlignment[], rows: string[][], index: number) => {
// Ragged rows are common: render the widest row's worth of columns and pad the
// rest, so a missing cell leaves a gap instead of shifting the row's columns.
const columnCount = Math.max(header.length, ...rows.map(row => row.length));
const columns = Array.from({length: columnCount}, (_, column) => column);
const cellClass = (column: number) =>
`px-3 py-2 align-top break-words ${ALIGNMENT_CLASS[alignments[column] ?? 'left']}`;

// The bubble is `whitespace-pre-wrap`, which cells must not inherit, and it caps
// the width: let the table wrap to fit, and scroll only when it cannot.
return (
<div key={index} className="my-2 overflow-x-auto">
<table className="w-full min-w-[32rem] table-auto border-collapse whitespace-normal text-sm">
<thead>
<tr className="border-b border-gray-300 bg-gray-50">
{columns.map(column => (
<th key={column} className={`${cellClass(column)} font-semibold text-gray-900`}>
{renderInline(header[column] ?? '', `${index}-h${column}`)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowIndex} className="border-b border-gray-200 last:border-b-0">
{columns.map(column => (
<td key={column} className={`${cellClass(column)} text-gray-700`}>
{renderInline(row[column] ?? '', `${index}-r${rowIndex}c${column}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};

const lines = content.split('\n');
const blocks: JSX.Element[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// A table only starts where the delimiter row confirms it. While the answer is
// still streaming the header arrives first and reads as text for a moment, then
// snaps into the table as soon as the delimiter lands.
if (TABLE_ROW.test(line) && TABLE_DELIMITER.test(lines[i + 1]?.trim() ?? '')) {
const header = splitRow(line);
const alignments = parseAlignments(lines[i + 1].trim());
const rows: string[][] = [];
let end = i + 2;
while (end < lines.length && TABLE_ROW.test(lines[end].trim())) {
rows.push(splitRow(lines[end].trim()));
end++;
}
blocks.push(renderTable(header, alignments, rows, i));
i = end - 1;
continue;
}
blocks.push(renderParagraph(lines[i], i));
}

return <>{blocks}</>;
};
Loading