Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |