Enhance HTML export with collapsible messages, diff highlighting, and progress badges

Major overhaul of the static HTML export to match the interactive viewer:

Collapsible messages:
- thinking, tool_call, and tool_result categories render collapsed by
  default with data-collapsed attribute
- Chevron toggle button rotates on expand/collapse
- Collapsed preview shows: line count (thinking), tool name (tool_call),
  or first 120 chars (tool_result)
- Print media query forces all sections expanded and hides toggle UI

Diff highlighting:
- tool_result content is checked for diff patterns (hunk headers, +/-
  lines) via isDiffContent() heuristic
- Diff content renders with color-coded spans: green additions, red
  deletions, purple hunk headers, gray meta lines

Progress badges:
- tool_call messages with associated progress events render a clickable
  pill row showing event counts by subtype (hook/bash/mcp/agent)
- Clicking toggles a drawer with timestamped event log
- Subtype colors match the client-side ProgressBadge component

Interactive JavaScript:
- Export now includes a <script> block for toggle interactivity
- Single delegated click handler on document for both collapsible
  messages and progress drawers

CSS additions:
- Left color bar via ::before pseudo-element on .message
- Collapsible toggle, collapsed preview, diff highlighting, and
  progress badge/drawer styles
- Print overrides to show all content and hide interactive controls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 23:04:21 -05:00
parent f69ba1f32a
commit 51a54e3fdd

View File

@@ -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<string, { dot: string; border: string; text: string }> = {
@@ -37,10 +41,19 @@ const CATEGORY_STYLES: Record<string, { dot: string; border: string; text: strin
summary: { dot: "#2dd4bf", border: "#1a4d45", text: "#2dd4bf" },
};
// Progress subtype colors for the export badge pills
const PROGRESS_SUBTYPE_COLORS: Record<ProgressSubtype, { text: string; bg: string }> = {
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<string> {
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}
</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" : ""}
${messageCount} message${messageCount !== 1 ? "s" : ""}
</span>
</div>
</header>
@@ -108,6 +122,9 @@ ${hljsCss}
${messageHtmlParts.join("\n ")}
</main>
</div>
<script>
${getExportJs()}
</script>
</body>
</html>`;
}
@@ -123,9 +140,11 @@ function renderRedactedDivider(): string {
</div>`;
}
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 {
? `<pre class="hljs"><code>${escapeHtml(msg.toolInput)}</code></pre>`
: "";
bodyHtml = `<div class="tool-name" style="color: ${style.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
} else if (msg.category === "tool_result") {
bodyHtml = isDiffContent(msg.content)
? renderDiffHtml(msg.content)
: `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
} 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);
@@ -147,16 +167,119 @@ function renderMessage(msg: ParsedMessage): string {
? `<span class="header-sep">&middot;</span><span class="message-time">${escapeHtml(timestamp)}</span>`
: "";
return `<div class="message" style="border-color: ${style.border}">
// 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 = `<span class="header-sep">&middot;</span><span class="collapsed-preview">${escapeHtml(previewText)}</span>`;
}
// Chevron toggle button for collapsible messages
const chevronHtml = isCollapsible
? `<button class="collapsible-toggle" aria-label="Toggle"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg></button>`
: "";
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 `<div class="message"${dataAttrs} style="border-color: ${style.border}">
<div class="message-header">
<span class="message-dot" style="background: ${style.dot}"></span>
${chevronHtml}<span class="message-dot" style="background: ${style.dot}"></span>
<span class="message-label">${escapeHtml(label)}</span>
${timestampHtml}
${previewHtml}
</div>
<div class="message-body prose-message">${bodyHtml}</div>
${progressHtml}
</div>`;
}
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 `<span class="diff-hunk">${escaped}</span>`;
}
if (line.startsWith("+++") || line.startsWith("---")) {
return `<span class="diff-meta">${escaped}</span>`;
}
if (line.startsWith("diff --")) {
return `<span class="diff-header">${escaped}</span>`;
}
if (line.startsWith("+")) {
return `<span class="diff-add">${escaped}</span>`;
}
if (line.startsWith("-")) {
return `<span class="diff-del">${escaped}</span>`;
}
return escaped;
});
return `<pre class="hljs diff-view"><code>${htmlLines.join("\n")}</code></pre>`;
}
function renderProgressBadge(events: ParsedMessage[]): string {
// Count by subtype
const counts: Partial<Record<ProgressSubtype, number>> = {};
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 `<span class="progress-pill" style="color:${colors.text};background:${colors.bg}">${escapeHtml(sub)}: ${count}</span>`;
})
.join("");
// Drawer rows
const rows = events
.map((e) => {
const time = e.timestamp ? formatTimestamp(e.timestamp) : "--:--:--";
const sub = e.progressSubtype || "hook";
return `<div class="progress-row"><span class="progress-time">${escapeHtml(time)}</span><span class="progress-subtype">${escapeHtml(sub)}</span><span class="progress-content">${escapeHtml(e.content)}</span></div>`;
})
.join("\n ");
return `<div class="progress-badge">
<button class="progress-toggle">${pills}</button>
<div class="progress-drawer" style="display:none">
${rows}
</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
@@ -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; }
}
`;
}