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