claw-web-v2 / client /src /components /MessageBubble.tsx
Claw Web
fix: smarter error classification + interleaved text/tool rendering
1f28fab
import { cn } from "@/lib/utils";
import { User, Bot, Copy, Check, RotateCcw, Pencil, Minimize2 } from "lucide-react";
import { useState } from "react";
import { ToolCallCard } from "./ToolCallCard";
import { ThinkingBlock, extractThinkingBlocks } from "./ThinkingBlock";
import { Streamdown } from "streamdown";
import type { ChatMessage, ContentSegment } from "@/hooks/useChat";
export function MessageBubble({
message,
onRetry,
onEdit,
}: {
message: ChatMessage;
onRetry?: () => void;
onEdit?: (content: string) => void;
}) {
const [copied, setCopied] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const isUser = message.role === "user";
const isSystem = message.role === "system";
const copyContent = () => {
if (message.content) {
navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleEdit = () => {
setIsEditing(true);
setEditValue(message.content);
};
const submitEdit = () => {
if (onEdit && editValue.trim()) {
onEdit(editValue.trim());
}
setIsEditing(false);
};
if (isSystem) {
// Detect compact report (matches original format_compact_report)
const isCompactReport = message.content.startsWith("Compact\n");
if (isCompactReport) {
return (
<div className="flex justify-center my-3">
<div className="flex items-center gap-2 text-xs text-amber-400/80 bg-amber-500/10 border border-amber-500/20 px-4 py-2 rounded-lg max-w-xl font-mono">
<Minimize2 className="size-3.5 shrink-0" />
<pre className="whitespace-pre text-[11px] leading-relaxed">{message.content}</pre>
</div>
</div>
);
}
return (
<div className="flex justify-center my-2">
<div className="text-xs text-muted-foreground bg-accent/30 px-3 py-1.5 rounded-full max-w-xl">
<Streamdown>{message.content}</Streamdown>
</div>
</div>
);
}
// ─── Segmented rendering for assistant messages ───────────────────
// If we have contentSegments, render them in order (interleaved text + tools).
// Otherwise fall back to legacy rendering (all tools then all text).
const hasSegments = !isUser && message.contentSegments && message.contentSegments.length > 0;
const toolCalls = message.toolCalls || [];
const renderSegmentedContent = () => {
if (!message.contentSegments) return null;
return message.contentSegments.map((segment: ContentSegment, i: number) => {
if (segment.type === "text") {
const text = segment.content?.trim();
if (!text) return null;
return (
<div
key={`seg-text-${i}`}
className="text-sm leading-relaxed text-foreground/90 prose prose-invert prose-sm max-w-none mt-1.5"
>
<Streamdown>{segment.content}</Streamdown>
</div>
);
}
if (segment.type === "tool") {
const tool = toolCalls[segment.toolIndex];
if (!tool) return null;
return (
<div key={`seg-tool-${i}`} className="mt-1.5">
<ToolCallCard tool={tool} />
</div>
);
}
return null;
});
};
const renderLegacyContent = () => {
return (
<>
{/* Tool calls — show BEFORE message text */}
{toolCalls.length > 0 && (
<div className="mt-2 space-y-1">
{toolCalls.map((tool) => (
<ToolCallCard key={tool.id} tool={tool} />
))}
</div>
)}
{/* Message text — after tool calls */}
{isEditing ? (
<div className="inline-block text-left max-w-[85%]">
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm font-mono outline-none focus:border-primary resize-none min-h-[60px]"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitEdit();
}
if (e.key === "Escape") setIsEditing(false);
}}
/>
<div className="flex gap-1.5 mt-1 justify-end">
<button
onClick={() => setIsEditing(false)}
className="text-xs px-2 py-1 rounded bg-muted text-muted-foreground hover:bg-accent"
>
Cancel
</button>
<button
onClick={submitEdit}
className="text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90"
>
Save
</button>
</div>
</div>
) : message.content ? (
<div
className={cn(
"text-sm leading-relaxed",
isUser
? "bg-primary/10 rounded-2xl rounded-tr-sm px-4 py-2.5 inline-block text-left max-w-[85%]"
: "text-foreground/90 prose prose-invert prose-sm max-w-none",
toolCalls.length > 0 ? "mt-2" : ""
)}
>
{isUser ? (
<span className="whitespace-pre-wrap">{message.content}</span>
) : (
<Streamdown>{message.content}</Streamdown>
)}
{message.isStreaming && <span className="streaming-cursor" />}
</div>
) : null}
</>
);
};
return (
<div
className={cn(
"group flex gap-3 py-4 px-4",
isUser ? "flex-row-reverse" : ""
)}
>
{/* Avatar */}
<div
className={cn(
"size-7 rounded-lg shrink-0 flex items-center justify-center mt-0.5",
isUser
? "bg-primary/20 text-primary"
: "bg-accent text-accent-foreground"
)}
>
{isUser ? <User className="size-4" /> : <Bot className="size-4" />}
</div>
{/* Content */}
<div className={cn("flex-1 min-w-0", isUser ? "text-right" : "")}>
<div
className={cn(
"flex items-center gap-2 mb-1",
isUser ? "justify-end" : ""
)}
>
<span className="text-xs font-medium text-muted-foreground">
{isUser ? "You" : "Claw"}
</span>
{message.model && (
<span className="text-[10px] text-muted-foreground/60 font-mono">
{message.model}
</span>
)}
{/* Action buttons */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
{message.content && (
<button
onClick={copyContent}
className="text-muted-foreground hover:text-foreground transition-colors"
title="Copy"
>
{copied ? (
<Check className="size-3" />
) : (
<Copy className="size-3" />
)}
</button>
)}
{isUser && onEdit && (
<button
onClick={handleEdit}
className="text-muted-foreground hover:text-foreground transition-colors"
title="Edit"
>
<Pencil className="size-3" />
</button>
)}
{!isUser && onRetry && (
<button
onClick={onRetry}
className="text-muted-foreground hover:text-foreground transition-colors"
title="Retry"
>
<RotateCcw className="size-3" />
</button>
)}
</div>
</div>
{/* Thinking blocks — show first (model reasoning) */}
{!isUser && message.thinkingBlocks && message.thinkingBlocks.length > 0 && (
<div className="mt-1">
{message.thinkingBlocks.map((block: any, i: number) => (
<ThinkingBlock key={i} thinking={block.thinking} durationMs={block.durationMs} />
))}
</div>
)}
{/* ─── Main content area ─── */}
{hasSegments ? (
// New: Interleaved rendering — text and tools in the order they arrived
<div className="mt-1">
{renderSegmentedContent()}
{message.isStreaming && <span className="streaming-cursor" />}
</div>
) : (
// Legacy: all tools first, then all text
renderLegacyContent()
)}
{/* Editing UI for user messages */}
{isUser && isEditing && (
<div className="inline-block text-left max-w-[85%]">
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm font-mono outline-none focus:border-primary resize-none min-h-[60px]"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitEdit();
}
if (e.key === "Escape") setIsEditing(false);
}}
/>
<div className="flex gap-1.5 mt-1 justify-end">
<button
onClick={() => setIsEditing(false)}
className="text-xs px-2 py-1 rounded bg-muted text-muted-foreground hover:bg-accent"
>
Cancel
</button>
<button
onClick={submitEdit}
className="text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90"
>
Save
</button>
</div>
</div>
)}
{/* Usage info */}
{message.cost !== undefined && message.cost > 0 && (
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground/60">
{message.promptTokens && (
<span>{message.promptTokens.toLocaleString()} in</span>
)}
{message.completionTokens && (
<span>{message.completionTokens.toLocaleString()} out</span>
)}
<span>${message.cost.toFixed(6)}</span>
</div>
)}
</div>
</div>
);
}