diff --git a/ui/src/components/ContextMenu.tsx b/ui/src/components/ContextMenu.tsx deleted file mode 100644 index 7216408379739aa3b63fd5d9a52e5894ff74e326..0000000000000000000000000000000000000000 --- a/ui/src/components/ContextMenu.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from "react"; - -interface ContextMenuProps { - x: number; - y: number; - onClose: () => void; - items: ContextMenuItem[]; -} - -interface ContextMenuItem { - label: string; - icon: React.ReactNode; - onClick: () => void; -} - -function ContextMenu({ x, y, onClose, items }: ContextMenuProps) { - // Clamp menu within viewport - const vw = typeof window !== "undefined" ? window.innerWidth : 0; - const vh = typeof window !== "undefined" ? window.innerHeight : 0; - const menuWidth = 200; - const menuHeight = items.length * 44 + 8; // approximate height - - const clampedX = Math.max(8, Math.min(x, vw - menuWidth - 8)); - const clampedY = Math.max(8, Math.min(y, vh - menuHeight - 8)); - - // Close on any click outside (handled by parent) - React.useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - const target = e.target as HTMLElement; - if (!target.closest("[data-context-menu]")) { - onClose(); - } - }; - - // Use capture phase to ensure we catch the click before other handlers - document.addEventListener("mousedown", handleClickOutside, true); - return () => document.removeEventListener("mousedown", handleClickOutside, true); - }, [onClose]); - - // Close on escape key - React.useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [onClose]); - - return ( -
- {items.map((item, index) => ( - - ))} -
- ); -} - -export default ContextMenu; diff --git a/ui/src/components/Message.tsx b/ui/src/components/Message.tsx index 7dd8ddfcab4aeb9860cb4a3a5ca444da6b18ff3b..d328d8f74ec8b3284c6d108d3ed5362c8c5d701e 100644 --- a/ui/src/components/Message.tsx +++ b/ui/src/components/Message.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from "react"; +import React, { useState, useRef, useEffect } from "react"; import { linkifyText } from "../utils/linkify"; import { Message as MessageType, LLMMessage, LLMContent, Usage } from "../types"; import BashTool from "./BashTool"; @@ -13,8 +13,8 @@ import ReadImageTool from "./ReadImageTool"; import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool"; import ChangeDirTool from "./ChangeDirTool"; import BrowserResizeTool from "./BrowserResizeTool"; -import ContextMenu from "./ContextMenu"; import UsageDetailModal from "./UsageDetailModal"; +import MessageActionBar from "./MessageActionBar"; // Display data types from different tools interface ToolDisplay { @@ -110,12 +110,15 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp ); } - // Context menu state - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + // Action bar state (show on hover or tap) + const [showActionBar, setShowActionBar] = useState(false); + const [isHovered, setIsHovered] = useState(false); const [showUsageModal, setShowUsageModal] = useState(false); - const [longPressTimer, setLongPressTimer] = useState | null>(null); const messageRef = useRef(null); + // Show action bar on hover or when explicitly tapped + const actionBarVisible = showActionBar || isHovered; + // Parse usage data if available (only for agent messages) let usage: Usage | null = null; if (message.type === "agent" && message.usage_data) { @@ -163,7 +166,7 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp } }; - // Get text content from message for copying + // Get text content from message for copying (includes tool results) const getMessageText = (): string => { if (!llmMessage?.Content) return ""; @@ -172,74 +175,58 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp const contentType = getContentType(content.Type); if (contentType === "text" && content.Text) { textParts.push(content.Text); + } else if (contentType === "tool_result" && content.ToolResult) { + // Extract text from tool result content + content.ToolResult.forEach((result) => { + if (result.Text) { + textParts.push(result.Text); + } + }); } }); return textParts.join("\n"); }; - // Handle right-click (desktop) - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }; - - // Handle long-press (mobile) - const handleTouchStart = (e: React.TouchEvent) => { - const touch = e.touches[0]; - const timer = setTimeout(() => { - setContextMenu({ x: touch.clientX, y: touch.clientY }); - }, 500); // 500ms long press - setLongPressTimer(timer); - }; - - const handleTouchEnd = () => { - if (longPressTimer) { - clearTimeout(longPressTimer); - setLongPressTimer(null); + // Handle tap on message to toggle action bar (for mobile) + const handleMessageClick = (e: React.MouseEvent) => { + // Don't toggle if clicking on a link, button, or interactive element + const target = e.target as HTMLElement; + if ( + target.closest("a") || + target.closest("button") || + target.closest("[data-action-bar]") || + target.closest(".bash-tool-header") || + target.closest(".patch-tool-header") || + target.closest(".generic-tool-header") || + target.closest(".think-tool-header") || + target.closest(".keyword-search-tool-header") || + target.closest(".change-dir-tool-header") || + target.closest(".browser-tool-header") || + target.closest(".screenshot-tool-header") + ) { + return; } + setShowActionBar((prev) => !prev); }; - const handleTouchMove = () => { - if (longPressTimer) { - clearTimeout(longPressTimer); - setLongPressTimer(null); - } - }; + // Handle mouse enter/leave for hover + const handleMouseEnter = () => setIsHovered(true); + const handleMouseLeave = () => setIsHovered(false); - // Copy icon SVG - const CopyIcon = () => ( - - - - - ); + // Close action bar when clicking outside + useEffect(() => { + if (!showActionBar) return; - // Info icon SVG - const InfoIcon = () => ( - - - - - - ); + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (!messageRef.current?.contains(target)) { + setShowActionBar(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showActionBar]); // Handle copy action const handleCopy = () => { @@ -249,6 +236,13 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp console.error("Failed to copy text:", err); }); } + setShowActionBar(false); + }; + + // Handle usage detail action + const handleShowUsage = () => { + setShowUsageModal(true); + setShowActionBar(false); }; let displayData: ToolDisplay[] | null = null; @@ -278,27 +272,10 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp const isTool = message.type === "tool" || hasToolContent(llmMessage); const isError = message.type === "error"; - // Build context menu items after llmMessage is available - const contextMenuItems = []; - - // Always show copy for messages with text content + // Determine which actions to show in action bar const messageText = getMessageText(); - if (messageText) { - contextMenuItems.push({ - label: "Copy", - icon: , - onClick: handleCopy, - }); - } - - // Show usage detail only for agent messages with usage data - if (message.type === "agent" && usage) { - contextMenuItems.push({ - label: "Usage Detail", - icon: , - onClick: () => setShowUsageModal(true), - }); - } + const hasCopyAction = !!messageText; + const hasUsageAction = message.type === "agent" && !!usage; // Build a map of tool use IDs to their inputs for linking tool_result back to tool_use const toolUseMap: Record = {}; @@ -788,27 +765,24 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
+ {actionBarVisible && (hasCopyAction || hasUsageAction) && ( + + )}
{errorText}
- {contextMenu && contextMenuItems.length > 0 && ( - setContextMenu(null)} - items={contextMenuItems} - /> - )} {showUsageModal && usage && ( + {actionBarVisible && (hasCopyAction || hasUsageAction) && ( + + )}
{displayData.map((toolDisplay, index) => (
{renderDisplayData(toolDisplay, toolDisplay.tool_name)}
))}
- {contextMenu && contextMenuItems.length > 0 && ( - setContextMenu(null)} - items={contextMenuItems} - /> - )} {showUsageModal && usage && ( + {actionBarVisible && (hasCopyAction || hasUsageAction) && ( + + )} {/* Message content */}
{contentToRender.map((content, index) => ( @@ -914,14 +890,6 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp ))}
- {contextMenu && contextMenuItems.length > 0 && ( - setContextMenu(null)} - items={contextMenuItems} - /> - )} {showUsageModal && usage && ( void; + onShowUsage?: () => void; +} + +function MessageActionBar({ onCopy, onShowUsage }: MessageActionBarProps) { + const [copyFeedback, setCopyFeedback] = useState(false); + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onCopy) { + onCopy(); + setCopyFeedback(true); + setTimeout(() => setCopyFeedback(false), 1500); + } + }; + + const handleShowUsage = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onShowUsage) { + onShowUsage(); + } + }; + + return ( +
+ {onCopy && ( + + )} + {onShowUsage && ( + + )} +
+ ); +} + +export default MessageActionBar;