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