Message.tsx

  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;