feat(web): render markdown for system and agent event details

Bring back the Marked-based renderer for system prompt and assistant
message bodies in the event details panel so headings, lists, inline
code, and fenced blocks render as formatted prose instead of a single
preformatted block. Tool input/result and command scripts continue to
render as fixed-width code since they're JSON/shell. Same URL/HTML
sanitization policy as the prior markdown integration: protocol-
relative and non-http(s)/mailto links are rewritten to empty hrefs,
and raw HTML tokens are dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-07 11:46:13 -07:00
parent de9656dfb7
commit 036ee0472f
No known key found for this signature in database

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { Marked } from "marked";
import { StageSidebar } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
@ -167,6 +168,50 @@ function oneLine(text: string): string {
return `${collapsed.slice(0, SUMMARY_MAX_CHARS - 1)}…`;
}
const SAFE_HTTP_URL_RE = /^https?:\/\//i;
const SAFE_MAILTO_URL_RE = /^mailto:/i;
function isSafeMarkdownHref(href: string): boolean {
return (
SAFE_HTTP_URL_RE.test(href) ||
SAFE_MAILTO_URL_RE.test(href) ||
href.startsWith("#") ||
(href.startsWith("/") && !href.startsWith("//"))
);
}
const markedSafe = new Marked();
markedSafe.use({
async: false,
walkTokens(token) {
if (
(token.type === "link" || token.type === "image") &&
typeof token.href === "string" &&
!isSafeMarkdownHref(token.href)
) {
token.href = "";
}
},
renderer: {
html() {
return "";
},
},
});
function Markdown({ content }: { content: string }) {
const html = useMemo(
() => markedSafe.parse(content, { async: false }) as string,
[content],
);
return (
<div
className="prose prose-sm max-w-none text-fg-3 prose-headings:text-fg-2 prose-strong:text-fg-2 prose-code:rounded prose-code:bg-overlay-strong prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.8em] prose-code:font-mono prose-code:text-fg-3 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-overlay-strong prose-pre:text-fg-3 prose-a:text-teal-500"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
const TOOL_NAME_DISPLAY: Record<string, string> = {
read_file: "Read",
write_file: "Write",
@ -296,7 +341,7 @@ function EventDetails({ turn, runStart }: { turn: TurnType; runStart: string | u
{(turn.kind === "system" || turn.kind === "assistant") && (
<DetailField label="Content">
<CodeBlock>{turn.content}</CodeBlock>
<Markdown content={turn.content} />
</DetailField>
)}