Collapsible blocks:
- Thinking, tool_call, and tool_result messages start collapsed by default
- Chevron toggle in the header expands/collapses content
- Collapsed preview shows line count (thinking), tool name (tool_call),
or first line truncated to 120 chars (tool_result)
- Collapsed blocks skip expensive markdown/highlight rendering entirely
Diff rendering:
- Detect unified diff content via hunk header + add/delete line heuristics
(requires both @@ headers AND +/- lines to avoid false positives on
YAML or markdown lists with leading dashes)
- Render diffs with color-coded line classes: green additions, red
deletions, blue hunk headers, and muted meta/header lines
- Add full diff-view CSS with background tints and block-level spans
Header actions (appear on hover):
- Copy link button: copies a #msg-{uuid} anchor URL to clipboard with
a checkmark confirmation animation
- Redaction toggle button: replaces the previous whole-card onClick
handler with an explicit eye-slash icon button, colored red when
selected — more discoverable and less accident-prone
Style adjustments:
- Raise dimmed message opacity from 0.2/0.45 to 0.35/0.65 for better
readability during search filtering
- Fix btn-secondary hover border using explicit rgba value instead of
Tailwind opacity modifier (which was generating invalid CSS)
- Position copy button below language label when both are present
- Simplify formatTimestamp with isNaN guard instead of try/catch
- Use fixed h-10 header height for consistent vertical alignment
- Brighten user and assistant message backgrounds (bg-surface-overlay)
to visually distinguish them from other message types
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
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
|
|
? `<pre class="hljs mt-2"><code>${escapeHtml(msg.toolInput)}</code></pre>`
|
|
: "";
|
|
const html = `<div class="font-medium ${colors.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${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 = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
|
|
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
|
}
|
|
|
|
if (msg.category === "tool_result") {
|
|
const html = isDiffContent(msg.content)
|
|
? renderDiffHtml(msg.content)
|
|
: `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
|
|
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 (
|
|
<div
|
|
className={`
|
|
group rounded-xl border bg-surface-raised
|
|
transition-all duration-200 relative overflow-hidden
|
|
${colors.border}
|
|
${dimmed ? "message-dimmed" : ""}
|
|
${selectedForRedaction ? "redaction-selected" : ""}
|
|
hover:shadow-card-hover
|
|
shadow-card
|
|
`}
|
|
>
|
|
{/* Category accent strip */}
|
|
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-xl ${colors.dot}`} />
|
|
|
|
{/* Header bar */}
|
|
<div className="flex items-center gap-2 px-4 pl-5 h-10">
|
|
{isCollapsible && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
|
|
className="flex items-center justify-center w-5 h-5 text-foreground-muted hover:text-foreground transition-colors flex-shrink-0"
|
|
aria-label={collapsed ? "Expand" : "Collapse"}
|
|
>
|
|
<svg
|
|
className={`w-3.5 h-3.5 transition-transform duration-150 ${collapsed ? "" : "rotate-90"}`}
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${colors.dot}`} />
|
|
<span className="text-caption font-semibold uppercase tracking-wider text-foreground-muted leading-none">
|
|
{label}
|
|
</span>
|
|
{timestamp && (
|
|
<>
|
|
<span className="text-border leading-none">·</span>
|
|
<span className="text-caption text-foreground-muted tabular-nums leading-none">
|
|
{timestamp}
|
|
</span>
|
|
</>
|
|
)}
|
|
{isCollapsible && collapsed && collapsedPreview && (
|
|
<>
|
|
<span className="text-border leading-none">·</span>
|
|
<span className="text-caption text-foreground-muted truncate max-w-[300px] leading-none">
|
|
{message.category === "thinking" && collapsedPreview.totalLines > 2
|
|
? `${collapsedPreview.totalLines} lines`
|
|
: collapsedPreview.preview}
|
|
</span>
|
|
</>
|
|
)}
|
|
|
|
{/* Right-aligned header actions */}
|
|
<div className="ml-auto flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const url = `${window.location.origin}${window.location.pathname}#msg-${message.uuid}`;
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
setLinkCopied(true);
|
|
setTimeout(() => setLinkCopied(false), 1500);
|
|
}).catch(() => {});
|
|
}}
|
|
className="flex items-center justify-center w-7 h-7 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
|
|
title="Copy link to message"
|
|
>
|
|
{linkCopied ? (
|
|
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m0 0a2.625 2.625 0 115.25 0H12m-3.75 0h3.75" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleRedactionSelection();
|
|
}}
|
|
className={`flex items-center justify-center w-7 h-7 rounded-md transition-colors ${
|
|
selectedForRedaction
|
|
? "text-red-400 bg-red-500/15 hover:bg-red-500/25"
|
|
: "text-foreground-muted hover:text-red-400 hover:bg-red-500/10"
|
|
}`}
|
|
title={selectedForRedaction ? "Deselect for redaction" : "Select for redaction"}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
|
|
{!collapsed && (
|
|
<div
|
|
className="prose-message text-body text-foreground px-4 pl-5 pb-4 pt-1 max-w-none break-words overflow-hidden"
|
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
|
/>
|
|
)}
|
|
{collapsed && message.category === "thinking" && collapsedPreview && (
|
|
<div className="px-4 pl-5 pb-3 pt-1">
|
|
<pre className="text-caption text-foreground-muted whitespace-pre-wrap line-clamp-2 font-mono">{collapsedPreview.preview}</pre>
|
|
</div>
|
|
)}
|
|
</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++;
|
|
}
|
|
}
|
|
// 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 `<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 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",
|
|
});
|
|
}
|