mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 13:17:40 +02:00
[eric] menus: messages, tool rows, app windows, island name answer right-click; host goes shell-global
This commit is contained in:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user