diff --git a/src/server/services/html-exporter.ts b/src/server/services/html-exporter.ts index 77c1177..c591e6a 100644 --- a/src/server/services/html-exporter.ts +++ b/src/server/services/html-exporter.ts @@ -1,7 +1,7 @@ import { marked } from "marked"; import hljs from "highlight.js"; import { markedHighlight } from "marked-highlight"; -import type { ExportRequest, ParsedMessage } from "../../shared/types.js"; +import type { ExportRequest, ParsedMessage, ProgressSubtype } 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"; @@ -22,7 +22,11 @@ marked.use( // 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"]); +// Note: tool_result is handled explicitly in renderMessage() for diff detection. +const PREFORMATTED_CATEGORIES = new Set(["hook_progress", "file_snapshot"]); + +// Categories that render collapsed by default +const COLLAPSIBLE_CATEGORIES = new Set(["thinking", "tool_call", "tool_result"]); // Category dot/border colors matching the client-side design const CATEGORY_STYLES: Record = { @@ -37,10 +41,19 @@ const CATEGORY_STYLES: Record = { + hook: { text: "#484f58", bg: "rgba(72,79,88,0.1)" }, + bash: { text: "#d29922", bg: "rgba(210,153,34,0.1)" }, + mcp: { text: "#8b8cf8", bg: "rgba(139,140,248,0.1)" }, + agent: { text: "#bc8cff", bg: "rgba(188,140,255,0.1)" }, +}; + export async function generateExportHtml( req: ExportRequest ): Promise { const { session, visibleMessageUuids, redactedMessageUuids, autoRedactEnabled } = req; + const toolProgress = session.toolProgress || {}; const visibleSet = new Set(visibleMessageUuids); const redactedSet = new Set(redactedMessageUuids); @@ -65,7 +78,8 @@ export async function generateExportHtml( lastWasRedacted = false; } const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg; - messageHtmlParts.push(renderMessage(msgToRender)); + const progressEvents = msg.toolUseId ? toolProgress[msg.toolUseId] : undefined; + messageHtmlParts.push(renderMessage(msgToRender, progressEvents)); } const hljsCss = getHighlightCss(); @@ -100,7 +114,7 @@ ${hljsCss} - ${messageCount} message${messageCount !== 1 ? "s" : ""} + ${messageCount} message${messageCount !== 1 ? "s" : ""} @@ -108,6 +122,9 @@ ${hljsCss} ${messageHtmlParts.join("\n ")} + `; } @@ -123,9 +140,11 @@ function renderRedactedDivider(): string { `; } -function renderMessage(msg: ParsedMessage): string { +function renderMessage(msg: ParsedMessage, progressEvents?: ParsedMessage[]): string { const style = CATEGORY_STYLES[msg.category] || CATEGORY_STYLES.system_message; const label = CATEGORY_LABELS[msg.category]; + const isCollapsible = COLLAPSIBLE_CATEGORIES.has(msg.category); + let bodyHtml: string; if (msg.category === "tool_call") { @@ -133,10 +152,11 @@ function renderMessage(msg: ParsedMessage): string { ? `
${escapeHtml(msg.toolInput)}
` : ""; bodyHtml = `
${escapeHtml(msg.toolName || "Unknown Tool")}
${inputHtml}`; + } else if (msg.category === "tool_result") { + bodyHtml = isDiffContent(msg.content) + ? renderDiffHtml(msg.content) + : `
${escapeHtml(msg.content)}
`; } 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); @@ -147,16 +167,119 @@ function renderMessage(msg: ParsedMessage): string { ? `·${escapeHtml(timestamp)}` : ""; - return `
+ // Build collapsed preview for collapsible categories + let previewHtml = ""; + if (isCollapsible) { + let previewText: string; + if (msg.category === "thinking") { + const lineCount = msg.content.split("\n").filter(l => l.trim()).length; + previewText = `${lineCount} line${lineCount !== 1 ? "s" : ""}`; + } else if (msg.category === "tool_call") { + previewText = msg.toolName || "Unknown Tool"; + } else { + // tool_result — first 120 chars of first line + previewText = (msg.content.split("\n")[0] || "Result").substring(0, 120); + } + previewHtml = `·${escapeHtml(previewText)}`; + } + + // Chevron toggle button for collapsible messages + const chevronHtml = isCollapsible + ? `` + : ""; + + const dataAttrs = isCollapsible ? ' data-collapsed="true"' : ""; + + // Progress badge for tool_call messages + const progressHtml = (msg.category === "tool_call" && progressEvents && progressEvents.length > 0) + ? renderProgressBadge(progressEvents) + : ""; + + return `
- + ${chevronHtml} ${escapeHtml(label)} ${timestampHtml} + ${previewHtml}
${bodyHtml}
+ ${progressHtml}
`; } +function isDiffContent(content: string): boolean { + const lines = content.split("\n").slice(0, 30); + let hunkHeaders = 0; + let diffLines = 0; + for (const line of lines) { + if (line.startsWith("@@") || line.startsWith("diff --")) { + hunkHeaders++; + } else if (line.startsWith("+++") || line.startsWith("---")) { + hunkHeaders++; + } else if (line.startsWith("+") || line.startsWith("-")) { + diffLines++; + } + } + return hunkHeaders >= 1 && diffLines >= 2; +} + +function renderDiffHtml(content: string): string { + const lines = content.split("\n"); + const htmlLines = lines.map((line) => { + const escaped = escapeHtml(line); + if (line.startsWith("@@")) { + return `${escaped}`; + } + if (line.startsWith("+++") || line.startsWith("---")) { + return `${escaped}`; + } + if (line.startsWith("diff --")) { + return `${escaped}`; + } + if (line.startsWith("+")) { + return `${escaped}`; + } + if (line.startsWith("-")) { + return `${escaped}`; + } + return escaped; + }); + return `
${htmlLines.join("\n")}
`; +} + +function renderProgressBadge(events: ParsedMessage[]): string { + // Count by subtype + const counts: Partial> = {}; + for (const e of events) { + const sub = e.progressSubtype || "hook"; + counts[sub] = (counts[sub] || 0) + 1; + } + + // Pill row + const pills = (Object.entries(counts) as [ProgressSubtype, number][]) + .map(([sub, count]) => { + const colors = PROGRESS_SUBTYPE_COLORS[sub] || PROGRESS_SUBTYPE_COLORS.hook; + return `${escapeHtml(sub)}: ${count}`; + }) + .join(""); + + // Drawer rows + const rows = events + .map((e) => { + const time = e.timestamp ? formatTimestamp(e.timestamp) : "--:--:--"; + const sub = e.progressSubtype || "hook"; + return `
${escapeHtml(time)}${escapeHtml(sub)}${escapeHtml(e.content)}
`; + }) + .join("\n "); + + return `
+ + +
`; +} + // 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 @@ -179,6 +302,28 @@ function formatTimestamp(ts: string): string { }); } +function getExportJs(): string { + return ` +document.addEventListener("click", function(e) { + var toggle = e.target.closest(".collapsible-toggle"); + if (toggle) { + var msg = toggle.closest(".message"); + if (!msg) return; + var collapsed = msg.getAttribute("data-collapsed") === "true"; + msg.setAttribute("data-collapsed", collapsed ? "false" : "true"); + return; + } + var progressToggle = e.target.closest(".progress-toggle"); + if (progressToggle) { + var drawer = progressToggle.nextElementSibling; + if (drawer && drawer.classList.contains("progress-drawer")) { + drawer.style.display = drawer.style.display === "none" ? "block" : "none"; + } + } +}); +`; +} + function getHighlightCss(): string { // Dark theme highlight.js (GitHub Dark) matching the client return ` @@ -259,6 +404,19 @@ body { border-radius: 0.75rem; border: 1px solid #30363d; box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3); + position: relative; + overflow: hidden; +} +.message::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + border-radius: 0.75rem 0 0 0.75rem; + background: currentColor; + opacity: 0.5; } .message-header { display: flex; @@ -298,6 +456,97 @@ body { line-height: 1.5rem; } +/* Collapsible toggle */ +.collapsible-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + background: none; + border: none; + color: #484f58; + cursor: pointer; + padding: 0; + flex-shrink: 0; + transition: color 0.15s, transform 0.15s; +} +.collapsible-toggle:hover { color: #e6edf3; } +.message[data-collapsed="false"] .collapsible-toggle svg { transform: rotate(90deg); } +.message[data-collapsed="true"] .message-body { display: none; } +.collapsed-preview { + font-size: 0.75rem; + color: #484f58; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 300px; +} + +/* Diff highlighting */ +.diff-view { font-size: 0.8125rem; line-height: 1.6; } +.diff-add { color: #7ee787; background: rgba(46,160,67,0.15); display: block; } +.diff-del { color: #ffa198; background: rgba(248,81,73,0.15); display: block; } +.diff-hunk { color: #bc8cff; display: block; } +.diff-meta { color: #8b949e; display: block; } +.diff-header { color: #e6edf3; font-weight: 600; display: block; } + +/* Progress badge */ +.progress-badge { + padding: 0.25rem 1rem 0.75rem; +} +.progress-toggle { + display: flex; + align-items: center; + gap: 0.375rem; + background: none; + border: none; + cursor: pointer; + padding: 0; + font-size: 0.6875rem; +} +.progress-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 0.6875rem; +} +.progress-drawer { + margin-top: 0.5rem; + padding: 0.5rem; + background: #161b22; + border: 1px solid #21262d; + border-radius: 0.5rem; +} +.progress-row { + display: flex; + gap: 0.5rem; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 0.6875rem; + line-height: 1.4; + padding: 0.125rem 0; +} +.progress-time { + width: 5rem; + flex-shrink: 0; + color: #484f58; + font-variant-numeric: tabular-nums; +} +.progress-subtype { + width: 3.5rem; + flex-shrink: 0; + font-weight: 500; +} +.progress-content { + flex: 1; + min-width: 0; + overflow-wrap: break-word; + color: #8b949e; +} + /* Tool name */ .tool-name { font-weight: 500; margin-bottom: 0.5rem; } @@ -405,6 +654,10 @@ body { body { background: #1c2128; } .session-export { padding: 0; max-width: 100%; } .session-header, .message { box-shadow: none; break-inside: avoid; } + .message-body { display: block !important; } + .collapsed-preview { display: none !important; } + .collapsible-toggle { display: none !important; } + .progress-drawer { display: block !important; } } `; }