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) && (
+
+ )}
- {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;