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