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
  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;