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.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 (
{collapsedPreview.preview}
${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",
});
}