From 0e89924685f2c99ce034812cc65e3ac20327741c Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 30 Jan 2026 01:10:35 -0500 Subject: [PATCH] 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 --- src/server/services/html-exporter.ts | 337 ++++++++++++++++++++++----- tests/unit/html-exporter.test.ts | 4 +- 2 files changed, 277 insertions(+), 64 deletions(-) diff --git a/src/server/services/html-exporter.ts b/src/server/services/html-exporter.ts index 5246a38..89bc85b 100644 --- a/src/server/services/html-exporter.ts +++ b/src/server/services/html-exporter.ts @@ -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 = { + 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 { @@ -40,9 +61,7 @@ export async function generateExportHtml( continue; } if (lastWasRedacted) { - messageHtmlParts.push( - '
··· content redacted ···
' - ); + messageHtmlParts.push(renderRedactedDivider()); lastWasRedacted = false; } const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg; @@ -71,9 +90,18 @@ ${hljsCss}

Session Export

- Project: ${escapeHtml(session.project)} - Date: ${escapeHtml(dateStr)} - ${messageCount} messages + + + ${escapeHtml(session.project)} + + + + ${escapeHtml(dateStr)} + + + + ${messageCount} message${messageCount !== 1 ? "s" : ""} +
@@ -84,26 +112,55 @@ ${hljsCss} `; } +function renderRedactedDivider(): string { + return `
+
+
+ + content redacted +
+
+
`; +} + 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 - ? `
${escapeHtml(msg.toolInput)}
` + ? `
${escapeHtml(msg.toolInput)}
` : ""; - bodyHtml = `
${escapeHtml(msg.toolName || "Unknown Tool")}
${inputHtml}`; + bodyHtml = `
${escapeHtml(msg.toolName || "Unknown Tool")}
${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 = `
${escapeHtml(msg.content)}
`; } else { bodyHtml = renderMarkdown(msg.content); } - return `
-
${escapeHtml(label)}
-
${bodyHtml}
+ const timestamp = msg.timestamp ? formatTimestamp(msg.timestamp) : ""; + const timestampHtml = timestamp + ? `·${escapeHtml(timestamp)}` + : ""; + + return `
+
+ + ${escapeHtml(label)} + ${timestampHtml} +
+
${bodyHtml}
`; } +// marked.parse() is called synchronously here. In marked v14+ it can return +// Promise 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, "'"); +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; } } `; } diff --git a/tests/unit/html-exporter.test.ts b/tests/unit/html-exporter.test.ts index d374be2..74d1f38 100644 --- a/tests/unit/html-exporter.test.ts +++ b/tests/unit/html-exporter.test.ts @@ -114,6 +114,8 @@ describe("html-exporter", () => { const html = await generateExportHtml(makeExportRequest([msg])); expect(html).toContain("test-project"); expect(html).toContain("Session Export"); - expect(html).toContain("1 messages"); + expect(html).toContain("1 message"); + // Verify singular — should NOT contain "1 messages" + expect(html).not.toMatch(/\b1 messages\b/); }); });