diff --git a/src/client/components/MessageBubble.tsx b/src/client/components/MessageBubble.tsx index a6ec589..054e21e 100644 --- a/src/client/components/MessageBubble.tsx +++ b/src/client/components/MessageBubble.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import type { ParsedMessage } from "../lib/types"; import { CATEGORY_LABELS } from "../lib/types"; import { CATEGORY_COLORS } from "../lib/constants"; @@ -33,7 +33,15 @@ export function MessageBubble({ 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") { @@ -46,14 +54,41 @@ export function MessageBubble({ // 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 === "tool_result" || msg.category === "file_snapshot") { + 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]); + }, [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) @@ -61,14 +96,13 @@ export function MessageBubble({ return (
@@ -76,39 +110,151 @@ export function MessageBubble({
{/* 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 formatTimestamp(ts: string): string { - try { - const d = new Date(ts); - return d.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - } catch { - return ""; +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", + }); } diff --git a/src/client/styles/main.css b/src/client/styles/main.css index fa721a1..7a04c9f 100644 --- a/src/client/styles/main.css +++ b/src/client/styles/main.css @@ -147,6 +147,12 @@ transition: opacity 150ms, background-color 150ms, color 150ms; } +/* When a language label is present, shift copy button below it */ +.code-block-wrapper .code-lang-label + .code-copy-btn, +.code-block-wrapper:has(.code-lang-label) .code-copy-btn { + top: 2rem; +} + .code-block-wrapper:hover .code-copy-btn { opacity: 1; } @@ -156,6 +162,54 @@ background: var(--color-surface-overlay); } +/* ═══════════════════════════════════════════════ + Diff view — colored line-level highlighting + ═══════════════════════════════════════════════ */ + +.diff-view { + font-size: 0.8125rem; + line-height: 1.6; +} + +.diff-add { + background: rgba(63, 185, 80, 0.12); + color: #3fb950; + display: block; + margin: 0 -1rem; + padding: 0 1rem; +} + +.diff-del { + background: rgba(248, 81, 73, 0.12); + color: #f85149; + display: block; + margin: 0 -1rem; + padding: 0 1rem; +} + +.diff-hunk { + color: #79c0ff; + display: block; + margin: 0 -1rem; + padding: 0.25rem 1rem; + background: rgba(121, 192, 255, 0.06); +} + +.diff-meta { + color: var(--color-foreground-muted); + font-weight: 600; + display: block; +} + +.diff-header { + color: var(--color-foreground-secondary); + font-weight: 600; + display: block; + margin: 0 -1rem; + padding: 0.25rem 1rem; + background: rgba(136, 144, 245, 0.06); +} + /* ═══════════════════════════════════════════════ Search highlight — warm amber glow ═══════════════════════════════════════════════ */ @@ -246,12 +300,12 @@ mark.search-highlight { ═══════════════════════════════════════════════ */ .message-dimmed { - opacity: 0.2; + opacity: 0.35; transition: opacity 200ms ease; } .message-dimmed:hover { - opacity: 0.45; + opacity: 0.65; } /* ═══════════════════════════════════════════════ @@ -473,11 +527,15 @@ mark.search-highlight { .btn-secondary { @apply bg-surface-raised text-foreground-secondary border border-border; - @apply hover:bg-surface-overlay hover:text-foreground hover:border-foreground-muted/30; + @apply hover:bg-surface-overlay hover:text-foreground; @apply disabled:opacity-50 disabled:cursor-not-allowed; @apply focus-visible:ring-accent; } + .btn-secondary:hover { + border-color: rgba(80, 90, 110, 0.3); + } + .btn-ghost { @apply text-foreground-secondary; @apply hover:bg-surface-overlay hover:text-foreground;