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