From d269a9f3c7fce949eef0119090cca07d779c9535 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 5 Aug 2026 00:59:50 -0700 Subject: [PATCH] [eric] menus: messages, tool rows, app windows, island name answer right-click; host goes shell-global --- .../src/app/components/Layout/AppShell.tsx | 4 ++ .../src/app/pages/AgentChat/AgentChat.tsx | 16 ++++++++ .../tool-bubbles/CompactMcpBubble.tsx | 3 +- .../tool-bubbles/CreateAgentBubble.tsx | 3 +- .../tool-bubbles/DefaultToolBubble.tsx | 3 +- .../tool-bubbles/InvokeAgentBubble.tsx | 3 +- .../AgentChat/tool-bubbles/ToolCallBubble.tsx | 26 ++++++++++++- .../Dashboard/canvas/DashboardCanvas.tsx | 4 -- .../Dashboard/canvas/DashboardHeader.tsx | 37 +++++++++++++++++++ .../Dashboard/cards/CanvasWindowCard.tsx | 22 +++++++++++ .../app/pages/Settings/SettingsAppCard.tsx | 2 + .../app/pages/Workflows/ScheduleCalendar.tsx | 4 +- .../pages/Workflows/app/WorkflowsAppCard.tsx | 6 ++- 13 files changed, 121 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 9309665d..3159f6e6 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -27,6 +27,7 @@ import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice'; import { fetchOutputs } from '@/shared/state/outputsSlice'; import UpdateReadyPill from '@/app/components/Layout/UpdateReadyPill'; import ShareRequestHost from '@/app/components/share/ShareRequestHost'; +import CardContextMenu from '@/app/pages/Dashboard/desktop/CardContextMenu'; import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; import { byPreviewRecency } from '@/shared/previewOrder'; import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; @@ -577,6 +578,9 @@ const AppShell: React.FC = () => { + {/* Shell-global right-click host (portals to body): chat surfaces render on non-dashboard routes too, so the menu can't live inside DashboardCanvas. */} + + ); }; diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 795a2289..2421b0a0 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -76,6 +76,7 @@ import ContextDrawer from './shell/ContextDrawer'; import { ErrorSlime } from '@/app/components/feedback/ErrorSlime'; import { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeCard } from '@/shared/state/dashboardLayoutSlice'; +import { openCardContextMenu, isNativeMenuTarget, type CardMenuRow } from '../Dashboard/desktop/openCardContextMenu'; import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice'; import { setCardSidecar, commitDraft, updateWorkflowCard, controlWorkflowRun } from '@/shared/state/workflowsSlice'; import { shallowEqual } from 'react-redux'; @@ -1834,6 +1835,21 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose return ( { + // ENG-148: the hover action bar's verbs, reachable by right-click on any message; typing surfaces (the edit box) keep the OS menu. + if (isEditing || isNativeMenuTarget(e)) return; + const selection = window.getSelection()?.toString() ?? ''; + const items: CardMenuRow[] = []; + if (selection) items.push({ label: 'Copy selection', onClick: () => { void navigator.clipboard.writeText(selection); } }); + items.push({ label: 'Copy message', onClick: () => { void navigator.clipboard.writeText(rawText); } }); + if (msg.role === 'user') items.push({ label: 'Edit message', onClick: () => setEditingMessageId(msg.id) }); + if (msg.role === 'assistant' && lastAssistantIdsInTurn.has(msg.id)) { + items.push({ kind: 'separator' }); + items.push({ label: 'Regenerate', onClick: () => handleRegenerate(msg) }); + items.push({ label: 'Branch chat', onClick: () => handleBranchChat(msg.id) }); + } + openCardContextMenu(e, { items }); + }} sx={{ '&:hover .msg-actions': { opacity: 1 }, // Cheap virtualization: tells the browser to skip paint + layout for bubbles outside the scroll viewport. `contain-intrinsic-size: auto N` reserves a placeholder height so the scrollbar doesn't jump, and `auto` lets the browser remember the actual height after first render. Works alongside the container's overflow-anchor. Chrome 85+ (Electron covers this). diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx index d2343cd9..9755ea88 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { ToolSelectAttrs } from './ToolCallBubble'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Collapse from '@mui/material/Collapse'; @@ -40,7 +41,7 @@ interface CompactMcpBubbleProps { toggle: () => void; parsedResult: ParsedResult | null; isBrowserAgent: boolean; - selectAttrs: Record; + selectAttrs: ToolSelectAttrs; } export const CompactMcpBubble: React.FC = ({ diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CreateAgentBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CreateAgentBubble.tsx index 1e6fca59..dba9624a 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CreateAgentBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CreateAgentBubble.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { ToolSelectAttrs } from './ToolCallBubble'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; @@ -28,7 +29,7 @@ interface CreateAgentBubbleProps { createAgentSessionId: string | null; handleRevealAgent: (e: React.MouseEvent) => void; bubbleRef: React.RefObject; - selectAttrs: Record; + selectAttrs: ToolSelectAttrs; } export const CreateAgentBubble: React.FC = ({ diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx index 017c5784..29e66e20 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { ToolSelectAttrs } from './ToolCallBubble'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Collapse from '@mui/material/Collapse'; @@ -49,7 +50,7 @@ interface DefaultToolBubbleProps { parsedResult: ParsedResult | null; isBrowserAgent: boolean; accentRgb: string; - selectAttrs: Record; + selectAttrs: ToolSelectAttrs; suppressReveal?: boolean; } diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/InvokeAgentBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/InvokeAgentBubble.tsx index b18112cb..5b81c73d 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/InvokeAgentBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/InvokeAgentBubble.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { ToolSelectAttrs } from './ToolCallBubble'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; @@ -29,7 +30,7 @@ interface InvokeAgentBubbleProps { invokedSessionId: string | null; handleRevealAgent: (e: React.MouseEvent) => void; bubbleRef: React.RefObject; - selectAttrs: Record; + selectAttrs: ToolSelectAttrs; } export const InvokeAgentBubble: React.FC = ({ diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx index 78f0a125..a19ae9cc 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx @@ -25,6 +25,7 @@ import { InvokeAgentBubble } from './InvokeAgentBubble'; import { CreateAgentBubble } from './CreateAgentBubble'; import { CompactMcpBubble } from './CompactMcpBubble'; import { DefaultToolBubble } from './DefaultToolBubble'; +import { openCardContextMenu, isNativeMenuTarget, type CardMenuRow } from '../../Dashboard/desktop/openCardContextMenu'; export { parseMcpToolName, getMcpShortAction } from '@/shared/mcpToolMeta'; export type { McpToolInfo } from '@/shared/mcpToolMeta'; @@ -36,6 +37,14 @@ export interface ToolPair { result: AgentMessage | null; } +/** Spread onto every bubble variant's root: the select-frame data attrs plus the shared right-click menu. */ +export interface ToolSelectAttrs { + 'data-select-type': 'tool-call'; + 'data-select-id': string; + 'data-select-meta': string; + onContextMenu: (e: React.MouseEvent) => void; +} + interface ToolCallBubbleProps { call: AgentMessage; result?: AgentMessage | null; @@ -193,10 +202,25 @@ const ToolCallBubble: React.FC = React.memo( const promptPrefix = getPromptPrefix(toolName); - const selectAttrs = { + // ENG-148: tool rows answer right-click with the shared grammar (copy the command/output, toggle details) instead of falling through to the OS text menu. + const handleContextMenu = useCallback((e: React.MouseEvent) => { + if (isNativeMenuTarget(e)) return; + const selection = window.getSelection()?.toString() ?? ''; + const inputText = formattedInput || inputSummary || JSON.stringify(input, null, 2); + const items: CardMenuRow[] = [{ kind: 'header', label: mcpInfo.isMcp ? mcpInfo.displayName : toolName }]; + if (selection) items.push({ label: 'Copy selection', onClick: () => { void navigator.clipboard.writeText(selection); } }); + items.push({ label: toolName === 'Bash' ? 'Copy command' : 'Copy input', disabled: !inputText, onClick: () => { void navigator.clipboard.writeText(inputText); } }); + items.push({ label: 'Copy output', disabled: !resultRawText, onClick: () => { void navigator.clipboard.writeText(resultRawText); } }); + items.push({ kind: 'separator' }); + items.push({ label: expanded ? 'Collapse details' : 'Expand details', disabled: isStreaming, onClick: toggle }); + openCardContextMenu(e, { items }); + }, [formattedInput, inputSummary, input, mcpInfo, toolName, resultRawText, expanded, isStreaming, toggle]); + + const selectAttrs: ToolSelectAttrs = { 'data-select-type': 'tool-call' as const, 'data-select-id': call.id, 'data-select-meta': JSON.stringify({ tool: toolName, inputSummary }), + onContextMenu: handleContextMenu, }; if (isInvokeAgent) { diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 280beebb..7a2c863d 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -6,7 +6,6 @@ import DashboardHeader from './DashboardHeader'; import TetherLayerHost from './TetherLayerHost'; import DashboardCardLayer from './DashboardCardLayer'; import DashboardOverlays from './DashboardOverlays'; -import CardContextMenu from '../desktop/CardContextMenu'; import { useCanvasContextMenu } from './useCanvasContextMenu'; import DashboardEmptyState from './DashboardEmptyState'; import '../desktop/desktop.css'; @@ -537,9 +536,6 @@ const DashboardCanvas: React.FC = ({ /> - {/* Sibling of everything: the menu used to live inside the help pill's z:10 box (so any card - brought to front painted over it) and inside the fullscreen display:none wrapper. */} - ); diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx index 44e154de..9c9fc821 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx @@ -14,6 +14,9 @@ import ShareButton from '@/app/components/share/ShareButton'; import type { AgentSession } from '@/shared/state/agentsSlice'; import { saveLayout, viewCardKey } from '@/shared/state/dashboardLayoutSlice'; import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice'; +import { useNavigate } from 'react-router-dom'; +import { renameDashboard, duplicateDashboard, deleteDashboard } from '@/shared/state/dashboardsSlice'; +import { openCardContextMenu } from '../desktop/openCardContextMenu'; import type { Output } from '@/shared/state/outputsSlice'; import type { CanvasActions } from '../hooks/interaction/useCanvasControls'; import { friendlyStatusLabel } from '@/shared/statusLabel'; @@ -121,10 +124,44 @@ const DashboardHeader: React.FC = ({ if (hasItems) setExpanded((v) => !v); }, [hasItems]); + // ENG-148: the island name answers right-click with Rename (inline) / Duplicate / Delete, so dashboard management stops hiding behind the Spaces strip's hover hot zone. + const dashboards = useAppSelector((s) => s.dashboards.items); + const navigate = useNavigate(); + const openNameMenu = useCallback((e: React.MouseEvent) => { + if (!dashboardId) return; + openCardContextMenu(e, { + rename: { + value: dashboardName || 'Dashboard', + onCommit: (next: string) => { void dispatch(renameDashboard({ id: dashboardId, name: next, previousName: dashboardName })); }, + }, + items: [ + { + label: 'Duplicate dashboard', + onClick: () => { + void dispatch(duplicateDashboard(dashboardId)).then((result) => { + if (duplicateDashboard.fulfilled.match(result)) navigate(`/dashboard/${(result.payload as { id: string }).id}`); + }); + }, + }, + { kind: 'separator' }, + { + label: 'Delete dashboard', + danger: true, + onClick: () => { + void dispatch(deleteDashboard(dashboardId)); + const next = Object.values(dashboards).find((d) => d.id !== dashboardId); + if (next) navigate(`/dashboard/${next.id}`); + }, + }, + ], + }); + }, [dashboardId, dashboardName, dashboards, dispatch, navigate]); + return ( void; onCommitPosition: (x: number, y: number) => void; onCommitSize: (width: number, height: number) => void; + /** ENG-148: right-click rows the window offers when the host wires them; the menu itself is standard chrome. */ + onMinimize?: () => void; + onClose?: () => void; children: (chrome: CanvasWindowChrome) => React.ReactNode; } @@ -62,6 +67,7 @@ const CanvasWindowCard: React.FC = ({ isSelected = false, isHighlighted = false, multiDragDelta = null, onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront, onCommitPosition, onCommitSize, + onMinimize, onClose, children, }) => { const c = useClaudeTokens(); @@ -179,6 +185,22 @@ const CanvasWindowCard: React.FC = ({ if (target.closest('[data-no-drag]')) return; onCardSelect?.(cardId, cardType, e.shiftKey); }} + onContextMenu={(e: React.MouseEvent) => { + // Same grammar as every other card; typing surfaces keep the OS menu, content with its own menu stopPropagates before this. + if (isNativeMenuTarget(e)) return; + const items: CardMenuRow[] = [ + { kind: 'header', label: selectName }, + { label: tiling.isFullscreen ? 'Exit Full Screen' : 'Full Screen', onClick: () => tiling.applyZone(tiling.isFullscreen ? 'restore' : 'fullscreen') }, + { label: 'Tile to zone', submenu: tileMenuRows(tiling.applyZone, tiling.zone) }, + ]; + if (onMinimize) items.push({ label: minimized ? 'Restore' : 'Minimize', onClick: onMinimize }); + if (onBringToFront) items.push({ label: 'Bring to front', onClick: () => onBringToFront(cardId, cardType) }); + if (onClose) { + items.push({ kind: 'separator' }); + items.push({ label: 'Close', onClick: onClose }); + } + openCardContextMenu(e, { items }); + }} data-keepalive-hidden={minimized ? '1' : undefined} style={{ position: 'absolute', diff --git a/frontend/src/app/pages/Settings/SettingsAppCard.tsx b/frontend/src/app/pages/Settings/SettingsAppCard.tsx index e488227c..784a8570 100644 --- a/frontend/src/app/pages/Settings/SettingsAppCard.tsx +++ b/frontend/src/app/pages/Settings/SettingsAppCard.tsx @@ -81,6 +81,8 @@ const SettingsAppCard: React.FC = ({ onBringToFront={onBringToFront} onCommitPosition={commitPosition} onCommitSize={commitSize} + onMinimize={minimize} + onClose={close} > {({ header, tileZone, onTileZone }) => ( <> diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 98cb39c1..0df0796f 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -368,7 +368,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD onSelectWorkflow?.(e.workflow.id, e.date)} - onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }} + onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }} sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}> {formatTime(e.date.getHours(), e.date.getMinutes())} @@ -445,7 +445,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD key={row.id} data-wl-id={row.id} onClick={() => onSelectWorkflow?.(e.workflow.id, e.date)} - onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }} + onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }} sx={{ display: 'flex', alignItems: 'center', gap: 1.25, px: 2, py: 0.4, diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index 1c37139c..e1829ad6 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { setWorkflowsHubPosition, setWorkflowsHubSize, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice'; +import { setWorkflowsHubPosition, setWorkflowsHubSize, toggleMinimizeCard, closeWorkflowsHub, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice'; import CanvasWindowCard from '@/app/pages/Dashboard/cards/CanvasWindowCard'; import type { CardType } from '@/shared/state/dashboardLayoutSlice'; import { useWC } from './uiKit'; @@ -45,6 +45,8 @@ const WorkflowsAppCard: React.FC = ({ const commitSize = useCallback((width: number, height: number) => { dispatch(setWorkflowsHubSize({ width, height })); }, [dispatch]); + const minimize = useCallback(() => { dispatch(toggleMinimizeCard({ cardId: WORKFLOWS_HUB_ID })); }, [dispatch]); + const close = useCallback(() => { dispatch(closeWorkflowsHub()); }, [dispatch]); return ( = ({ onBringToFront={onBringToFront} onCommitPosition={commitPosition} onCommitSize={commitSize} + onMinimize={minimize} + onClose={close} > {({ header, tileZone, onTileZone }) => (