From b3cf6be86bf8c362ad425a3bad133970a6e48056 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Thu, 15 Jan 2026 16:33:43 +0000 Subject: [PATCH] shelley: ui: replace right-click menu with tap-to-show action bar Fixes: https://github.com/boldsoftware/shelley/issues/19 Prompt: Get rid of the right click menu for copy and paste. Instead if you tap on a message, a copy icon should show up above the message that can be clicked. Likewise an info icon for usage details. This should work for tool results as well. Replace the context menu (right-click/long-press) with a tap-to-show action bar for copy and usage info actions. When a message is tapped, a small action bar appears in the top-right corner of the message with: - Copy icon: copies message text (including tool results) to clipboard - Info icon: shows usage details modal (only for agent messages) The action bar: - Appears on tap/click anywhere on the message - Closes when clicking outside the message - Works for all message types including tool results - Shows feedback (checkmark) after successful copy This provides a more touch-friendly UX compared to the context menu, and the icons are always accessible without needing to know about right-click or long-press gestures. Co-authored-by: Shelley shelley: ui: show action bar on hover, position above text Prompt: Not bad. I also want it to show up on mouseover, and I want the copy button above the text, so that it doesn't obscure the text. - Add hover (mouseenter/mouseleave) handlers to show action bar on hover - Reposition action bar above the message (top: -28px) so it doesn't obscure the message text - Action bar now shows on either hover OR tap (for mobile compatibility) Co-authored-by: Shelley shelley: ui: remove drop shadow from action bar Prompt: Not bad. It's got a drop shadow of some sort that looks inconsistent Remove the boxShadow from the message action bar for a cleaner look. Co-authored-by: Shelley --- ui/src/components/ContextMenu.tsx | 105 ------------- ui/src/components/Message.tsx | 208 +++++++++++-------------- ui/src/components/MessageActionBar.tsx | 147 +++++++++++++++++ 3 files changed, 235 insertions(+), 225 deletions(-) delete mode 100644 ui/src/components/ContextMenu.tsx create mode 100644 ui/src/components/MessageActionBar.tsx 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;