Redesign HTML exporter with dark theme, timestamps, and performance fixes

Visual overhaul of exported HTML to match the new client dark design:
- Replace category-specific CSS classes with inline border/dot/text styles
  from a CATEGORY_STYLES map matching client-side colors
- Add message header layout with category dot, label, and timestamp
- Add Inter font family, refined prose typography, and proper code styling
- Add print-friendly media query
- Redesign redacted divider with SVG eye-slash icon and red accent
- Add SVG icons to session header metadata (project, date, message count)
- Fix singular/plural for '1 message' vs 'N messages'

Performance: Skip markdown parsing for hook_progress, tool_result, and
file_snapshot categories (structured data). Render as preformatted text
instead, avoiding expensive marked.parse() on large JSON blobs (~300ms each).

Replace local escapeHtml with shared/escape-html module. Add formatTimestamp
helper. Add cast safety comment for marked.parse() sync usage.

Update test to verify singular message count ('1 message' not '1 messages').

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 01:10:35 -05:00
parent 9716091ecc
commit 0e89924685
2 changed files with 277 additions and 64 deletions

View File

@@ -4,6 +4,7 @@ import { markedHighlight } from "marked-highlight";
import type { ExportRequest, ParsedMessage } from "../../shared/types.js";
import { CATEGORY_LABELS } from "../../shared/types.js";
import { redactMessage } from "../../shared/sensitive-redactor.js";
import { escapeHtml } from "../../shared/escape-html.js";
marked.use(
markedHighlight({
@@ -11,11 +12,31 @@ marked.use(
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
// Plain-text fallback: highlightAuto tries every grammar (~6.7ms/block)
// vs explicit highlight (~0.04ms). With thousands of unlabeled blocks
// this dominates export time (50+ seconds). Escaping is sufficient.
return escapeHtml(code);
},
})
);
// Categories whose content is structured data (JSON, logs, snapshots) — not markdown.
// Rendered as preformatted text to avoid the cost of markdown parsing on large blobs.
const PREFORMATTED_CATEGORIES = new Set(["hook_progress", "tool_result", "file_snapshot"]);
// Category dot/border colors matching the client-side design
const CATEGORY_STYLES: Record<string, { dot: string; border: string; text: string }> = {
user_message: { dot: "#58a6ff", border: "#1f3a5f", text: "#58a6ff" },
assistant_text: { dot: "#3fb950", border: "#1a4d2e", text: "#3fb950" },
thinking: { dot: "#bc8cff", border: "#3b2d6b", text: "#bc8cff" },
tool_call: { dot: "#d29922", border: "#4d3a15", text: "#d29922" },
tool_result: { dot: "#8b8cf8", border: "#2d2d60", text: "#8b8cf8" },
system_message: { dot: "#8b949e", border: "#30363d", text: "#8b949e" },
hook_progress: { dot: "#484f58", border: "#21262d", text: "#484f58" },
file_snapshot: { dot: "#f778ba", border: "#5c2242", text: "#f778ba" },
summary: { dot: "#2dd4bf", border: "#1a4d45", text: "#2dd4bf" },
};
export async function generateExportHtml(
req: ExportRequest
): Promise<string> {
@@ -40,9 +61,7 @@ export async function generateExportHtml(
continue;
}
if (lastWasRedacted) {
messageHtmlParts.push(
'<div class="redacted-divider">··· content redacted ···</div>'
);
messageHtmlParts.push(renderRedactedDivider());
lastWasRedacted = false;
}
const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg;
@@ -71,9 +90,18 @@ ${hljsCss}
<header class="session-header">
<h1>Session Export</h1>
<div class="meta">
<span class="project">Project: ${escapeHtml(session.project)}</span>
<span class="date">Date: ${escapeHtml(dateStr)}</span>
<span class="count">${messageCount} messages</span>
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"/></svg>
${escapeHtml(session.project)}
</span>
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"/></svg>
${escapeHtml(dateStr)}
</span>
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"/></svg>
${messageCount} message${messageCount !== 1 ? "s" : ""}
</span>
</div>
</header>
<main class="messages">
@@ -84,26 +112,55 @@ ${hljsCss}
</html>`;
}
function renderRedactedDivider(): string {
return `<div class="redacted-divider">
<div class="redacted-line"></div>
<div class="redacted-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"/></svg>
<span>content redacted</span>
</div>
<div class="redacted-line"></div>
</div>`;
}
function renderMessage(msg: ParsedMessage): string {
const categoryClass = msg.category.replace(/_/g, "-");
const style = CATEGORY_STYLES[msg.category] || CATEGORY_STYLES.system_message;
const label = CATEGORY_LABELS[msg.category];
let bodyHtml: string;
if (msg.category === "tool_call") {
const inputHtml = msg.toolInput
? `<pre class="tool-input"><code>${escapeHtml(msg.toolInput)}</code></pre>`
? `<pre class="hljs"><code>${escapeHtml(msg.toolInput)}</code></pre>`
: "";
bodyHtml = `<div class="tool-name">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
bodyHtml = `<div class="tool-name" style="color: ${style.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
} else if (PREFORMATTED_CATEGORIES.has(msg.category)) {
// These categories contain structured data (JSON, logs, snapshots), not prose.
// Rendering them through marked is both incorrect and extremely slow on large
// content (370KB JSON blobs take ~300ms each in marked.parse).
bodyHtml = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
} else {
bodyHtml = renderMarkdown(msg.content);
}
return `<div class="message ${categoryClass}">
<div class="message-label">${escapeHtml(label)}</div>
<div class="message-body">${bodyHtml}</div>
const timestamp = msg.timestamp ? formatTimestamp(msg.timestamp) : "";
const timestampHtml = timestamp
? `<span class="header-sep">&middot;</span><span class="message-time">${escapeHtml(timestamp)}</span>`
: "";
return `<div class="message" style="border-color: ${style.border}">
<div class="message-header">
<span class="message-dot" style="background: ${style.dot}"></span>
<span class="message-label">${escapeHtml(label)}</span>
${timestampHtml}
</div>
<div class="message-body prose-message">${bodyHtml}</div>
</div>`;
}
// marked.parse() is called synchronously here. In marked v14+ it can return
// Promise<string> if async extensions are configured. Our markedHighlight setup
// is synchronous, so the cast is safe — but do not add async extensions without
// updating this callsite.
function renderMarkdown(text: string): string {
try {
return marked.parse(text) as string;
@@ -112,18 +169,23 @@ function renderMarkdown(text: string): string {
}
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
function formatTimestamp(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return "";
}
}
function getHighlightCss(): string {
// Dark theme highlight.js (GitHub Dark) matching the client
return `
.hljs{color:#e6edf3;background:#161b22}
.hljs{background:#0d1117;color:#e6edf3;padding:1rem;border-radius:0.5rem;border:1px solid #30363d;overflow-x:auto;font-size:0.8125rem;line-height:1.6;white-space:pre-wrap;word-break:break-word}
.hljs-comment,.hljs-quote{color:#8b949e;font-style:italic}
.hljs-keyword,.hljs-selector-tag,.hljs-subst{color:#ff7b72;font-weight:bold}
.hljs-number,.hljs-literal,.hljs-variable,.hljs-template-variable,.hljs-tag .hljs-attr{color:#79c0ff}
@@ -144,59 +206,208 @@ function getHighlightCss(): string {
function getExportCss(): string {
return `
/* Reset & base */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117; color: #e6edf3; line-height: 1.6;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0d1117;
color: #e6edf3;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
.session-export { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
/* Layout */
.session-export { max-width: 64rem; margin: 0 auto; padding: 1.25rem 1.5rem 3rem; }
/* Header */
.session-header {
background: #161b22; color: #e6edf3; padding: 1.5rem 2rem;
border-radius: 12px; margin-bottom: 2rem; border: 1px solid #30363d;
background: #1c2128;
padding: 1.25rem 1.5rem;
border-radius: 0.75rem;
margin-bottom: 1rem;
border: 1px solid #30363d;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
}
.session-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.session-header .meta { display: flex; gap: 1.5rem; font-size: 0.875rem; color: #8b949e; flex-wrap: wrap; }
.messages { display: flex; flex-direction: column; gap: 1rem; }
.session-header h1 {
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.02em;
color: #e6edf3;
margin-bottom: 0.5rem;
}
.meta {
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #484f58;
line-height: 1rem;
letter-spacing: 0.01em;
}
.meta-item svg { color: #484f58; flex-shrink: 0; }
/* Messages */
.messages { display: flex; flex-direction: column; gap: 0.75rem; }
/* Message card */
.message {
padding: 1rem 1.25rem; border-radius: 10px;
border-left: 4px solid #30363d; background: #161b22;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
background: #1c2128;
border-radius: 0.75rem;
border: 1px solid #30363d;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
}
.message-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem 0.25rem;
}
.message-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
flex-shrink: 0;
}
.message-label {
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; margin-bottom: 0.5rem; color: #8b949e;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #484f58;
line-height: 1rem;
}
.message-body { overflow-wrap: break-word; }
.message-body pre {
background: #0d1117; padding: 1rem; border-radius: 6px;
overflow-x: auto; font-size: 0.875rem; margin: 0.5rem 0;
border: 1px solid #30363d;
.header-sep {
color: #30363d;
font-size: 0.75rem;
}
.message-body code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.875em; }
.message-body p { margin: 0.5em 0; }
.message-body ul, .message-body ol { padding-left: 1.5em; margin: 0.5em 0; }
.message-body h1,.message-body h2,.message-body h3 { margin: 0.75em 0 0.25em; color: #f0f6fc; }
.message-body a { color: #58a6ff; }
.message-body table { border-collapse: collapse; margin: 0.5em 0; width: 100%; }
.message-body th, .message-body td { border: 1px solid #30363d; padding: 0.4em 0.75em; text-align: left; }
.message-body th { background: #1c2128; }
.message-body blockquote { border-left: 3px solid #30363d; padding-left: 1em; color: #8b949e; margin: 0.5em 0; }
.message-body hr { border: none; border-top: 1px solid #30363d; margin: 1em 0; }
.user-message { border-left-color: #58a6ff; background: #121d2f; }
.assistant-text { border-left-color: #3fb950; background: #161b22; }
.thinking { border-left-color: #bc8cff; background: #1c1631; }
.tool-call { border-left-color: #d29922; background: #1c1a10; }
.tool-result { border-left-color: #8b8cf8; background: #181830; }
.system-message { border-left-color: #8b949e; background: #1c2128; font-size: 0.875rem; }
.hook-progress { border-left-color: #484f58; background: #131820; font-size: 0.875rem; }
.file-snapshot { border-left-color: #f778ba; background: #241525; }
.summary { border-left-color: #2dd4bf; background: #122125; }
.tool-name { font-weight: 600; color: #d29922; margin-bottom: 0.5rem; }
.tool-input { font-size: 0.8rem; }
.message-time {
font-size: 0.75rem;
color: #484f58;
font-variant-numeric: tabular-nums;
line-height: 1rem;
letter-spacing: 0.01em;
}
.message-body {
padding: 0.25rem 1rem 1rem;
overflow-wrap: break-word;
font-size: 0.875rem;
line-height: 1.5rem;
}
/* Tool name */
.tool-name { font-weight: 500; margin-bottom: 0.5rem; }
/* redacted-divider */
.redacted-divider {
text-align: center; color: #484f58; font-size: 0.875rem;
padding: 0.75rem 0; border-top: 1px dashed #30363d; border-bottom: 1px dashed #30363d;
margin: 0.5rem 0;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
}
.redacted-line {
flex: 1;
border-top: 1px dashed rgba(127, 29, 29, 0.4);
}
.redacted-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
color: #f87171;
line-height: 1rem;
}
.redacted-label svg { color: #f87171; flex-shrink: 0; }
/* ═══════════════════════════════════════════════
Prose — message content typography
═══════════════════════════════════════════════ */
.prose-message h1 {
font-size: 1.25rem; font-weight: 600;
margin-top: 1.25rem; margin-bottom: 0.5rem;
letter-spacing: -0.02em; color: #f0f6fc;
}
.prose-message h2 {
font-size: 1.125rem; font-weight: 600;
margin-top: 1rem; margin-bottom: 0.375rem;
letter-spacing: -0.01em; color: #f0f6fc;
}
.prose-message h3 {
font-size: 1rem; font-weight: 600;
margin-top: 0.875rem; margin-bottom: 0.375rem;
color: #f0f6fc;
}
.prose-message p {
margin-top: 0.5rem; margin-bottom: 0.5rem;
line-height: 1.625;
}
.prose-message p:first-child { margin-top: 0; }
.prose-message p:last-child { margin-bottom: 0; }
.prose-message ul, .prose-message ol {
margin-top: 0.5rem; margin-bottom: 0.5rem;
padding-left: 1.25rem;
}
.prose-message li {
margin-top: 0.25rem; margin-bottom: 0.25rem;
line-height: 1.5;
}
.prose-message code:not(pre code) {
font-family: "JetBrains Mono", "Fira Code", "SF Mono", ui-monospace, monospace;
font-size: 0.8125em;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: #0d1117;
border: 1px solid #21262d;
color: #bc8cff;
font-weight: 500;
}
.prose-message pre {
margin-top: 0.75rem; margin-bottom: 0.75rem;
}
.prose-message blockquote {
border-left: 3px solid #30363d;
padding-left: 0.75rem;
color: #8b949e;
margin-top: 0.5rem; margin-bottom: 0.5rem;
}
.prose-message a {
color: #58a6ff;
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-message a:hover { color: #79c0ff; }
.prose-message table {
width: 100%; border-collapse: collapse;
margin-top: 0.75rem; margin-bottom: 0.75rem;
font-size: 0.8125rem;
}
.prose-message th, .prose-message td {
border: 1px solid #30363d;
padding: 0.375rem 0.75rem;
text-align: left;
}
.prose-message th {
background: #161b22;
font-weight: 600;
}
.prose-message hr {
border: none;
border-top: 1px solid #30363d;
margin: 1rem 0;
}
/* Print-friendly */
@media print {
body { background: #1c2128; }
.session-export { padding: 0; max-width: 100%; }
.session-header, .message { box-shadow: none; break-inside: avoid; }
}
`;
}