1import React, { useState, useRef, useEffect } from "react";
2import { linkifyText } from "../utils/linkify";
3import { Message as MessageType, LLMMessage, LLMContent, Usage } from "../types";
4import BashTool from "./BashTool";
5import PatchTool from "./PatchTool";
6import ScreenshotTool from "./ScreenshotTool";
7import GenericTool from "./GenericTool";
8import ThinkTool from "./ThinkTool";
9import KeywordSearchTool from "./KeywordSearchTool";
10import BrowserNavigateTool from "./BrowserNavigateTool";
11import BrowserEvalTool from "./BrowserEvalTool";
12import ReadImageTool from "./ReadImageTool";
13import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
14import ChangeDirTool from "./ChangeDirTool";
15import BrowserResizeTool from "./BrowserResizeTool";
16import SubagentTool from "./SubagentTool";
17import OutputIframeTool from "./OutputIframeTool";
18import UsageDetailModal from "./UsageDetailModal";
19import MessageActionBar from "./MessageActionBar";
20
21// Display data types from different tools
22interface ToolDisplay {
23 tool_use_id: string;
24 tool_name?: string;
25 display: unknown;
26}
27
28interface MessageProps {
29 message: MessageType;
30 onOpenDiffViewer?: (commit: string) => void;
31 onCommentTextChange?: (text: string) => void;
32}
33
34function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProps) {
35 // Hide system messages from the UI
36 if (message.type === "system") {
37 return null;
38 }
39
40 // Render gitinfo messages as compact status updates
41 if (message.type === "gitinfo") {
42 // Parse user_data which contains structured git state info
43 let commitHash: string | null = null;
44 let subject: string | null = null;
45 let branch: string | null = null;
46
47 if (message.user_data) {
48 try {
49 const userData =
50 typeof message.user_data === "string" ? JSON.parse(message.user_data) : message.user_data;
51 if (userData.commit) {
52 commitHash = userData.commit;
53 }
54 if (userData.subject) {
55 subject = userData.subject;
56 }
57 if (userData.branch) {
58 branch = userData.branch;
59 }
60 } catch (err) {
61 console.error("Failed to parse gitinfo user_data:", err);
62 }
63 }
64
65 if (!commitHash) {
66 return null;
67 }
68
69 const canShowDiff = commitHash && onOpenDiffViewer;
70
71 const handleDiffClick = () => {
72 if (commitHash && onOpenDiffViewer) {
73 onOpenDiffViewer(commitHash);
74 }
75 };
76
77 return (
78 <div
79 className="message message-gitinfo"
80 data-testid="message-gitinfo"
81 style={{
82 padding: "0.4rem 1rem",
83 fontSize: "0.8rem",
84 color: "var(--text-secondary)",
85 textAlign: "center",
86 fontStyle: "italic",
87 }}
88 >
89 <span>
90 {branch} now at {commitHash}
91 {subject && ` "${subject}"`}
92 {canShowDiff && (
93 <>
94 {" "}
95 <a
96 href="#"
97 onClick={(e) => {
98 e.preventDefault();
99 handleDiffClick();
100 }}
101 style={{
102 color: "var(--link-color, #0066cc)",
103 textDecoration: "underline",
104 }}
105 >
106 diff
107 </a>
108 </>
109 )}
110 </span>
111 </div>
112 );
113 }
114
115 // Action bar state (show on hover or tap)
116 const [showActionBar, setShowActionBar] = useState(false);
117 const [isHovered, setIsHovered] = useState(false);
118 const [showUsageModal, setShowUsageModal] = useState(false);
119 const messageRef = useRef<HTMLDivElement | null>(null);
120
121 // Show action bar on hover or when explicitly tapped
122 const actionBarVisible = showActionBar || isHovered;
123
124 // Parse usage data if available (only for agent messages)
125 let usage: Usage | null = null;
126 if (message.type === "agent" && message.usage_data) {
127 try {
128 usage =
129 typeof message.usage_data === "string"
130 ? JSON.parse(message.usage_data)
131 : message.usage_data;
132 } catch (err) {
133 console.error("Failed to parse usage data:", err);
134 }
135 }
136
137 // Calculate duration if we have timing info
138 let durationMs: number | null = null;
139 if (usage?.start_time && usage?.end_time) {
140 const start = new Date(usage.start_time).getTime();
141 const end = new Date(usage.end_time).getTime();
142 durationMs = end - start;
143 }
144
145 // Convert Go struct Type field (number) to string type
146 // Based on llm/llm.go constants (iota continues across types in same const block):
147 // MessageRoleUser = 0, MessageRoleAssistant = 1,
148 // ContentTypeText = 2, ContentTypeThinking = 3, ContentTypeRedactedThinking = 4,
149 // ContentTypeToolUse = 5, ContentTypeToolResult = 6
150 const getContentType = (type: number): string => {
151 switch (type) {
152 case 0:
153 return "message_role_user"; // Should not occur in Content, but handle gracefully
154 case 1:
155 return "message_role_assistant"; // Should not occur in Content, but handle gracefully
156 case 2:
157 return "text";
158 case 3:
159 return "thinking";
160 case 4:
161 return "redacted_thinking";
162 case 5:
163 return "tool_use";
164 case 6:
165 return "tool_result";
166 default:
167 return "unknown";
168 }
169 };
170
171 // Get text content from message for copying (includes tool results)
172 const getMessageText = (): string => {
173 if (!llmMessage?.Content) return "";
174
175 const textParts: string[] = [];
176 llmMessage.Content.forEach((content) => {
177 const contentType = getContentType(content.Type);
178 if (contentType === "text" && content.Text) {
179 textParts.push(content.Text);
180 } else if (contentType === "tool_result" && content.ToolResult) {
181 // Extract text from tool result content
182 content.ToolResult.forEach((result) => {
183 if (result.Text) {
184 textParts.push(result.Text);
185 }
186 });
187 }
188 });
189 return textParts.join("\n");
190 };
191
192 // Handle tap on message to toggle action bar (for mobile)
193 const handleMessageClick = (e: React.MouseEvent) => {
194 // Don't toggle if clicking on a link, button, or interactive element
195 const target = e.target as HTMLElement;
196 if (
197 target.closest("a") ||
198 target.closest("button") ||
199 target.closest("[data-action-bar]") ||
200 target.closest(".bash-tool-header") ||
201 target.closest(".patch-tool-header") ||
202 target.closest(".generic-tool-header") ||
203 target.closest(".think-tool-header") ||
204 target.closest(".keyword-search-tool-header") ||
205 target.closest(".change-dir-tool-header") ||
206 target.closest(".browser-tool-header") ||
207 target.closest(".screenshot-tool-header")
208 ) {
209 return;
210 }
211 setShowActionBar((prev) => !prev);
212 };
213
214 // Handle mouse enter/leave for hover
215 const handleMouseEnter = () => setIsHovered(true);
216 const handleMouseLeave = () => setIsHovered(false);
217
218 // Close action bar when clicking outside
219 useEffect(() => {
220 if (!showActionBar) return;
221
222 const handleClickOutside = (e: MouseEvent) => {
223 const target = e.target as HTMLElement;
224 if (!messageRef.current?.contains(target)) {
225 setShowActionBar(false);
226 }
227 };
228
229 document.addEventListener("mousedown", handleClickOutside);
230 return () => document.removeEventListener("mousedown", handleClickOutside);
231 }, [showActionBar]);
232
233 // Handle copy action
234 const handleCopy = () => {
235 const text = getMessageText();
236 if (text) {
237 navigator.clipboard.writeText(text).catch((err) => {
238 console.error("Failed to copy text:", err);
239 });
240 }
241 setShowActionBar(false);
242 };
243
244 // Handle usage detail action
245 const handleShowUsage = () => {
246 setShowUsageModal(true);
247 setShowActionBar(false);
248 };
249
250 let displayData: ToolDisplay[] | null = null;
251 if (message.display_data) {
252 try {
253 displayData =
254 typeof message.display_data === "string"
255 ? JSON.parse(message.display_data)
256 : message.display_data;
257 } catch (err) {
258 console.error("Failed to parse display data:", err);
259 }
260 }
261
262 // Parse LLM data if available
263 let llmMessage: LLMMessage | null = null;
264 if (message.llm_data) {
265 try {
266 llmMessage =
267 typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
268 } catch (err) {
269 console.error("Failed to parse LLM data:", err);
270 }
271 }
272
273 const isUser = message.type === "user" && !hasToolResult(llmMessage);
274 const isTool = message.type === "tool" || hasToolContent(llmMessage);
275 const isError = message.type === "error";
276
277 // Determine which actions to show in action bar
278 const messageText = getMessageText();
279 const hasCopyAction = !!messageText;
280 const hasUsageAction = message.type === "agent" && !!usage;
281
282 // Build a map of tool use IDs to their inputs for linking tool_result back to tool_use
283 const toolUseMap: Record<string, { name: string; input: unknown }> = {};
284 if (llmMessage && llmMessage.Content) {
285 llmMessage.Content.forEach((content) => {
286 if (content.Type === 5 && content.ID && content.ToolName) {
287 // tool_use
288 toolUseMap[content.ID] = {
289 name: content.ToolName,
290 input: content.ToolInput,
291 };
292 }
293 });
294 }
295
296 const renderContent = (content: LLMContent) => {
297 const contentType = getContentType(content.Type);
298
299 switch (contentType) {
300 case "message_role_user":
301 case "message_role_assistant":
302 // These shouldn't occur in Content objects, but display as text if they do
303 return (
304 <div
305 style={{
306 background: "#fff7ed",
307 border: "1px solid #fed7aa",
308 borderRadius: "0.25rem",
309 padding: "0.5rem",
310 fontSize: "0.875rem",
311 }}
312 >
313 <div style={{ color: "#9a3412", fontFamily: "monospace" }}>
314 [Unexpected message role content: {contentType}]
315 </div>
316 <div style={{ marginTop: "0.25rem" }}>{content.Text || JSON.stringify(content)}</div>
317 </div>
318 );
319 case "text":
320 return (
321 <div className="whitespace-pre-wrap break-words">{linkifyText(content.Text || "")}</div>
322 );
323 case "tool_use":
324 // IMPORTANT: When adding a new tool component here, also add it to:
325 // 1. The tool_result case below
326 // 2. TOOL_COMPONENTS map in ChatInterface.tsx
327 // See AGENTS.md in this directory.
328
329 // Use specialized component for bash tool
330 if (content.ToolName === "bash") {
331 return <BashTool toolInput={content.ToolInput} isRunning={true} />;
332 }
333 // Use specialized component for patch tool
334 if (content.ToolName === "patch") {
335 return (
336 <PatchTool
337 toolInput={content.ToolInput}
338 isRunning={true}
339 onCommentTextChange={onCommentTextChange}
340 />
341 );
342 }
343 // Use specialized component for screenshot tool
344 if (content.ToolName === "screenshot" || content.ToolName === "browser_take_screenshot") {
345 return <ScreenshotTool toolInput={content.ToolInput} isRunning={true} />;
346 }
347 // Use specialized component for think tool
348 if (content.ToolName === "think") {
349 return <ThinkTool toolInput={content.ToolInput} isRunning={true} />;
350 }
351 // Use specialized component for change_dir tool
352 if (content.ToolName === "change_dir") {
353 return <ChangeDirTool toolInput={content.ToolInput} isRunning={true} />;
354 }
355 // Use specialized component for keyword search tool
356 if (content.ToolName === "keyword_search") {
357 return <KeywordSearchTool toolInput={content.ToolInput} isRunning={true} />;
358 }
359 // Use specialized component for browser navigate tool
360 if (content.ToolName === "browser_navigate") {
361 return <BrowserNavigateTool toolInput={content.ToolInput} isRunning={true} />;
362 }
363 // Use specialized component for browser eval tool
364 if (content.ToolName === "browser_eval") {
365 return <BrowserEvalTool toolInput={content.ToolInput} isRunning={true} />;
366 }
367 // Use specialized component for read image tool
368 if (content.ToolName === "read_image") {
369 return <ReadImageTool toolInput={content.ToolInput} isRunning={true} />;
370 }
371 // Use specialized component for browser resize tool
372 if (content.ToolName === "browser_resize") {
373 return <BrowserResizeTool toolInput={content.ToolInput} isRunning={true} />;
374 }
375 // Use specialized component for subagent tool
376 if (content.ToolName === "subagent") {
377 return <SubagentTool toolInput={content.ToolInput} isRunning={true} />;
378 }
379 // Use specialized component for output iframe tool
380 if (content.ToolName === "output_iframe") {
381 return <OutputIframeTool toolInput={content.ToolInput} isRunning={true} />;
382 }
383 // Use specialized component for browser console logs tools
384 if (
385 content.ToolName === "browser_recent_console_logs" ||
386 content.ToolName === "browser_clear_console_logs"
387 ) {
388 return (
389 <BrowserConsoleLogsTool
390 toolName={content.ToolName}
391 toolInput={content.ToolInput}
392 isRunning={true}
393 />
394 );
395 }
396 // Default rendering for other tools using GenericTool
397 return (
398 <GenericTool
399 toolName={content.ToolName || "Unknown Tool"}
400 toolInput={content.ToolInput}
401 isRunning={true}
402 />
403 );
404 case "tool_result": {
405 const hasError = content.ToolError;
406 const toolUseId = content.ToolUseID;
407 const startTime = content.ToolUseStartTime;
408 const endTime = content.ToolUseEndTime;
409
410 // Calculate execution time if available
411 let executionTime = "";
412 if (startTime && endTime) {
413 const start = new Date(startTime).getTime();
414 const end = new Date(endTime).getTime();
415 const diffMs = end - start;
416 if (diffMs < 1000) {
417 executionTime = `${diffMs}ms`;
418 } else {
419 executionTime = `${(diffMs / 1000).toFixed(1)}s`;
420 }
421 }
422
423 // Get a short summary of the tool result for mobile-friendly display
424 const getToolResultSummary = (results: LLMContent[]) => {
425 if (!results || results.length === 0) return "No output";
426
427 const firstResult = results[0];
428 if (firstResult.Type === 2 && firstResult.Text) {
429 // text content
430 const text = firstResult.Text.trim();
431 if (text.length <= 50) return text;
432 return text.substring(0, 47) + "...";
433 }
434
435 return `${results.length} result${results.length > 1 ? "s" : ""}`;
436 };
437
438 // unused for now
439 void getToolResultSummary;
440
441 // Get tool information from the toolUseMap or fallback to content
442 const toolInfo = toolUseId && toolUseMap && toolUseMap[toolUseId];
443 const toolName =
444 (toolInfo && typeof toolInfo === "object" && toolInfo.name) ||
445 content.ToolName ||
446 "Unknown Tool";
447 const toolInput = toolInfo && typeof toolInfo === "object" ? toolInfo.input : undefined;
448
449 // Use specialized component for bash tool
450 if (toolName === "bash") {
451 return (
452 <BashTool
453 toolInput={toolInput}
454 isRunning={false}
455 toolResult={content.ToolResult}
456 hasError={hasError}
457 executionTime={executionTime}
458 />
459 );
460 }
461
462 // Use specialized component for patch tool
463 if (toolName === "patch") {
464 return (
465 <PatchTool
466 toolInput={toolInput}
467 isRunning={false}
468 toolResult={content.ToolResult}
469 hasError={hasError}
470 executionTime={executionTime}
471 display={content.Display}
472 onCommentTextChange={onCommentTextChange}
473 />
474 );
475 }
476
477 // Use specialized component for screenshot tool
478 if (toolName === "screenshot" || toolName === "browser_take_screenshot") {
479 return (
480 <ScreenshotTool
481 toolInput={toolInput}
482 isRunning={false}
483 toolResult={content.ToolResult}
484 hasError={hasError}
485 executionTime={executionTime}
486 display={content.Display}
487 />
488 );
489 }
490
491 // Use specialized component for think tool
492 if (toolName === "think") {
493 return (
494 <ThinkTool
495 toolInput={toolInput}
496 isRunning={false}
497 toolResult={content.ToolResult}
498 hasError={hasError}
499 executionTime={executionTime}
500 />
501 );
502 }
503
504 // Use specialized component for change_dir tool
505 if (toolName === "change_dir") {
506 return (
507 <ChangeDirTool
508 toolInput={toolInput}
509 isRunning={false}
510 toolResult={content.ToolResult}
511 hasError={hasError}
512 executionTime={executionTime}
513 />
514 );
515 }
516
517 // Use specialized component for keyword search tool
518 if (toolName === "keyword_search") {
519 return (
520 <KeywordSearchTool
521 toolInput={toolInput}
522 isRunning={false}
523 toolResult={content.ToolResult}
524 hasError={hasError}
525 executionTime={executionTime}
526 />
527 );
528 }
529
530 // Use specialized component for browser navigate tool
531 if (toolName === "browser_navigate") {
532 return (
533 <BrowserNavigateTool
534 toolInput={toolInput}
535 isRunning={false}
536 toolResult={content.ToolResult}
537 hasError={hasError}
538 executionTime={executionTime}
539 />
540 );
541 }
542
543 // Use specialized component for browser eval tool
544 if (toolName === "browser_eval") {
545 return (
546 <BrowserEvalTool
547 toolInput={toolInput}
548 isRunning={false}
549 toolResult={content.ToolResult}
550 hasError={hasError}
551 executionTime={executionTime}
552 />
553 );
554 }
555
556 // Use specialized component for read image tool
557 if (toolName === "read_image") {
558 return (
559 <ReadImageTool
560 toolInput={toolInput}
561 isRunning={false}
562 toolResult={content.ToolResult}
563 hasError={hasError}
564 executionTime={executionTime}
565 />
566 );
567 }
568
569 // Use specialized component for browser resize tool
570 if (toolName === "browser_resize") {
571 return (
572 <BrowserResizeTool
573 toolInput={toolInput}
574 isRunning={false}
575 toolResult={content.ToolResult}
576 hasError={hasError}
577 executionTime={executionTime}
578 />
579 );
580 }
581
582 // Use specialized component for subagent tool
583 if (toolName === "subagent") {
584 return (
585 <SubagentTool
586 toolInput={toolInput}
587 isRunning={false}
588 toolResult={content.ToolResult}
589 hasError={hasError}
590 executionTime={executionTime}
591 displayData={content.Display as { slug?: string; conversation_id?: string }}
592 />
593 );
594 }
595
596 // Use specialized component for output iframe tool
597 if (toolName === "output_iframe") {
598 return (
599 <OutputIframeTool
600 toolInput={toolInput}
601 isRunning={false}
602 toolResult={content.ToolResult}
603 hasError={hasError}
604 executionTime={executionTime}
605 display={content.Display}
606 />
607 );
608 }
609
610 // Use specialized component for browser console logs tools
611 if (
612 toolName === "browser_recent_console_logs" ||
613 toolName === "browser_clear_console_logs"
614 ) {
615 return (
616 <BrowserConsoleLogsTool
617 toolName={toolName}
618 toolInput={toolInput}
619 isRunning={false}
620 toolResult={content.ToolResult}
621 hasError={hasError}
622 executionTime={executionTime}
623 />
624 );
625 }
626
627 // Default rendering for other tools using GenericTool
628 return (
629 <GenericTool
630 toolName={toolName}
631 toolInput={toolInput}
632 isRunning={false}
633 toolResult={content.ToolResult}
634 hasError={hasError}
635 executionTime={executionTime}
636 />
637 );
638 }
639 case "redacted_thinking":
640 return <div className="text-tertiary italic text-sm">[Thinking content hidden]</div>;
641 case "thinking":
642 // Hide thinking content by default in main flow, but could be made expandable
643 return null;
644 default: {
645 // For unknown content types, show the type and try to display useful content
646 const displayText = content.Text || content.Data || "";
647 const hasMediaType = content.MediaType;
648 const hasOtherData = Object.keys(content).some(
649 (key) => key !== "Type" && key !== "ID" && content[key as keyof typeof content],
650 );
651
652 return (
653 <div
654 style={{
655 background: "var(--bg-tertiary)",
656 border: "1px solid var(--border)",
657 borderRadius: "0.25rem",
658 padding: "0.75rem",
659 }}
660 >
661 <div
662 className="text-xs text-secondary"
663 style={{ marginBottom: "0.5rem", fontFamily: "monospace" }}
664 >
665 Unknown content type: {contentType} (value: {content.Type})
666 </div>
667
668 {/* Show media content if available */}
669 {hasMediaType && (
670 <div style={{ marginBottom: "0.5rem" }}>
671 <div className="text-xs text-secondary" style={{ marginBottom: "0.25rem" }}>
672 Media Type: {content.MediaType}
673 </div>
674 {content.MediaType?.startsWith("image/") && content.Data && (
675 <img
676 src={`data:${content.MediaType};base64,${content.Data}`}
677 alt="Tool output image"
678 className="rounded border"
679 style={{ maxWidth: "100%", height: "auto", maxHeight: "300px" }}
680 />
681 )}
682 </div>
683 )}
684
685 {/* Show text content if available */}
686 {displayText && (
687 <div className="text-sm whitespace-pre-wrap break-words">{displayText}</div>
688 )}
689
690 {/* Show raw JSON for debugging if no text content */}
691 {!displayText && hasOtherData && (
692 <details className="text-xs">
693 <summary className="text-secondary" style={{ cursor: "pointer" }}>
694 Show raw content
695 </summary>
696 <pre
697 style={{
698 marginTop: "0.5rem",
699 padding: "0.5rem",
700 background: "var(--bg-base)",
701 borderRadius: "0.25rem",
702 fontSize: "0.75rem",
703 overflow: "auto",
704 }}
705 >
706 {JSON.stringify(content, null, 2)}
707 </pre>
708 </details>
709 )}
710 </div>
711 );
712 }
713 }
714 };
715
716 // Render display data for tool-specific rendering
717 const renderDisplayData = (toolDisplay: ToolDisplay, toolName?: string) => {
718 const display = toolDisplay.display;
719
720 // Skip rendering screenshot displays here - they are handled by tool_result rendering
721 if (
722 display &&
723 typeof display === "object" &&
724 "type" in display &&
725 display.type === "screenshot"
726 ) {
727 return null;
728 }
729
730 // Infer tool type from display content if tool name not provided
731 const inferredToolName =
732 toolName ||
733 (typeof display === "string" && display.includes("---") && display.includes("+++")
734 ? "patch"
735 : undefined);
736
737 // Render patch tool displays using PatchTool component
738 if (inferredToolName === "patch" && typeof display === "string") {
739 // Create a mock toolResult with the diff in Text field
740 const mockToolResult: LLMContent[] = [
741 {
742 ID: toolDisplay.tool_use_id,
743 Type: 6, // tool_result
744 Text: display,
745 },
746 ];
747
748 return (
749 <PatchTool
750 toolInput={{}}
751 isRunning={false}
752 toolResult={mockToolResult}
753 hasError={false}
754 onCommentTextChange={onCommentTextChange}
755 />
756 );
757 }
758
759 // For other types of display data, use GenericTool component
760 const mockToolResult: LLMContent[] = [
761 {
762 ID: toolDisplay.tool_use_id,
763 Type: 6, // tool_result
764 Text: JSON.stringify(display, null, 2),
765 },
766 ];
767
768 return (
769 <GenericTool
770 toolName={inferredToolName || toolName || "Tool output"}
771 toolInput={{}}
772 isRunning={false}
773 toolResult={mockToolResult}
774 hasError={false}
775 />
776 );
777 };
778
779 const getMessageClasses = () => {
780 if (isUser) {
781 return "message message-user";
782 }
783 if (isError) {
784 return "message message-error";
785 }
786 if (isTool) {
787 return "message message-tool";
788 }
789 return "message message-agent";
790 };
791
792 // Special rendering for error messages
793 if (isError) {
794 let errorText = "An error occurred";
795 if (llmMessage && llmMessage.Content && llmMessage.Content.length > 0) {
796 const textContent = llmMessage.Content.find((c) => c.Type === 2);
797 if (textContent && textContent.Text) {
798 errorText = textContent.Text;
799 }
800 }
801 return (
802 <>
803 <div
804 ref={messageRef}
805 className={getMessageClasses()}
806 onClick={handleMessageClick}
807 onMouseEnter={handleMouseEnter}
808 onMouseLeave={handleMouseLeave}
809 style={{ position: "relative" }}
810 data-testid="message"
811 role="alert"
812 aria-label="Error message"
813 >
814 {actionBarVisible && (hasCopyAction || hasUsageAction) && (
815 <MessageActionBar
816 onCopy={hasCopyAction ? handleCopy : undefined}
817 onShowUsage={hasUsageAction ? handleShowUsage : undefined}
818 />
819 )}
820 <div className="message-content" data-testid="message-content">
821 <div className="whitespace-pre-wrap break-words">{errorText}</div>
822 </div>
823 </div>
824 {showUsageModal && usage && (
825 <UsageDetailModal
826 usage={usage}
827 durationMs={durationMs}
828 onClose={() => setShowUsageModal(false)}
829 />
830 )}
831 </>
832 );
833 }
834
835 // If we have display_data, use that for rendering (more compact, tool-specific)
836 if (displayData && displayData.length > 0) {
837 return (
838 <>
839 <div
840 ref={messageRef}
841 className={getMessageClasses()}
842 onClick={handleMessageClick}
843 onMouseEnter={handleMouseEnter}
844 onMouseLeave={handleMouseLeave}
845 style={{ position: "relative" }}
846 data-testid="message"
847 role="article"
848 >
849 {actionBarVisible && (hasCopyAction || hasUsageAction) && (
850 <MessageActionBar
851 onCopy={hasCopyAction ? handleCopy : undefined}
852 onShowUsage={hasUsageAction ? handleShowUsage : undefined}
853 />
854 )}
855 <div className="message-content" data-testid="message-content">
856 {displayData.map((toolDisplay, index) => (
857 <div key={index}>{renderDisplayData(toolDisplay, toolDisplay.tool_name)}</div>
858 ))}
859 </div>
860 </div>
861 {showUsageModal && usage && (
862 <UsageDetailModal
863 usage={usage}
864 durationMs={durationMs}
865 onClose={() => setShowUsageModal(false)}
866 />
867 )}
868 </>
869 );
870 }
871
872 // Don't render messages with no meaningful content
873 if (!llmMessage || !llmMessage.Content || llmMessage.Content.length === 0) {
874 return null;
875 }
876
877 // Filter out thinking content, empty content, tool_use, and tool_result
878 const meaningfulContent =
879 llmMessage?.Content?.filter((c) => {
880 const contentType = c.Type;
881 // Filter out thinking (3), redacted thinking (4), tool_use (5), tool_result (6), and empty text content
882 return (
883 contentType !== 3 &&
884 contentType !== 4 &&
885 contentType !== 5 &&
886 contentType !== 6 &&
887 (c.Text?.trim() || contentType !== 2)
888 ); // 3 = thinking, 4 = redacted_thinking, 5 = tool_use, 6 = tool_result, 2 = text
889 }) || [];
890
891 // Don't filter out messages that contain operation status like "[Operation cancelled]"
892 const hasOperationStatus = llmMessage?.Content?.some(
893 (c) => c.Type === 2 && c.Text?.includes("[Operation"),
894 );
895
896 if (meaningfulContent.length === 0 && !hasOperationStatus) {
897 return null;
898 }
899
900 // If we have operation status but no meaningful content, render the status
901 const contentToRender =
902 meaningfulContent.length > 0
903 ? meaningfulContent
904 : llmMessage?.Content?.filter((c) => c.Type === 2 && c.Text?.includes("[Operation")) || [];
905
906 return (
907 <>
908 <div
909 ref={messageRef}
910 className={getMessageClasses()}
911 onClick={handleMessageClick}
912 onMouseEnter={handleMouseEnter}
913 onMouseLeave={handleMouseLeave}
914 style={{ position: "relative" }}
915 data-testid="message"
916 role="article"
917 >
918 {actionBarVisible && (hasCopyAction || hasUsageAction) && (
919 <MessageActionBar
920 onCopy={hasCopyAction ? handleCopy : undefined}
921 onShowUsage={hasUsageAction ? handleShowUsage : undefined}
922 />
923 )}
924 {/* Message content */}
925 <div className="message-content" data-testid="message-content">
926 {contentToRender.map((content, index) => (
927 <div key={index}>{renderContent(content)}</div>
928 ))}
929 </div>
930 </div>
931 {showUsageModal && usage && (
932 <UsageDetailModal
933 usage={usage}
934 durationMs={durationMs}
935 onClose={() => setShowUsageModal(false)}
936 />
937 )}
938 </>
939 );
940}
941
942// Helper functions
943function hasToolResult(llmMessage: LLMMessage | null): boolean {
944 if (!llmMessage) return false;
945 return llmMessage.Content?.some((c) => c.Type === 6) ?? false; // 6 = tool_result
946}
947
948function hasToolContent(llmMessage: LLMMessage | null): boolean {
949 if (!llmMessage) return false;
950 return llmMessage.Content?.some((c) => c.Type === 5 || c.Type === 6) ?? false; // 5 = tool_use, 6 = tool_result
951}
952
953export default Message;