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 (
{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",
+ });
}
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;