import React, { useMemo, useState } from "react"; import type { ParsedMessage } from "../lib/types"; import { CATEGORY_LABELS } from "../lib/types"; import { CATEGORY_COLORS } from "../lib/constants"; import { renderMarkdown, highlightSearchText } from "../lib/markdown"; import { redactMessage } from "../../shared/sensitive-redactor"; import { escapeHtml } from "../../shared/escape-html"; interface Props { message: ParsedMessage; searchQuery: string; dimmed: boolean; selectedForRedaction: boolean; onToggleRedactionSelection: () => void; autoRedactEnabled: boolean; } /** * MessageBubble renders session messages using innerHTML. * SECURITY: This is safe because content comes only from local JSONL session files * owned by the user, processed through the `marked` markdown renderer. * This is a local-only developer tool, not exposed to untrusted input. * The session files are read from the user's own filesystem (~/.claude/projects/). */ export function MessageBubble({ message, searchQuery, dimmed, selectedForRedaction, onToggleRedactionSelection, autoRedactEnabled, }: Props) { const colors = CATEGORY_COLORS[message.category]; const label = CATEGORY_LABELS[message.category]; // Collapsible state for thinking blocks and tool calls/results const isCollapsible = message.category === "thinking" || message.category === "tool_call" || message.category === "tool_result"; const [collapsed, setCollapsed] = useState(isCollapsible); const [linkCopied, setLinkCopied] = useState(false); const renderedHtml = useMemo(() => { // Skip expensive rendering when content is collapsed and not visible if (collapsed) return ""; const msg = autoRedactEnabled ? redactMessage(message) : message; if (msg.category === "tool_call") { const inputHtml = msg.toolInput ? `
${escapeHtml(msg.toolInput)}
` : ""; const html = `
${escapeHtml(msg.toolName || "Unknown Tool")}
${inputHtml}`; return searchQuery ? highlightSearchText(html, searchQuery) : html; } // Structured data categories: render as preformatted text, not markdown. // Avoids expensive marked.parse() on large JSON/log blobs. if (msg.category === "hook_progress" || msg.category === "file_snapshot") { const html = `
${escapeHtml(msg.content)}
`; return searchQuery ? highlightSearchText(html, searchQuery) : html; } if (msg.category === "tool_result") { const html = isDiffContent(msg.content) ? renderDiffHtml(msg.content) : `
${escapeHtml(msg.content)}
`; return searchQuery ? highlightSearchText(html, searchQuery) : html; } const html = renderMarkdown(msg.content); return searchQuery ? highlightSearchText(html, searchQuery) : html; }, [message, searchQuery, autoRedactEnabled, colors.text, collapsed]); // Generate preview for collapsed thinking blocks const collapsedPreview = useMemo(() => { if (!isCollapsible || !collapsed) return null; if (message.category === "thinking") { const lines = message.content.split("\n").filter(l => l.trim()); const preview = lines.slice(0, 2).join("\n"); const totalLines = lines.length; return { preview, totalLines }; } if (message.category === "tool_call") { return { preview: message.toolName || "Unknown Tool", totalLines: 0 }; } if (message.category === "tool_result") { const lines = message.content.split("\n"); const preview = lines[0]?.substring(0, 120) || "Result"; return { preview, totalLines: lines.length }; } return null; }, [isCollapsible, collapsed, message]); const timestamp = message.timestamp ? formatTimestamp(message.timestamp) : null; return (
{/* Category accent strip */}
{/* Header bar */}
{isCollapsible && ( )} {label} {timestamp && ( <> · {timestamp} )} {isCollapsible && collapsed && collapsedPreview && ( <> · {message.category === "thinking" && collapsedPreview.totalLines > 2 ? `${collapsedPreview.totalLines} lines` : collapsedPreview.preview} )} {/* Right-aligned header actions */}
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */} {!collapsed && (
)} {collapsed && message.category === "thinking" && collapsedPreview && (
{collapsedPreview.preview}
)}
); } 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++; } } // Require at least one hunk header AND some +/- lines to avoid false positives // on YAML lists, markdown lists, or other content with leading dashes 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 formatTimestamp(ts: string): string { const d = new Date(ts); if (isNaN(d.getTime())) return ""; return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", }); }