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