shelley: ui: replace right-click menu with tap-to-show action bar

Philip Zeyliger and Shelley created

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@exe.dev>

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@exe.dev>

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 <shelley@exe.dev>

Change summary

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(-)

Detailed changes

ui/src/components/ContextMenu.tsx 🔗

@@ -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 (
-    <div
-      data-context-menu
-      style={{
-        position: "fixed",
-        left: `${clampedX}px`,
-        top: `${clampedY}px`,
-        backgroundColor: "#ffffff",
-        border: "1px solid #e5e7eb",
-        borderRadius: "6px",
-        boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
-        zIndex: 10000,
-        minWidth: `${menuWidth}px`,
-        padding: "4px 0",
-      }}
-    >
-      {items.map((item, index) => (
-        <button
-          key={index}
-          onClick={() => {
-            item.onClick();
-            onClose();
-          }}
-          style={{
-            display: "flex",
-            alignItems: "center",
-            gap: "12px",
-            width: "100%",
-            padding: "10px 16px",
-            border: "none",
-            backgroundColor: "transparent",
-            cursor: "pointer",
-            fontSize: "14px",
-            color: "#1f2937",
-            textAlign: "left",
-            transition: "background-color 0.1s",
-          }}
-          onMouseEnter={(e) => {
-            e.currentTarget.style.backgroundColor = "#f3f4f6";
-          }}
-          onMouseLeave={(e) => {
-            e.currentTarget.style.backgroundColor = "transparent";
-          }}
-        >
-          <span style={{ display: "flex", alignItems: "center", width: "20px", height: "20px" }}>
-            {item.icon}
-          </span>
-          <span>{item.label}</span>
-        </button>
-      ))}
-    </div>
-  );
-}
-
-export default ContextMenu;

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<ReturnType<typeof setTimeout> | null>(null);
   const messageRef = useRef<HTMLDivElement | null>(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 = () => (
-    <svg
-      width="20"
-      height="20"
-      viewBox="0 0 24 24"
-      fill="none"
-      stroke="currentColor"
-      strokeWidth="2"
-      strokeLinecap="round"
-      strokeLinejoin="round"
-    >
-      <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
-      <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
-    </svg>
-  );
+  // Close action bar when clicking outside
+  useEffect(() => {
+    if (!showActionBar) return;
 
-  // Info icon SVG
-  const InfoIcon = () => (
-    <svg
-      width="20"
-      height="20"
-      viewBox="0 0 24 24"
-      fill="none"
-      stroke="currentColor"
-      strokeWidth="2"
-      strokeLinecap="round"
-      strokeLinejoin="round"
-    >
-      <circle cx="12" cy="12" r="10"></circle>
-      <line x1="12" y1="16" x2="12" y2="12"></line>
-      <line x1="12" y1="8" x2="12.01" y2="8"></line>
-    </svg>
-  );
+    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: <CopyIcon />,
-      onClick: handleCopy,
-    });
-  }
-
-  // Show usage detail only for agent messages with usage data
-  if (message.type === "agent" && usage) {
-    contextMenuItems.push({
-      label: "Usage Detail",
-      icon: <InfoIcon />,
-      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<string, { name: string; input: unknown }> = {};
@@ -788,27 +765,24 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
         <div
           ref={messageRef}
           className={getMessageClasses()}
-          onContextMenu={handleContextMenu}
-          onTouchStart={handleTouchStart}
-          onTouchEnd={handleTouchEnd}
-          onTouchMove={handleTouchMove}
+          onClick={handleMessageClick}
+          onMouseEnter={handleMouseEnter}
+          onMouseLeave={handleMouseLeave}
           style={{ position: "relative" }}
           data-testid="message"
           role="alert"
           aria-label="Error message"
         >
+          {actionBarVisible && (hasCopyAction || hasUsageAction) && (
+            <MessageActionBar
+              onCopy={hasCopyAction ? handleCopy : undefined}
+              onShowUsage={hasUsageAction ? handleShowUsage : undefined}
+            />
+          )}
           <div className="message-content" data-testid="message-content">
             <div className="whitespace-pre-wrap break-words">{errorText}</div>
           </div>
         </div>
-        {contextMenu && contextMenuItems.length > 0 && (
-          <ContextMenu
-            x={contextMenu.x}
-            y={contextMenu.y}
-            onClose={() => setContextMenu(null)}
-            items={contextMenuItems}
-          />
-        )}
         {showUsageModal && usage && (
           <UsageDetailModal
             usage={usage}
@@ -827,28 +801,25 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
         <div
           ref={messageRef}
           className={getMessageClasses()}
-          onContextMenu={handleContextMenu}
-          onTouchStart={handleTouchStart}
-          onTouchEnd={handleTouchEnd}
-          onTouchMove={handleTouchMove}
+          onClick={handleMessageClick}
+          onMouseEnter={handleMouseEnter}
+          onMouseLeave={handleMouseLeave}
           style={{ position: "relative" }}
           data-testid="message"
           role="article"
         >
+          {actionBarVisible && (hasCopyAction || hasUsageAction) && (
+            <MessageActionBar
+              onCopy={hasCopyAction ? handleCopy : undefined}
+              onShowUsage={hasUsageAction ? handleShowUsage : undefined}
+            />
+          )}
           <div className="message-content" data-testid="message-content">
             {displayData.map((toolDisplay, index) => (
               <div key={index}>{renderDisplayData(toolDisplay, toolDisplay.tool_name)}</div>
             ))}
           </div>
         </div>
-        {contextMenu && contextMenuItems.length > 0 && (
-          <ContextMenu
-            x={contextMenu.x}
-            y={contextMenu.y}
-            onClose={() => setContextMenu(null)}
-            items={contextMenuItems}
-          />
-        )}
         {showUsageModal && usage && (
           <UsageDetailModal
             usage={usage}
@@ -899,14 +870,19 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
       <div
         ref={messageRef}
         className={getMessageClasses()}
-        onContextMenu={handleContextMenu}
-        onTouchStart={handleTouchStart}
-        onTouchEnd={handleTouchEnd}
-        onTouchMove={handleTouchMove}
+        onClick={handleMessageClick}
+        onMouseEnter={handleMouseEnter}
+        onMouseLeave={handleMouseLeave}
         style={{ position: "relative" }}
         data-testid="message"
         role="article"
       >
+        {actionBarVisible && (hasCopyAction || hasUsageAction) && (
+          <MessageActionBar
+            onCopy={hasCopyAction ? handleCopy : undefined}
+            onShowUsage={hasUsageAction ? handleShowUsage : undefined}
+          />
+        )}
         {/* Message content */}
         <div className="message-content" data-testid="message-content">
           {contentToRender.map((content, index) => (
@@ -914,14 +890,6 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
           ))}
         </div>
       </div>
-      {contextMenu && contextMenuItems.length > 0 && (
-        <ContextMenu
-          x={contextMenu.x}
-          y={contextMenu.y}
-          onClose={() => setContextMenu(null)}
-          items={contextMenuItems}
-        />
-      )}
       {showUsageModal && usage && (
         <UsageDetailModal
           usage={usage}

ui/src/components/MessageActionBar.tsx 🔗

@@ -0,0 +1,147 @@
+import React, { useState } from "react";
+
+interface MessageActionBarProps {
+  onCopy?: () => 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 (
+    <div
+      className="message-action-bar"
+      data-action-bar
+      style={{
+        position: "absolute",
+        top: "-28px",
+        right: "8px",
+        display: "flex",
+        gap: "2px",
+        background: "var(--bg-base)",
+        border: "1px solid var(--border)",
+        borderRadius: "4px",
+        padding: "2px",
+
+        zIndex: 10,
+      }}
+    >
+      {onCopy && (
+        <button
+          onClick={handleCopy}
+          title="Copy"
+          style={{
+            display: "flex",
+            alignItems: "center",
+            justifyContent: "center",
+            width: "24px",
+            height: "24px",
+            borderRadius: "4px",
+            border: "none",
+            background: copyFeedback ? "var(--success-bg)" : "transparent",
+            cursor: "pointer",
+            color: copyFeedback ? "var(--success-text)" : "var(--text-secondary)",
+            transition: "background-color 0.15s, color 0.15s",
+          }}
+          onMouseEnter={(e) => {
+            if (!copyFeedback) {
+              e.currentTarget.style.backgroundColor = "var(--bg-tertiary)";
+            }
+          }}
+          onMouseLeave={(e) => {
+            if (!copyFeedback) {
+              e.currentTarget.style.backgroundColor = "transparent";
+            }
+          }}
+        >
+          {copyFeedback ? (
+            <svg
+              width="16"
+              height="16"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="2"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            >
+              <polyline points="20 6 9 17 4 12"></polyline>
+            </svg>
+          ) : (
+            <svg
+              width="16"
+              height="16"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="2"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            >
+              <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
+              <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
+            </svg>
+          )}
+        </button>
+      )}
+      {onShowUsage && (
+        <button
+          onClick={handleShowUsage}
+          title="Usage Details"
+          style={{
+            display: "flex",
+            alignItems: "center",
+            justifyContent: "center",
+            width: "24px",
+            height: "24px",
+            borderRadius: "4px",
+            border: "none",
+            background: "transparent",
+            cursor: "pointer",
+            color: "var(--text-secondary)",
+            transition: "background-color 0.15s",
+          }}
+          onMouseEnter={(e) => {
+            e.currentTarget.style.backgroundColor = "var(--bg-tertiary)";
+          }}
+          onMouseLeave={(e) => {
+            e.currentTarget.style.backgroundColor = "transparent";
+          }}
+        >
+          <svg
+            width="16"
+            height="16"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            strokeWidth="2"
+            strokeLinecap="round"
+            strokeLinejoin="round"
+          >
+            <circle cx="12" cy="12" r="10"></circle>
+            <line x1="12" y1="16" x2="12" y2="12"></line>
+            <line x1="12" y1="8" x2="12.01" y2="8"></line>
+          </svg>
+        </button>
+      )}
+    </div>
+  );
+}
+
+export default MessageActionBar;