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:
@@ -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">·</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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
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; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user