[eric] menus: messages, tool rows, app windows, island name answer right-click; host goes shell-global

This commit is contained in:
ciregenz
2026-08-05 00:59:50 -07:00
parent 8fcea4a24d
commit d269a9f3c7
13 changed files with 121 additions and 12 deletions
@@ -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 = () => {
<ShareRequestHost />
{/* Shell-global right-click host (portals to body): chat surfaces render on non-dashboard routes too, so the menu can't live inside DashboardCanvas. */}
<CardContextMenu />
</Box>
);
};
@@ -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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
return (
<Box key={msg.id} data-window-item-id={msg.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
<Box
onContextMenu={(e: React.MouseEvent) => {
// 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).
@@ -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<string, string>;
selectAttrs: ToolSelectAttrs;
}
export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
@@ -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<HTMLDivElement>;
selectAttrs: Record<string, string>;
selectAttrs: ToolSelectAttrs;
}
export const CreateAgentBubble: React.FC<CreateAgentBubbleProps> = ({
@@ -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<string, string>;
selectAttrs: ToolSelectAttrs;
suppressReveal?: boolean;
}
@@ -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<HTMLDivElement>;
selectAttrs: Record<string, string>;
selectAttrs: ToolSelectAttrs;
}
export const InvokeAgentBubble: React.FC<InvokeAgentBubbleProps> = ({
@@ -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<ToolCallBubbleProps> = 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) {
@@ -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<DashboardCanvasProps> = ({
/>
</Box>
{/* 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. */}
<CardContextMenu />
</Box>
</>
);
@@ -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<DashboardHeaderProps> = ({
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 (
<Box ref={containerRef} sx={{ position: 'relative', display: 'inline-flex', flexDirection: 'column' }}>
<Box
onClick={toggle}
onContextMenu={openNameMenu}
sx={{
display: 'flex',
alignItems: 'center',
@@ -4,6 +4,8 @@ import { useTiledCard } from './useTiledCard';
import { useCardTiling } from './useCardTiling';
import { useCanvasWindowResize } from './useCanvasWindowResize';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import { openCardContextMenu, isNativeMenuTarget, type CardMenuRow } from '../desktop/openCardContextMenu';
import { tileMenuRows } from './tileMenuRows';
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
const DRAG_THRESHOLD = 3;
@@ -48,6 +50,9 @@ interface CanvasWindowCardProps {
onBringToFront?: (id: string, type: CardType) => 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<CanvasWindowCardProps> = ({
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<CanvasWindowCardProps> = ({
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',
@@ -81,6 +81,8 @@ const SettingsAppCard: React.FC<Props> = ({
onBringToFront={onBringToFront}
onCommitPosition={commitPosition}
onCommitSize={commitSize}
onMinimize={minimize}
onClose={close}
>
{({ header, tileZone, onTileZone }) => (
<>
@@ -368,7 +368,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
<Box
key={`${e.workflow.id}-${idx}`}
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={{ 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 } }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', boxSizing: 'border-box', bgcolor: accent, flexShrink: 0 }} />
<span style={{ color: c.text.muted, flexShrink: 0 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
@@ -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,
@@ -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<Props> = ({
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 (
<CanvasWindowCard
@@ -73,6 +75,8 @@ const WorkflowsAppCard: React.FC<Props> = ({
onBringToFront={onBringToFront}
onCommitPosition={commitPosition}
onCommitSize={commitSize}
onMinimize={minimize}
onClose={close}
>
{({ header, tileZone, onTileZone }) => (
<WorkflowsAppContent header={header} tileZone={tileZone} onTileZone={onTileZone} />