Files
AgentHub/client/src/components/conversation/MarkdownContent.tsx
T
nntrivi2001 58be04a4e7 fix(chat): render a markdown table that follows prose with no blank line (#83)
The chat renderer's paragraph collector never stopped at a table header,
so a table right after a line of text was shown as raw pipes. A table now
interrupts a paragraph as in GFM, and the divider accepts single dashes.

Closes #83

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:51:30 +07:00

672 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file MarkdownContent.tsx
* @description Lightweight markdown renderer for conversation messages. Supports the
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs. A file path — absolute, or relative with a folder and
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
* inline code or as a markdown link's target — opens through `OpenFileContext`
* when the page provides one (the page resolves a relative one against its own
* folder); `linkify` does the same links for plain text. To make a reply easy to scan, bold is coloured
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
* of how serious it is.
*
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
* copy-to-clipboard.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `./CodeBlock`
*
* ## Public surface
* - `MarkdownContent` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **MarkdownContent**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import React from "react";
import { CodeBlock } from "./CodeBlock";
type Block =
| { kind: "code"; lang: string; code: string }
| { kind: "heading"; level: number; text: string }
| { kind: "list"; ordered: boolean; items: string[] }
| { kind: "quote"; text: string }
| { kind: "hr" }
| {
kind: "table";
header: string[];
aligns: ("left" | "center" | "right" | null)[];
rows: string[][];
}
| { kind: "para"; text: string };
const FENCE_RE = /^([ \t]*)(```|~~~)(\s*[\w+-]*)\s*$/;
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
const QUOTE_RE = /^\s*>\s?(.*)$/;
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
/** A GFM table starts at a line holding a pipe that is followed by a divider. */
function isTableStart(lines: string[], i: number): boolean {
return lines[i]!.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!);
}
function splitTableRow(line: string): string[] {
// Trim leading/trailing pipes, then split, respecting escaped pipes.
let s = line.trim();
if (s.startsWith("|")) s = s.slice(1);
if (s.endsWith("|")) s = s.slice(0, -1);
// Split on unescaped pipes
const parts: string[] = [];
let cur = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && s[i + 1] === "|") {
cur += "|";
i++;
continue;
}
if (s[i] === "|") {
parts.push(cur.trim());
cur = "";
} else {
cur += s[i];
}
}
parts.push(cur.trim());
return parts;
}
function parseAlignments(divider: string): ("left" | "center" | "right" | null)[] {
return splitTableRow(divider).map((cell) => {
const left = cell.startsWith(":");
const right = cell.endsWith(":");
if (left && right) return "center";
if (right) return "right";
if (left) return "left";
return null;
});
}
function parseBlocks(src: string): Block[] {
const lines = src.split("\n");
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i]!;
// Fenced code block
const fence = line.match(FENCE_RE);
if (fence) {
const fenceMarker = fence[2]!;
const lang = (fence[3] ?? "").trim();
const codeLines: string[] = [];
i++;
while (i < lines.length) {
const closing = lines[i]!.match(/^([ \t]*)(```|~~~)\s*$/);
if (closing && closing[2] === fenceMarker) {
i++;
break;
}
codeLines.push(lines[i]!);
i++;
}
blocks.push({ kind: "code", lang, code: codeLines.join("\n") });
continue;
}
// Blank line
if (line.trim() === "") {
i++;
continue;
}
// ATX heading
const heading = line.match(HEADING_RE);
if (heading) {
blocks.push({ kind: "heading", level: heading[1]!.length, text: heading[2]! });
i++;
continue;
}
// Horizontal rule
if (HR_RE.test(line)) {
blocks.push({ kind: "hr" });
i++;
continue;
}
// Table: header line followed by an alignment divider
if (isTableStart(lines, i)) {
const header = splitTableRow(line);
const aligns = parseAlignments(lines[i + 1]!);
i += 2;
const rows: string[][] = [];
while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim() !== "") {
rows.push(splitTableRow(lines[i]!));
i++;
}
blocks.push({ kind: "table", header, aligns, rows });
continue;
}
// Lists
const ulMatch = line.match(UL_RE);
const olMatch = line.match(OL_RE);
if (ulMatch || olMatch) {
const ordered = !!olMatch;
const itemRe = ordered ? OL_RE : UL_RE;
const items: string[] = [];
while (i < lines.length) {
const m = lines[i]!.match(itemRe);
if (m) {
items.push(m[3]!);
i++;
while (
i < lines.length &&
lines[i]!.trim() !== "" &&
!lines[i]!.match(UL_RE) &&
!lines[i]!.match(OL_RE) &&
/^\s+\S/.test(lines[i]!)
) {
items[items.length - 1] += "\n" + lines[i]!.trim();
i++;
}
} else {
break;
}
}
blocks.push({ kind: "list", ordered, items });
continue;
}
// Blockquote
if (QUOTE_RE.test(line)) {
const qLines: string[] = [];
while (i < lines.length) {
const m = lines[i]!.match(QUOTE_RE);
if (!m) break;
qLines.push(m[1]!);
i++;
}
blocks.push({ kind: "quote", text: qLines.join("\n") });
continue;
}
// Paragraph: collect until a blank line or the start of another block —
// a table included, which (as in GFM) may follow prose with no blank line.
const paraLines: string[] = [line];
i++;
while (i < lines.length) {
const nl = lines[i]!;
if (
nl.trim() === "" ||
FENCE_RE.test(nl) ||
HEADING_RE.test(nl) ||
HR_RE.test(nl) ||
UL_RE.test(nl) ||
OL_RE.test(nl) ||
QUOTE_RE.test(nl) ||
isTableStart(lines, i)
) {
break;
}
paraLines.push(nl);
i++;
}
blocks.push({ kind: "para", text: paraLines.join("\n") });
}
return blocks;
}
/** Opens a file a message names; provided by a view that can show one. */
export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined);
const LINK_CLASS =
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
// A `:12` or `:12:5` line suffix belongs to the link (Claude writes `path:line`).
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?)?/;
// A relative path needs a folder AND an extension starting with a letter
// (`docs/API.md`, `./a/b.ts:9`), so "and/or" and "3/4.5" stay text; a first segment with an inner dot is a host
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
const REL_PATH_RE =
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
/** A path at the start of `text`, absolute or relative, or null. */
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
// A markdown link target that is a page, not a file.
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
// A link starts a word: "and/or" or "a/b/c" stays text.
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
// 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like
// "2fd0c903" stay text.
const NUM_RE =
/^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u;
const numStart = (text: string, i: number) =>
i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!);
// A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is.
const LABEL_RE =
/^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i;
const LABEL_CLASS = (word: string) =>
/^(error|lỗi|caution|danger|breaking)$/i.test(word)
? "text-status-danger"
: /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word)
? "text-status-warning"
: "text-accent";
function UrlLink({ url }: { url: string }) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
{url}
</a>
);
}
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
function decodeHref(href: string): string {
let p = href
.replace(/^file:\/\//i, "")
.replace(/#L(\d+)(?:-L?\d+)?$/, ":$1")
.replace(/#.*$/, "");
try {
p = decodeURIComponent(p);
} catch {
/* a stray % stays as written */
}
return p;
}
/** A path as a button that opens it, or as it was when nothing can. */
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
const open = React.useContext(OpenFileContext);
if (!open) return <>{children}</>;
return (
<button
type="button"
title={path}
onClick={() => open(stripLine(path))}
className={`${LINK_CLASS} text-left`}
>
{children}
</button>
);
}
/** Plain text with its URLs and absolute paths as links — nothing else parsed. */
export function linkify(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
let buf = "";
for (let i = 0; i < text.length;) {
const rest = text.slice(i);
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
const path = !url && atWordStart(text, i) && matchPath(rest);
const hit = url || path;
if (!hit) {
buf += text[i++];
continue;
}
if (buf) out.push(buf);
buf = "";
out.push(
url ? (
<UrlLink key={i} url={url} />
) : (
<PathLink key={i} path={hit}>
{hit}
</PathLink>
)
);
i += hit.length;
}
if (buf) out.push(buf);
return out;
}
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
let i = 0;
let buf = "";
let n = 0;
const flush = () => {
if (buf) {
out.push(buf);
buf = "";
}
};
const push = (node: React.ReactNode) => {
flush();
out.push(<React.Fragment key={`${baseKey}-${n++}`}>{node}</React.Fragment>);
};
while (i < text.length) {
const rest = text.slice(i);
// Inline code: `...`
const codeM = rest.match(/^`([^`\n]+)`/);
if (codeM) {
const code = (
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
{codeM[1]}
</code>
);
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
i += codeM[0].length;
continue;
}
// Bold: **...** or __...__
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
if (boldM) {
push(
<strong className="font-bold text-accent">
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
</strong>
);
i += boldM[0].length;
continue;
}
// Italic: *...* or _..._
const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/);
if (italicM) {
push(
<em className="italic text-fg-secondary">
{renderInline(italicM[2]!, `${baseKey}-i${n}`)}
</em>
);
i += italicM[0].length;
continue;
}
// Strikethrough
const strikeM = rest.match(/^~~(.+?)~~/);
if (strikeM) {
push(
<span className="line-through text-fg-muted">
{renderInline(strikeM[1]!, `${baseKey}-s${n}`)}
</span>
);
i += strikeM[0].length;
continue;
}
// Markdown link
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
if (linkM) {
const href = linkM[2]!;
// `[x.ts](src/x.ts#L12)` names a file: open it like a bare path.
if (
/^file:\/\//i.test(href) ||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
) {
push(
<PathLink path={decodeHref(href)}>{renderInline(linkM[1]!, `${baseKey}-l${n}`)}</PathLink>
);
i += linkM[0].length;
continue;
}
push(
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
>
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
</a>
);
i += linkM[0].length;
continue;
}
// Auto-link
const urlM = rest.match(URL_RE);
if (urlM) {
push(<UrlLink url={urlM[0]} />);
i += urlM[0].length;
continue;
}
// An absolute path at a word start
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
if (pathM) {
push(<PathLink path={pathM}>{pathM}</PathLink>);
i += pathM.length;
continue;
}
const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null;
if (labelM) {
push(<span className={`font-bold ${LABEL_CLASS(labelM[1]!)}`}>{labelM[0]}</span>);
i += labelM[0].length;
continue;
}
const numM = numStart(text, i) ? NUM_RE.exec(rest) : null;
if (numM) {
push(
<span className="font-semibold tabular-nums text-teal-700 dark:text-teal-300">
{numM[0]}
</span>
);
i += numM[0].length;
continue;
}
buf += text[i]!;
i++;
}
flush();
return out;
}
/** Render a single list item, handling [ ] / [x] task list prefixes. */
function renderListItem(item: string, key: string): React.ReactNode {
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
if (taskMatch) {
const checked = taskMatch[1]!.toLowerCase() === "x";
return (
<span className="inline-flex items-baseline gap-2">
<span
className={`inline-block w-3 h-3 rounded-sm border flex-shrink-0 translate-y-0.5 ${
checked
? "bg-status-success/40 border-status-success/60"
: "bg-surface-4 border-surface-3"
}`}
aria-hidden="true"
/>
<span className={checked ? "text-fg-muted line-through" : ""}>
{renderInline(taskMatch[2]!, key)}
</span>
</span>
);
}
return renderInline(item, key);
}
interface MarkdownContentProps {
text: string;
/** Tighter spacing for nested contexts (list items, quotes). */
dense?: boolean;
/** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */
prose?: boolean;
}
const HEADING_STYLES = [
"text-[18px] font-semibold text-fg-primary mt-2 pb-1 border-b border-surface-3",
"text-[16px] font-semibold text-fg-primary mt-2",
"text-[15px] font-semibold text-fg-primary",
"text-sm font-semibold text-fg-primary",
"text-sm font-medium text-fg-secondary",
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
];
export function MarkdownContent({ text, dense = false, prose = false }: MarkdownContentProps) {
const blocks = parseBlocks(text);
const gap = dense ? "space-y-1.5" : "space-y-2.5";
// A paragraph or list item takes the root's size and colour in prose.
const bodyText = prose ? "" : "text-sm text-fg-secondary";
return (
<div
className={`${prose ? "font-serif text-[15px] leading-7 text-fg-primary" : "text-sm text-fg-secondary leading-relaxed"} ${gap}`}
>
{blocks.map((b, idx) => {
switch (b.kind) {
case "code":
return <CodeBlock key={idx} code={b.code} lang={b.lang} />;
case "heading": {
const cls = HEADING_STYLES[b.level - 1] ?? HEADING_STYLES[5];
return (
<div key={idx} className={cls}>
{renderInline(b.text, `h${idx}`)}
</div>
);
}
case "list":
if (b.ordered) {
return (
<ol
key={idx}
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
>
{b.items.map((item, i) => (
<li key={i} className={bodyText}>
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
</ol>
);
}
return (
<ul
key={idx}
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
>
{b.items.map((item, i) => (
<li key={i} className={bodyText}>
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
</ul>
);
case "quote":
return (
<blockquote
key={idx}
className="relative border-l-2 border-violet-400/50 pl-3 pr-2 py-1 text-fg-secondary italic bg-violet-500/[0.04] rounded-r"
>
{renderInline(b.text, `q${idx}`)}
</blockquote>
);
case "hr":
return (
<hr
key={idx}
className="border-0 h-px bg-gradient-to-r from-transparent via-surface-3 to-transparent my-2"
/>
);
case "table": {
const alignClass = (a: "left" | "center" | "right" | null) =>
a === "center" ? "text-center" : a === "right" ? "text-right" : "text-left";
return (
<div
key={idx}
className="overflow-x-auto rounded-md border border-surface-3 bg-surface-4/40"
>
<table className="w-full text-xs border-collapse">
<thead className="bg-surface-3/60">
<tr>
{b.header.map((cell, i) => (
<th
key={i}
className={`px-3 py-1.5 font-semibold text-fg-secondary border-b border-surface-3 ${alignClass(b.aligns[i] ?? null)}`}
>
{renderInline(cell, `th${idx}-${i}`)}
</th>
))}
</tr>
</thead>
<tbody>
{b.rows.map((row, ri) => (
<tr
key={ri}
className="border-b border-surface-3/50 last:border-b-0 hover:bg-surface-3/30"
>
{row.map((cell, ci) => (
<td
key={ci}
className={`px-3 py-1.5 text-fg-secondary align-top ${alignClass(b.aligns[ci] ?? null)}`}
>
{renderInline(cell, `td${idx}-${ri}-${ci}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
case "para":
return (
<p key={idx} className={`${bodyText} whitespace-pre-wrap break-words`}>
{renderInline(b.text, `p${idx}`)}
</p>
);
}
})}
</div>
);
}