[eric] context menus: portal the card menu to body, widen the row schema, cover every shell surface

This commit is contained in:
ciregenz
2026-07-30 15:13:32 -07:00
parent b8cfaff690
commit 77b3595264
24 changed files with 998 additions and 212 deletions
@@ -17,7 +17,9 @@ import { addWorkflowCard, openWorkflowsApp, closeWorkflowsApp } from '@/shared/s
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice';
import { searchHistory, clearHistorySearch, deleteSession, renameSession } from '@/shared/state/agentsSlice';
import { openCardContextMenu } from './desktop/openCardContextMenu';
import { displaySessionName } from '@/shared/state/sessionDisplay';
import { updateSettingsPatch, AppSettings } from '@/shared/state/settingsSlice';
import { store } from '@/shared/state/store';
import { API_BASE, getAuthToken } from '@/shared/config';
@@ -252,6 +254,25 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
handleCloseHistory();
}, [onHistoryResume, handleCloseHistory]);
const handleHistoryContextMenu = useCallback((e: React.MouseEvent, entry: { id: string; name: string }) => {
openCardContextMenu(e, {
rename: { value: displaySessionName(entry.name), onCommit: (name) => { void dispatch(renameSession({ sessionId: entry.id, name })); } },
items: [
{ label: 'Resume chat', onClick: () => handleHistorySelect(entry.id) },
{ kind: 'separator' },
{
label: 'Delete chat',
danger: true,
onClick: () => {
void dispatch(deleteSession({ sessionId: entry.id })).then(() => {
dispatch(searchHistory({ q: historyQuery, limit: HISTORY_PAGE_SIZE, offset: 0 }));
});
},
},
],
});
}, [dispatch, handleHistorySelect, historyQuery]);
const handleHistoryLoadMore = useCallback(() => {
if (historySearch.loading || !historySearch.hasMore) return;
dispatch(searchHistory({
@@ -460,6 +481,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
historyQuery={historyQuery}
onHistoryQueryChange={setHistoryQuery}
onHistorySelect={handleHistorySelect}
onHistoryContextMenu={handleHistoryContextMenu}
onNewChat={() => { handleCloseHistory(); onNewAgent(); }}
onWorkflowSelect={(wid) => {
dispatch(openWorkflowsApp({ workflowId: wid }));
@@ -6,6 +6,8 @@ import DashboardHeader from './DashboardHeader';
import TetherLayer from './TetherLayer';
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';
import DesktopDock from '../desktop/DesktopDock';
@@ -176,6 +178,11 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
const anyFullscreen = !!fullscreenCardId || !!workflowsHub?.fullscreen || settingsFullscreen;
const [headerRevealed, setHeaderRevealed] = React.useState(false);
const [appsWindowOpen, setAppsWindowOpen] = React.useState(false);
const onCanvasContextMenu = useCanvasContextMenu({
dispatch, dashboardId, expandedSessionIds, selection, canvasEmpty,
viewportRef: canvas.viewportRef, getCamera: canvas.actions.getLiveState,
onNewAgent, onAddBrowser, onApplications: () => setAppsWindowOpen(true), onTidy, onFitToView,
});
useEffect(() => {
if (!fullscreenCardId) return undefined;
const onKey = (e: KeyboardEvent): void => {
@@ -315,15 +322,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
onMouseMove={onViewportMouseMove}
onMouseUp={onViewportMouseUp}
onDoubleClick={onViewportDoubleClick}
onContextMenu={(e) => {
// Right-drag is the canvas marquee-select (Google-Maps style), so the native menu (Inspect
// Element in dev) shouldn't pop over it. Suppress only on the bare canvas; cards, inputs, and
// webviews keep their own menus.
const t = e.target as HTMLElement;
if (!t.closest('[data-select-id]') && !t.closest('input, textarea, [contenteditable]')) {
e.preventDefault();
}
}}
onContextMenu={onCanvasContextMenu}
sx={{
position: 'absolute',
inset: 0,
@@ -468,6 +467,10 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
toolbarPrefillMode={toolbarPrefillMode}
/>
</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>
</>
);
@@ -3,7 +3,6 @@ import Box from '@mui/material/Box';
import DashboardToolbar from '../DashboardToolbar';
import CanvasControls from '../controls/CanvasControls';
import HelpPill from '../desktop/HelpPill';
import CardContextMenu from '../desktop/CardContextMenu';
import CardSearchPalette from '../controls/CardSearchPalette';
import DirectionHints from '../controls/DirectionHints';
import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast';
@@ -120,7 +119,6 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
{!anyFullscreen && (
<Box sx={{ position: 'absolute', top: 14, right: 16, zIndex: 10 }}>
<HelpPill />
<CardContextMenu />
</Box>
)}
@@ -0,0 +1,36 @@
import { reopenLastClosed } from '@/shared/state/dashboardLayoutSlice';
import { getClipboardCards } from '@/shared/dashboardClipboard';
import type { AppDispatch } from '@/shared/state/store';
import type { CardMenuRow } from '../desktop/openCardContextMenu';
import { chord } from '../desktop/chord';
interface CanvasMenuArgs {
dispatch: AppDispatch;
hasCards: boolean;
onNewAgent: () => void;
onAddBrowser: () => void;
onApplications: () => void;
onPaste: () => void;
onSelectAll: () => void;
onTidy: () => void;
onFitToView: () => void;
}
export function canvasMenuRows({
dispatch, hasCards, onNewAgent, onAddBrowser, onApplications, onPaste, onSelectAll, onTidy, onFitToView,
}: CanvasMenuArgs): CardMenuRow[] {
return [
{ kind: 'header', label: 'New' },
{ label: 'New chat', onClick: onNewAgent },
{ label: 'New browser', shortcut: chord('mod', 'N'), onClick: onAddBrowser },
{ label: 'Add app', shortcut: chord('mod', 'M'), onClick: onApplications },
{ kind: 'separator' },
{ label: 'Paste', shortcut: chord('mod', 'V'), disabled: getClipboardCards().length === 0, onClick: onPaste },
{ label: 'Reopen last closed', shortcut: chord('mod', 'shift', 'T'), onClick: () => { void dispatch(reopenLastClosed()); } },
{ kind: 'separator' },
{ kind: 'header', label: 'Canvas' },
{ label: 'Select all', shortcut: chord('mod', 'A'), disabled: !hasCards, onClick: onSelectAll },
{ label: 'Tidy layout', disabled: !hasCards, onClick: onTidy },
{ label: 'Fit to view', disabled: !hasCards, onClick: onFitToView },
];
}
@@ -0,0 +1,50 @@
import { useCallback } from 'react';
import type React from 'react';
import type { AppDispatch } from '@/shared/state/store';
import { openCardContextMenu } from '../desktop/openCardContextMenu';
import { pasteClipboardCards } from '../hooks/interaction/pasteClipboardCards';
import { canvasMenuRows } from './canvasMenuRows';
import type { useDashboardSelection } from '../hooks/state/useDashboardSelection';
interface CanvasContextMenuArgs {
dispatch: AppDispatch;
dashboardId: string;
expandedSessionIds: string[];
selection: ReturnType<typeof useDashboardSelection>;
canvasEmpty: boolean;
viewportRef: React.RefObject<HTMLDivElement>;
getCamera: () => { panX: number; panY: number; zoom: number };
onNewAgent: () => void;
onAddBrowser: () => void;
onApplications: () => void;
onTidy: () => void;
onFitToView: () => void;
}
export function useCanvasContextMenu(args: CanvasContextMenuArgs): (e: React.MouseEvent) => void {
const { dispatch, dashboardId, expandedSessionIds, selection, canvasEmpty, viewportRef, getCamera } = args;
const { onNewAgent, onAddBrowser, onApplications, onTidy, onFitToView } = args;
return useCallback((e: React.MouseEvent) => {
// Bare canvas only; cards own their own menus and inputs/webviews keep the native one.
const t = e.target as HTMLElement;
if (t.closest('[data-select-id]') || t.closest('input, textarea, [contenteditable]')) return;
const rect = viewportRef.current?.getBoundingClientRect();
const cam = getCamera();
const at = rect
? { x: (e.clientX - rect.left - cam.panX) / cam.zoom, y: (e.clientY - rect.top - cam.panY) / cam.zoom }
: undefined;
openCardContextMenu(e, {
items: canvasMenuRows({
dispatch,
hasCards: !canvasEmpty,
onNewAgent,
onAddBrowser,
onApplications,
onPaste: () => { void pasteClipboardCards({ dispatch, dashboardId, expandedSessionIds, selection, at }); },
onSelectAll: selection.selectAll,
onTidy,
onFitToView,
}),
});
}, [dispatch, dashboardId, expandedSessionIds, selection, canvasEmpty, viewportRef, getCamera, onNewAgent, onAddBrowser, onApplications, onTidy, onFitToView]);
}
@@ -19,7 +19,6 @@ import {
collapseSession,
expandSession,
closeSession,
deleteSession,
fetchSession,
renameSession,
} from '@/shared/state/agentsSlice';
@@ -39,7 +38,8 @@ import {
import WindowControls, { ARC_CHIP_SX } from './WindowControls';
import { useTiledStyle, computeTiledStyle } from './tileZones';
import AgentNarratorPill from '../desktop/AgentNarratorPill';
import { openCardContextMenu } from '../desktop/CardContextMenu';
import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu';
import { agentCardMenuRows } from './agentCardMenuRows';
import { extractLatestTodos } from '../desktop/agentTodos';
import { extractLatestShowUi, extractPendingAskUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
@@ -833,15 +833,14 @@ const AgentCard: React.FC<Props> = ({
e.stopPropagation();
onDoubleClick?.(session.id, 'agent');
}}
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
onContextMenu={(e: React.MouseEvent) => { if (isNativeMenuTarget(e)) return; openCardContextMenu(e, {
rename: { value: displayChatTitle(session), onCommit: (name) => dispatch(renameSession({ sessionId: session.id, name })) },
items: [
{ label: expanded ? 'Collapse' : 'Open', onClick: () => dispatch(expanded ? collapseSession(session.id) : expandSession(session.id)) },
{ label: 'Full Screen', onClick: () => onTile('fullscreen') },
{ label: 'Close', onClick: () => handleRemove() },
{ label: 'Delete chat', danger: true, onClick: () => { void dispatch(deleteSession({ sessionId: session.id })); } },
],
})}
items: agentCardMenuRows({
session, dispatch, expanded, tileZone, expandedSessionIds,
card: { x: cardX, y: cardY, width: cardWidth, height: cardHeight },
onTile, onClose: () => handleRemove(),
}),
}); }}
sx={{
position: 'relative',
// Hover runway for the pop-above header: the header is pointer-events:none until the CARD
@@ -59,12 +59,12 @@ import {
registerPendingLoad,
wakePendingLoad,
type BrowserWebview,
getWebview,
} from '@/shared/browserRegistry';
import { setLastInteractedBrowser } from '@/shared/browserFocus';
import { registerCapsuleForRestore } from '@/shared/browserStateCapsule';
import BrowserFindBar from './BrowserFindBar';
import { openCardContextMenu } from '../desktop/CardContextMenu';
import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu';
import { browserCardMenuRows, browserTabMenuRows } from './browserCardMenuRows';
import { useBrowserActivity } from '@/shared/useBrowserActivity';
import { getActionLabel } from '@/shared/browserCommandHandler';
import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl';
@@ -644,6 +644,8 @@ const BrowserCard: React.FC<Props> = ({
const handleTabPointerDown = useCallback((e: React.PointerEvent) => {
e.stopPropagation();
// A right-click still fires pointerdown; arming the drag here would capture the pointer under the menu.
if (e.button !== 0) return;
const tabId = (e.currentTarget as HTMLElement).getAttribute('data-tab-id');
if (!tabId) return;
tabDragRef.current = { tabId, startX: e.clientX, startY: e.clientY, isDragging: false, detached: false };
@@ -1015,16 +1017,22 @@ const BrowserCard: React.FC<Props> = ({
data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })}
// Marks a kept-alive card parked off-screen (it belongs to another dashboard); fit-to-view must skip it or it pans the canvas to chase it and the card bleeds onto the dashboard you're viewing.
data-keepalive-hidden={keepAliveHidden || isMinimized || dockParked ? '1' : undefined}
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
items: [
{ label: 'New Tab', onClick: () => dispatch(addBrowserTab({ browserId, url: browserHomepage })) },
{ label: 'Reload', onClick: () => { try { (getWebview(browserId) as { reload?: () => void } | undefined)?.reload?.(); } catch { /* webview gone */ } } },
{ label: 'Copy URL', onClick: () => { void navigator.clipboard.writeText(activeUrl); } },
{ label: 'Full Screen', onClick: () => onTile('fullscreen') },
{ label: 'Minimize', onClick: handleMinimize },
{ label: 'Close', danger: true, onClick: () => { dispatch(recordClosedCard({ kind: 'browser', id: browserId })); removeBrowserCardCleanly(browserId, dispatch); } },
],
})}
onContextMenu={(e: React.MouseEvent) => { if (isNativeMenuTarget(e)) return; openCardContextMenu(e, {
items: browserCardMenuRows({
browserId, dispatch, tabs, activeUrl, activeTitle, homepage: browserHomepage, tileZone, isMinimized,
card: { x: cardX, y: cardY, width: cardWidth, height: cardHeight },
nav: {
reload: () => { try { webviewMap.current.get(activeTabId)?.reload(); } catch { /* webview gone */ } },
back: () => { try { webviewMap.current.get(activeTabId)?.goBack(); } catch { /* webview gone */ } },
forward: () => { try { webviewMap.current.get(activeTabId)?.goForward(); } catch { /* webview gone */ } },
canGoBack: activeLocal.canGoBack,
canGoForward: activeLocal.canGoForward,
},
onTile,
onMinimize: () => (isMinimized ? dispatch(toggleMinimizeCard({ cardId: browserId })) : handleMinimize()),
onFind: () => { setFindOpen(true); setFindFocusSignal((n) => n + 1); },
}),
}); }}
onPointerDownCapture={(e: React.PointerEvent) => {
onBringToFront?.(browserId, 'browser');
// Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path. Pass the target so URL-bar/tab presses select without yanking the camera.
@@ -1144,6 +1152,9 @@ const BrowserCard: React.FC<Props> = ({
<Box
key={tab.id}
data-tab-id={tab.id}
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
items: browserTabMenuRows({ browserId, dispatch, tab, tabCount: tabs.length, homepage: browserHomepage }),
})}
onPointerDown={handleTabPointerDown}
onPointerMove={handleTabPointerMove}
onPointerUp={handleTabPointerUp}
@@ -15,14 +15,15 @@ import TerminalRoundedIcon from '@mui/icons-material/TerminalRounded';
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
import AddIcon from '@mui/icons-material/Add';
import KeyboardArrowUpRounded from '@mui/icons-material/KeyboardArrowUpRounded';
import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { Output, SERVE_BASE, updateOutput } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewDocked, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { saveMinimizedShot } from '../desktop/minimizedShots';
import { requestAppSlot, releaseAppSlot, subscribeAppBudget } from '@/shared/appWebviewBudget';
import { expandSession } from '@/shared/state/agentsSlice';
import WindowControls from './WindowControls';
import { openCardContextMenu } from '../desktop/CardContextMenu';
import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu';
import { viewCardMenuRows } from './viewCardMenuRows';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import { useTiledStyle, computeTiledStyle } from './tileZones';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -33,6 +34,7 @@ import TerminalPanel, { TerminalLine } from '@/app/pages/Views/TerminalPanel';
import AppCodePanel from '@/app/pages/Views/AppCodePanel';
import HistoryPanel from '@/app/pages/Views/HistoryPanel';
import ShareButton from '@/app/components/share/ShareButton';
import ShareModal from '@/app/components/share/ShareModal';
import { getDefault } from '@/shared/inputSchemaDefaults';
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
import {
@@ -597,8 +599,9 @@ const DashboardViewCard: React.FC<Props> = ({
};
const [reloadMenuRect, setReloadMenuRect] = useState<DOMRect | null>(null);
const handleHardReload = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
const [shareOpen, setShareOpen] = useState(false);
const handleHardReload = useCallback(async (e?: React.MouseEvent) => {
e?.stopPropagation();
setReloadMenuRect(null);
const wsId = output.workspace_id;
if (wsId) {
@@ -645,13 +648,19 @@ const DashboardViewCard: React.FC<Props> = ({
data-select-type="view-card"
data-select-id={cardKey}
data-keepalive-hidden={isMinimized ? '1' : undefined}
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
items: [
{ label: 'Full Screen', onClick: () => onTile('fullscreen') },
{ label: 'Minimize', onClick: onMinimize },
{ label: 'Close', danger: true, onClick: () => handleRemove() },
],
})}
onContextMenu={(e: React.MouseEvent) => { if (isNativeMenuTarget(e)) return; openCardContextMenu(e, {
rename: { value: output.name, onCommit: (name) => { void dispatch(updateOutput({ id: output.id, name })); } },
items: viewCardMenuRows({
output, cardKey, dispatch, tileZone, isMinimized,
card: { x: cardX, y: cardY, width: cardWidth, height: cardHeight },
onTile,
onMinimize: () => (isMinimized ? dispatch(toggleMinimizeCard({ cardId: cardKey })) : onMinimize()),
onReload: () => previewRef.current?.reload(),
onHardReload: () => { void handleHardReload(); },
onShare: () => setShareOpen(true),
onClose: () => handleRemove(),
}),
}); }}
data-select-meta={JSON.stringify({ name: output.name, description: output.description, path: output.workspace_path })}
className="osw-card"
onPointerDownCapture={() => onBringToFront?.(cardKey, 'view')}
@@ -987,6 +996,8 @@ const DashboardViewCard: React.FC<Props> = ({
</>,
document.body,
)}
{shareOpen && <ShareModal target={{ kind: 'app', id: output.id, name: output.name }} open onClose={() => setShareOpen(false)} />}
</Box>
);
};
@@ -0,0 +1,59 @@
import { clearSessionMessages, deleteSession, duplicateSession, expandSession, collapseSession, stopAgent, fetchSession, type AgentSession } from '@/shared/state/agentsSlice';
import { placeCard, bringToFront } from '@/shared/state/dashboardLayoutSlice';
import { setClipboardCards } from '@/shared/dashboardClipboard';
import { handleSlashCommand } from '@/app/pages/AgentChat/ChatInput/hooks/slashCommands';
import type { AppDispatch } from '@/shared/state/store';
import type { CardMenuRow } from '../desktop/openCardContextMenu';
import { chord } from '../desktop/chord';
import { tileMenuRows } from './tileMenuRows';
interface AgentMenuArgs {
session: AgentSession;
dispatch: AppDispatch;
expanded: boolean;
tileZone?: string;
expandedSessionIds: string[];
card: { x: number; y: number; width: number; height: number };
onTile: (zone: string) => void;
onClose: () => void;
}
export function agentCardMenuRows({ session, dispatch, expanded, tileZone, expandedSessionIds, card, onTile, onClose }: AgentMenuArgs): CardMenuRow[] {
const running = session.status === 'running';
return [
{ label: expanded ? 'Collapse' : 'Open', shortcut: chord('enter'), onClick: () => dispatch(expanded ? collapseSession(session.id) : expandSession(session.id)) },
{ label: tileZone === 'fullscreen' ? 'Exit Full Screen' : 'Full Screen', onClick: () => onTile(tileZone === 'fullscreen' ? 'restore' : 'fullscreen') },
{ label: 'Tile to zone', submenu: tileMenuRows(onTile, tileZone) },
{ label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: session.id, type: 'agent' })) },
{ kind: 'separator' },
{
label: 'Duplicate',
onClick: () => {
void dispatch(duplicateSession({ sessionId: session.id, dashboardId: session.dashboard_id })).then((action) => {
if (duplicateSession.fulfilled.match(action)) {
dispatch(placeCard({ sessionId: action.payload.id, x: card.x + 40, y: card.y - 40, width: card.width, height: card.height, expandedSessionIds }));
if (expanded) dispatch(expandSession(action.payload.id));
}
});
},
},
{
label: 'Copy',
shortcut: chord('mod', 'C'),
onClick: () => setClipboardCards([{
type: 'agent', id: session.id, name: session.name || session.id,
meta: { name: session.name, status: session.status, model: session.model, mode: session.mode },
x: card.x, y: card.y, width: card.width, height: card.height, expanded,
}]),
},
{ kind: 'separator' },
{ kind: 'header', label: 'Session' },
{ label: 'Stop turn', disabled: !running, onClick: () => { void dispatch(stopAgent({ sessionId: session.id })); } },
// Clear the server transcript first, then the local one: the reducer alone would leave the backend holding history.
{ label: 'Clear messages', disabled: running, onClick: () => { void handleSlashCommand('/clear', session.id).then(() => dispatch(clearSessionMessages(session.id))); } },
{ label: 'Compact context', disabled: running, onClick: () => { void handleSlashCommand('/compact', session.id).then(() => dispatch(fetchSession(session.id))); } },
{ kind: 'separator' },
{ label: 'Close', onClick: onClose },
{ label: 'Delete chat', shortcut: chord('del'), danger: true, onClick: () => { void dispatch(deleteSession({ sessionId: session.id })); } },
];
}
@@ -0,0 +1,97 @@
import { addBrowserTab, bringToFront, recordClosedCard, removeBrowserTab, reopenLastClosed, setActiveBrowserTab, type BrowserTab } from '@/shared/state/dashboardLayoutSlice';
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
import { setClipboardCards } from '@/shared/dashboardClipboard';
import type { AppDispatch } from '@/shared/state/store';
import type { CardMenuRow } from '../desktop/openCardContextMenu';
import { chord } from '../desktop/chord';
import { tileMenuRows } from './tileMenuRows';
interface BrowserNav {
reload: () => void;
back: () => void;
forward: () => void;
canGoBack: boolean;
canGoForward: boolean;
}
interface BrowserMenuArgs {
browserId: string;
dispatch: AppDispatch;
tabs: BrowserTab[];
activeUrl: string;
activeTitle: string;
homepage: string;
tileZone?: string;
isMinimized: boolean;
card: { x: number; y: number; width: number; height: number };
nav: BrowserNav;
onTile: (zone: string) => void;
onMinimize: () => void;
onFind: () => void;
}
export function closeBrowserCard(browserId: string, dispatch: AppDispatch): void {
dispatch(recordClosedCard({ kind: 'browser', id: browserId }));
void removeBrowserCardCleanly(browserId, dispatch);
}
export function browserCardMenuRows({
browserId, dispatch, tabs, activeUrl, activeTitle, homepage, tileZone, isMinimized, card, nav, onTile, onMinimize, onFind,
}: BrowserMenuArgs): CardMenuRow[] {
return [
{ label: 'New tab', onClick: () => dispatch(addBrowserTab({ browserId, url: homepage })) },
{ label: 'Reopen closed tab', shortcut: chord('mod', 'shift', 'T'), onClick: () => { void dispatch(reopenLastClosed()); } },
{ kind: 'separator' },
{ label: 'Back', disabled: !nav.canGoBack, onClick: nav.back },
{ label: 'Forward', disabled: !nav.canGoForward, onClick: nav.forward },
{ label: 'Reload', onClick: nav.reload },
{ label: 'Find in page', shortcut: chord('mod', 'F'), onClick: onFind },
{ label: 'Copy URL', disabled: !activeUrl, onClick: () => { void navigator.clipboard.writeText(activeUrl); } },
{ kind: 'separator' },
{ label: tileZone === 'fullscreen' ? 'Exit Full Screen' : 'Full Screen', onClick: () => onTile(tileZone === 'fullscreen' ? 'restore' : 'fullscreen') },
{ label: 'Tile to zone', submenu: tileMenuRows(onTile, tileZone) },
{ label: isMinimized ? 'Restore' : 'Minimize', onClick: onMinimize },
{ label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: browserId, type: 'browser' })) },
{
label: 'Copy',
shortcut: chord('mod', 'C'),
onClick: () => setClipboardCards([{
type: 'browser', id: browserId, name: activeTitle || 'Browser',
meta: { name: activeTitle || 'Browser', url: activeUrl, tabs },
x: card.x, y: card.y, width: card.width, height: card.height,
}]),
},
{ kind: 'separator' },
{ label: 'Close', danger: true, onClick: () => closeBrowserCard(browserId, dispatch) },
];
}
interface TabMenuArgs {
browserId: string;
dispatch: AppDispatch;
tab: BrowserTab;
tabCount: number;
homepage: string;
}
export function browserTabMenuRows({ browserId, dispatch, tab, tabCount, homepage }: TabMenuArgs): CardMenuRow[] {
return [
{ label: 'New tab', onClick: () => dispatch(addBrowserTab({ browserId, url: homepage })) },
{ label: 'Duplicate tab', onClick: () => dispatch(addBrowserTab({ browserId, url: tab.url })) },
{ label: 'Reopen closed tab', shortcut: chord('mod', 'shift', 'T'), onClick: () => { void dispatch(reopenLastClosed()); } },
{ kind: 'separator' },
{ label: 'Copy tab URL', disabled: !tab.url, onClick: () => { void navigator.clipboard.writeText(tab.url); } },
{ label: 'Focus tab', onClick: () => dispatch(setActiveBrowserTab({ browserId, tabId: tab.id })) },
{ kind: 'separator' },
{
label: 'Close tab',
danger: true,
onClick: () => {
// Record BEFORE removing: the reducer drops a tab record once the card is down to its last tab.
if (tabCount <= 1) { closeBrowserCard(browserId, dispatch); return; }
dispatch(recordClosedCard({ kind: 'tab', id: tab.id, browserId }));
dispatch(removeBrowserTab({ browserId, tabId: tab.id }));
},
},
];
}
@@ -0,0 +1,34 @@
import type { CardMenuRow } from '../desktop/openCardContextMenu';
// The green-dot tiling grid, spelled out as words for the keyboard/right-click path.
const ZONE_LABELS: Record<string, string> = {
fill: 'Fill',
left: 'Left half',
right: 'Right half',
top: 'Top half',
bottom: 'Bottom half',
tl: 'Top left',
tr: 'Top right',
bl: 'Bottom left',
br: 'Bottom right',
t3l: 'Left third',
t3c: 'Center third',
t3r: 'Right third',
};
const GROUPS: { label: string; zones: string[] }[] = [
{ label: 'Fill and halves', zones: ['fill', 'left', 'right', 'top', 'bottom'] },
{ label: 'Quarters', zones: ['tl', 'tr', 'bl', 'br'] },
{ label: 'Thirds', zones: ['t3l', 't3c', 't3r'] },
];
export function tileMenuRows(onTile: (zone: string) => void, currentZone?: string): CardMenuRow[] {
const rows: CardMenuRow[] = [];
for (const group of GROUPS) {
rows.push({ kind: 'header', label: group.label });
for (const zone of group.zones) {
rows.push({ label: ZONE_LABELS[zone], checked: currentZone === zone, onClick: () => onTile(zone) });
}
}
return rows;
}
@@ -0,0 +1,61 @@
import { addViewCard, bringToFront } from '@/shared/state/dashboardLayoutSlice';
import { deleteOutput, type Output } from '@/shared/state/outputsSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { setClipboardCards } from '@/shared/dashboardClipboard';
import type { AppDispatch } from '@/shared/state/store';
import type { CardMenuRow } from '../desktop/openCardContextMenu';
import { chord } from '../desktop/chord';
import { tileMenuRows } from './tileMenuRows';
interface ViewMenuArgs {
output: Output;
cardKey: string;
dispatch: AppDispatch;
tileZone?: string;
isMinimized: boolean;
card: { x: number; y: number; width: number; height: number };
onTile: (zone: string) => void;
onMinimize: () => void;
onReload: () => void;
onHardReload: () => void;
onShare: () => void;
onClose: () => void;
}
export function viewCardMenuRows({
output, cardKey, dispatch, tileZone, isMinimized, card,
onTile, onMinimize, onReload, onHardReload, onShare, onClose,
}: ViewMenuArgs): CardMenuRow[] {
return [
{ label: 'Open another instance', onClick: () => dispatch(addViewCard({ outputId: output.id, newInstance: true })) },
{ kind: 'separator' },
{ label: tileZone === 'fullscreen' ? 'Exit Full Screen' : 'Full Screen', onClick: () => onTile(tileZone === 'fullscreen' ? 'restore' : 'fullscreen') },
{ label: 'Tile to zone', submenu: tileMenuRows(onTile, tileZone) },
{ label: isMinimized ? 'Restore' : 'Minimize', onClick: onMinimize },
{ label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: cardKey, type: 'view' })) },
{ kind: 'separator' },
{ label: 'Reload', onClick: onReload },
{ label: 'Restart and hard reload', onClick: onHardReload },
{ kind: 'separator' },
{
label: 'Copy',
shortcut: chord('mod', 'C'),
onClick: () => setClipboardCards([{
type: 'view', id: cardKey, name: output.name,
meta: { name: output.name, description: output.description },
x: card.x, y: card.y, width: card.width, height: card.height,
}]),
},
{ label: 'Share or publish...', onClick: onShare },
{ kind: 'separator' },
{ label: 'Close', onClick: onClose },
{
label: 'Delete app',
danger: true,
onClick: () => {
// The card has to go first: deleting the output alone leaves a card pointing at nothing.
void removeViewCardCleanly(cardKey, dispatch).then(() => dispatch(deleteOutput(output.id)));
},
},
];
}
@@ -1,138 +1,274 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import Box from '@mui/material/Box';
import CardMenuPanel, { MENU_WIDTH } from './CardMenuPanel';
import { CARD_MENU_EVENT, isMenuAction, type CardMenuAction, type CardMenuRequest, type CardMenuRow } from './openCardContextMenu';
// One right-click menu for every canvas entity (chats, browsers, apps, workflow cards,
// minimized pills). Cards call openCardContextMenu with their items; this overlay renders the
// native-feeling glass menu (SpacesStrip grammar) and closes on outside press / Esc / item click.
export interface CardMenuItem {
label: string;
danger?: boolean;
disabled?: boolean;
onClick: () => void;
}
// INVARIANT: the canvas pans/zooms via a CSS transform, and a transformed ancestor becomes the
// containing block for position:fixed, so this menu portals to document.body and never mounts inside
// the canvas subtree. A DOM menu also cannot cover an Electron <webview>: guest pages get Electron's
// own native menu, and a page that preventDefaults its contextmenu gets neither. That is expected.
const EDGE = 8;
const TOP_LAYER = 2147483647;
export interface CardMenuRequest {
interface Placement {
x: number;
y: number;
items: CardMenuItem[];
/** Optional inline-rename affordance: shown as the first row with an editable input. */
rename?: { value: string; onCommit: (next: string) => void };
origin: string;
nudgeX: number;
nudgeY: number;
}
const EVENT = 'openswarm:card-context-menu';
export function openCardContextMenu(e: { clientX: number; clientY: number; preventDefault: () => void; stopPropagation: () => void }, req: Omit<CardMenuRequest, 'x' | 'y'>): void {
e.preventDefault();
e.stopPropagation();
window.dispatchEvent(new CustomEvent(EVENT, { detail: { ...req, x: e.clientX, y: e.clientY } }));
function place(x: number, y: number, w: number, h: number): Placement {
const flipX = x + w > window.innerWidth - EDGE && x - w >= EDGE;
const flipY = y + h > window.innerHeight - EDGE && y - h >= EDGE;
const rawX = flipX ? x - w : x;
const rawY = flipY ? y - h : y + 2;
return {
x: Math.max(EDGE, Math.min(rawX, window.innerWidth - w - EDGE)),
y: Math.max(EDGE, Math.min(rawY, window.innerHeight - h - EDGE)),
origin: `${flipX ? 'right' : 'left'} ${flipY ? 'bottom' : 'top'}`,
nudgeX: flipX ? 4 : -4,
nudgeY: flipY ? 4 : -4,
};
}
const MENU_W = 208;
function step(items: CardMenuRow[], from: number | null, dir: 1 | -1): number | null {
const n = items.length;
if (n === 0) return null;
for (let hop = 1; hop <= n; hop += 1) {
const i = ((from ?? (dir === 1 ? -1 : 0)) + dir * hop + n * 2) % n;
const row = items[i];
if (isMenuAction(row) && !row.disabled) return i;
}
return null;
}
function CardContextMenu(): React.ReactElement | null {
const [menu, setMenu] = useState<CardMenuRequest | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const [rootPlacement, setRootPlacement] = useState<Placement | null>(null);
const [shown, setShown] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [subActiveIndex, setSubActiveIndex] = useState<number | null>(null);
const [subPlacement, setSubPlacement] = useState<Placement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const subRef = useRef<HTMLDivElement | null>(null);
const returnFocusTo = useRef<HTMLElement | null>(null);
const close = useCallback((): void => {
setMenu(null);
setRootPlacement(null);
setSubPlacement(null);
setShown(false);
setOpenIndex(null);
setActiveIndex(null);
setSubActiveIndex(null);
const back = returnFocusTo.current;
returnFocusTo.current = null;
if (back && back.isConnected) back.focus?.();
}, []);
useEffect(() => {
const onOpen = (e: Event): void => {
const req = (e as CustomEvent).detail as CardMenuRequest;
returnFocusTo.current = document.activeElement as HTMLElement | null;
setMenu(req);
setRootPlacement(null);
setSubPlacement(null);
setShown(false);
setOpenIndex(null);
setActiveIndex(null);
setSubActiveIndex(null);
setRenaming(false);
setRenameValue(req.rename?.value ?? '');
};
window.addEventListener(EVENT, onOpen);
return () => window.removeEventListener(EVENT, onOpen);
window.addEventListener(CARD_MENU_EVENT, onOpen);
return () => window.removeEventListener(CARD_MENU_EVENT, onOpen);
}, []);
// Measured, never guessed: rows render ~30px, so a hardcoded row height over-corrects long menus.
useLayoutEffect(() => {
if (!menu || !rootRef.current) return;
const r = rootRef.current.getBoundingClientRect();
setRootPlacement(place(menu.x, menu.y, r.width || MENU_WIDTH, r.height));
}, [menu]);
// One frame at the pre-entry style, otherwise there is no start value for the transition to run from.
useEffect(() => {
if (!rootPlacement || shown) return undefined;
const raf = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(raf);
}, [rootPlacement, shown]);
const openRow = menu && openIndex !== null ? menu.items[openIndex] : undefined;
const submenu = openRow && isMenuAction(openRow) ? (openRow as CardMenuAction).submenu : undefined;
useLayoutEffect(() => {
if (!submenu || !subRef.current || !rootRef.current || openIndex === null) return;
const r = subRef.current.getBoundingClientRect();
const panel = rootRef.current.getBoundingClientRect();
const rowEl = rootRef.current.querySelector(`[data-menu-row="${openIndex}"]`);
const anchorY = (rowEl?.getBoundingClientRect().top ?? panel.top) - 6;
const toRight = panel.right - 4;
const fits = toRight + r.width <= window.innerWidth - EDGE;
const x = fits ? toRight : panel.left - r.width + 4;
setSubPlacement({
x: Math.max(EDGE, Math.min(x, window.innerWidth - r.width - EDGE)),
y: Math.max(EDGE, Math.min(anchorY, window.innerHeight - r.height - EDGE)),
origin: `${fits ? 'left' : 'right'} top`,
nudgeX: fits ? -4 : 4,
nudgeY: 0,
});
}, [submenu, openIndex]);
const runRow = useCallback((row: CardMenuRow | undefined): void => {
if (!row || !isMenuAction(row) || row.disabled || row.submenu) return;
close();
row.onClick?.();
}, [close]);
useEffect(() => {
if (!menu) return undefined;
const onDown = (): void => setMenu(null);
const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') setMenu(null); };
const onDown = (e: Event): void => {
const t = e.target as Node | null;
if (t && (rootRef.current?.contains(t) || subRef.current?.contains(t))) return;
close();
};
const onKey = (e: KeyboardEvent): void => {
const items = menu.items;
if (e.key === 'Escape') {
e.preventDefault();
if (openIndex !== null) { setOpenIndex(null); setSubActiveIndex(null); return; }
close();
return;
}
if (submenu && openIndex !== null) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
setSubActiveIndex(step(submenu, subActiveIndex, e.key === 'ArrowDown' ? 1 : -1));
return;
}
if (e.key === 'ArrowLeft') { e.preventDefault(); setOpenIndex(null); setSubActiveIndex(null); return; }
if (e.key === 'Enter' && subActiveIndex !== null) { e.preventDefault(); runRow(submenu[subActiveIndex]); }
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(step(items, activeIndex, e.key === 'ArrowDown' ? 1 : -1));
return;
}
const active = activeIndex !== null ? items[activeIndex] : undefined;
if (e.key === 'ArrowRight' && active && isMenuAction(active) && active.submenu) {
e.preventDefault();
setOpenIndex(activeIndex);
setSubActiveIndex(step(active.submenu, null, 1));
return;
}
if (e.key === 'Enter' && active) {
e.preventDefault();
if (isMenuAction(active) && active.submenu) { setOpenIndex(activeIndex); setSubActiveIndex(step(active.submenu, null, 1)); return; }
runRow(active);
}
};
window.addEventListener('mousedown', onDown);
window.addEventListener('keydown', onKey);
window.addEventListener('wheel', onDown, { passive: true });
window.addEventListener('resize', close);
return () => {
window.removeEventListener('mousedown', onDown);
window.removeEventListener('keydown', onKey);
window.removeEventListener('wheel', onDown);
window.removeEventListener('resize', close);
};
}, [menu]);
}, [menu, activeIndex, openIndex, subActiveIndex, submenu, close, runRow]);
if (!menu) return null;
const itemSx = {
display: 'flex', alignItems: 'center', width: '100%', px: 1.5, py: 0.75,
border: 'none', background: 'transparent', borderRadius: '7px',
color: 'rgba(255,255,255,0.9)', fontFamily: 'inherit', fontSize: '0.8125rem',
cursor: 'pointer', textAlign: 'left' as const,
'&:hover': { background: 'rgba(255,255,255,0.1)' },
'&:disabled': { color: 'rgba(255,255,255,0.35)', cursor: 'default', '&:hover': { background: 'transparent' } },
};
const commitRename = (): void => {
const trimmed = renameValue.trim();
if (trimmed && menu.rename && trimmed !== menu.rename.value) menu.rename.onCommit(trimmed);
setMenu(null);
close();
};
return (
<Box
onMouseDown={(e: React.MouseEvent) => e.stopPropagation()}
onContextMenu={(e: React.MouseEvent) => e.preventDefault()}
sx={{
position: 'fixed',
top: Math.min(menu.y + 2, window.innerHeight - 44 * (menu.items.length + 1) - 16),
left: Math.min(menu.x, window.innerWidth - MENU_W - 12),
zIndex: 100001,
width: MENU_W, p: 0.5, borderRadius: '10px',
background: 'rgba(28,25,33,0.96)',
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
border: '1px solid rgba(255,255,255,0.12)',
boxShadow: '0 18px 44px rgba(0,0,0,0.5)',
}}
>
{menu.rename && (
renaming ? (
<Box
component="input"
autoFocus
value={renameValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRenameValue(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') setMenu(null);
}}
onBlur={commitRename}
sx={{
width: '100%', boxSizing: 'border-box', mb: 0.25, px: 1.25, py: 0.6,
border: '1px solid rgba(255,255,255,0.35)', borderRadius: '7px',
background: 'rgba(0,0,0,0.35)', outline: 'none',
color: 'rgba(255,255,255,0.95)', fontFamily: 'inherit', fontSize: '0.8125rem',
}}
/>
) : (
<Box component="button" sx={itemSx} onClick={() => setRenaming(true)}>
Rename
</Box>
)
)}
{menu.items.map((item) => (
<Box
key={item.label}
component="button"
disabled={item.disabled}
sx={{
...itemSx,
...(item.danger && { color: '#ff7b72', '&:hover': { background: 'rgba(255,123,114,0.12)' } }),
const entry = (p: Placement | null, visible: boolean): Record<string, unknown> => ({
position: 'fixed' as const,
left: p?.x ?? -9999,
top: p?.y ?? -9999,
pointerEvents: 'auto' as const,
opacity: visible ? 1 : 0,
transform: visible ? 'none' : `translate(${p?.nudgeX ?? 0}px, ${p?.nudgeY ?? 4}px) scale(0.97)`,
transformOrigin: p?.origin ?? 'left top',
transition: 'opacity 140ms cubic-bezier(0.25,0.46,0.45,0.94), transform 170ms cubic-bezier(0.25,0.46,0.45,0.94)',
'@media (prefers-reduced-motion: reduce)': { transition: 'none', transform: 'none' },
});
return createPortal(
<Box role="menu" onContextMenu={(e: React.MouseEvent) => e.preventDefault()} sx={{ position: 'fixed', inset: 0, zIndex: TOP_LAYER, pointerEvents: 'none' }}>
<Box data-card-context-menu sx={entry(rootPlacement, shown)}>
<CardMenuPanel
ref={rootRef}
items={menu.items}
activeIndex={activeIndex}
openIndex={openIndex}
onActivate={(i) => {
const row = menu.items[i];
if (isMenuAction(row) && row.submenu) { setOpenIndex(i); setActiveIndex(i); setSubActiveIndex(null); return; }
runRow(row);
}}
onHover={(i) => {
setActiveIndex(i);
const row = menu.items[i];
if (isMenuAction(row) && row.submenu) { setOpenIndex(i); setSubActiveIndex(null); } else setOpenIndex(null);
}}
onClick={() => { setMenu(null); item.onClick(); }}
>
{item.label}
{menu.rename && (renaming ? (
<Box
component="input"
autoFocus
value={renameValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRenameValue(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') close();
}}
onBlur={commitRename}
sx={{
width: '100%', boxSizing: 'border-box', mb: '2px', px: '9px', py: '6px',
border: '1px solid rgba(255,255,255,0.35)', borderRadius: '8px',
background: 'rgba(0,0,0,0.35)', outline: 'none',
color: 'rgba(255,255,255,0.95)', fontFamily: 'inherit', fontSize: '0.8125rem',
}}
/>
) : (
<Box
component="button"
type="button"
onMouseEnter={() => { setActiveIndex(null); setOpenIndex(null); }}
onClick={() => setRenaming(true)}
sx={{
display: 'flex', alignItems: 'center', width: '100%', boxSizing: 'border-box',
px: '9px', py: '5px', minHeight: 30, border: 'none', background: 'transparent',
borderRadius: '8px', color: 'rgba(255,255,255,0.9)', fontFamily: 'inherit',
fontSize: '0.8125rem', cursor: 'pointer', textAlign: 'left',
'&:hover': { background: 'rgba(255,255,255,0.10)' },
}}
>
Rename
</Box>
))}
</CardMenuPanel>
</Box>
{submenu && (
<Box sx={entry(subPlacement, shown && !!subPlacement)}>
<CardMenuPanel ref={subRef} items={submenu} activeIndex={subActiveIndex} openIndex={null} onActivate={(i) => runRow(submenu[i])} onHover={setSubActiveIndex} />
</Box>
))}
</Box>
)}
</Box>,
document.body,
);
}
@@ -0,0 +1,130 @@
import React from 'react';
import Box from '@mui/material/Box';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import { isMenuAction, type CardMenuRow } from './openCardContextMenu';
export const MENU_WIDTH = 236;
const ROW_RADIUS = 8;
interface CardMenuPanelProps {
items: CardMenuRow[];
/** Index into `items` of the keyboard-active row, or null. */
activeIndex: number | null;
/** Index into `items` of the row whose submenu is open, or null. */
openIndex: number | null;
onActivate: (index: number) => void;
onHover: (index: number) => void;
width?: number;
children?: React.ReactNode;
}
const rowSx = {
display: 'flex',
alignItems: 'center',
gap: '9px',
width: '100%',
boxSizing: 'border-box' as const,
px: '9px',
py: '5px',
minHeight: 30,
border: 'none',
background: 'transparent',
borderRadius: `${ROW_RADIUS}px`,
color: 'rgba(255,255,255,0.9)',
fontFamily: 'inherit',
fontSize: '0.8125rem',
lineHeight: 1.35,
cursor: 'pointer',
textAlign: 'left' as const,
};
// Panel radius stays 12 while rows sit at 8: the inner corner must always be the smaller one.
export const PANEL_SX = {
width: MENU_WIDTH,
p: '6px',
boxSizing: 'border-box' as const,
borderRadius: '12px',
background: 'rgba(28,25,33,0.94)',
backdropFilter: 'blur(24px) saturate(150%)',
WebkitBackdropFilter: 'blur(24px) saturate(150%)',
border: '1px solid rgba(255,255,255,0.12)',
boxShadow: '0 18px 44px rgba(0,0,0,0.5)',
};
const CardMenuPanel = React.forwardRef<HTMLDivElement, CardMenuPanelProps>(function CardMenuPanel(
{ items, activeIndex, openIndex, onActivate, onHover, width, children },
ref,
) {
return (
<Box ref={ref} sx={{ ...PANEL_SX, ...(width ? { width } : {}) }}>
{children}
{items.map((row, index) => {
if (row.kind === 'separator') {
return <Box key={`sep-${index}`} sx={{ height: '1px', mx: '7px', my: '5px', background: 'rgba(255,255,255,0.09)' }} />;
}
if (row.kind === 'header') {
return (
<Box
key={`hdr-${index}`}
sx={{ px: '9px', pt: '7px', pb: '3px', fontSize: '0.6875rem', fontWeight: 600, color: 'rgba(255,255,255,0.42)', letterSpacing: '0.01em' }}
>
{row.label}
</Box>
);
}
if (!isMenuAction(row)) return null;
const active = activeIndex === index && !row.disabled;
const highlighted = active || openIndex === index;
const tone = row.danger ? '#ff7b72' : 'rgba(255,255,255,0.9)';
const hoverBg = row.danger ? 'rgba(255,123,114,0.14)' : 'rgba(255,255,255,0.10)';
return (
<Box
key={`${row.label}-${index}`}
component="button"
type="button"
role="menuitem"
aria-disabled={row.disabled || undefined}
aria-haspopup={row.submenu ? 'menu' : undefined}
disabled={row.disabled}
data-menu-row={index}
onMouseEnter={() => onHover(index)}
onClick={() => onActivate(index)}
sx={{
...rowSx,
color: tone,
background: highlighted ? hoverBg : 'transparent',
'&:hover': { background: hoverBg },
'&:disabled': { color: 'rgba(255,255,255,0.32)', cursor: 'default', background: 'transparent' },
}}
>
{row.checked !== undefined && (
<CheckRoundedIcon sx={{ fontSize: 15, flexShrink: 0, opacity: row.checked ? 1 : 0, color: 'rgba(255,255,255,0.75)' }} />
)}
{row.icon !== undefined && (
<Box sx={{ display: 'flex', flexShrink: 0, color: row.danger ? tone : 'rgba(255,255,255,0.6)', '& svg': { fontSize: 16 } }}>{row.icon}</Box>
)}
<Box component="span" sx={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{row.label}
</Box>
{row.shortcut && (
<Box
component="span"
sx={{
flexShrink: 0, px: '5px', py: '1px', borderRadius: '5px',
background: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.55)',
fontSize: '0.6875rem', letterSpacing: '0.02em',
}}
>
{row.shortcut}
</Box>
)}
{row.submenu && <ChevronRightRoundedIcon sx={{ fontSize: 16, flexShrink: 0, color: 'rgba(255,255,255,0.45)' }} />}
</Box>
);
})}
</Box>
);
});
export default CardMenuPanel;
@@ -10,6 +10,8 @@ import AppsRoundedIcon from '@mui/icons-material/AppsRounded';
import { useAppDispatch } from '@/shared/hooks';
import { getWebview } from '@/shared/browserRegistry';
import { buildDockEntries, CardRect, DockEntry } from './dockEntries';
import { openCardContextMenu } from './openCardContextMenu';
import { dockTileMenuRows } from './dockTileMenuRows';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -177,6 +179,10 @@ function DesktopDock({
endHover();
onFocusCard(entry.id, entry.rect);
}}
onContextMenu={(e: React.MouseEvent) => {
endHover();
openCardContextMenu(e, { items: dockTileMenuRows(entry, dispatch, () => onFocusCard(entry.id, entry.rect)) });
}}
sx={{
position: 'relative',
width: TILE,
@@ -9,6 +9,8 @@ import { GLASS_SURFACE, GLASS_SURFACE_BLUR } from '@/shared/styles/glassSurface'
import { dropMinimizedShot } from './minimizedShots';
import { buildMinimizedEntries, MinimizedEntry, MinimizedRect } from './minimizedEntries';
import MinimizedTile, { MINIMIZED_TILE_W } from './MinimizedTile';
import { openCardContextMenu } from './openCardContextMenu';
import { tileMenuRows } from '../cards/tileMenuRows';
import type { BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
@@ -88,6 +90,15 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto
onRestore={() => restore(entry)}
onClose={() => close(entry)}
onTile={(zone: string) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }}
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
items: [
{ label: 'Restore', onClick: () => restore(entry) },
{ label: 'Restore full screen', onClick: () => { restore(entry); dispatch(setTiledCard({ cardId: entry.id, zone: 'fullscreen' })); } },
{ label: 'Tile to zone', submenu: tileMenuRows((zone) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }) },
{ kind: 'separator' },
{ label: 'Close', danger: true, onClick: () => close(entry) },
],
})}
/>
))}
</Box>
@@ -15,6 +15,7 @@ interface MinimizedTileProps {
onRestore: () => void;
onClose: () => void;
onTile: (zone: string) => void;
onContextMenu: (e: React.MouseEvent) => void;
}
export const MINIMIZED_TILE_W = 132;
@@ -27,7 +28,7 @@ const REST_EDGE = 'rgba(255,255,255,0.10)';
const HOVER_EDGE = 'rgba(255,255,255,0.16)';
/** One parked window: its own last frame, a favicon or glyph, and the title. Hover reveals the lights. */
function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile }: MinimizedTileProps): React.ReactElement {
function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile, onContextMenu }: MinimizedTileProps): React.ReactElement {
const [faviconFailed, setFaviconFailed] = useState(false);
const preview = getMinimizedShot(entry.id) || entry.thumbnail || null;
const showFavicon = entry.kind === 'browser' && !!entry.faviconUrl && !faviconFailed;
@@ -55,6 +56,7 @@ function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile }:
<Box
className="osw-card osw-pill-host osw-min-tile"
onClick={onRestore}
onContextMenu={onContextMenu}
title={entry.label}
sx={{
position: 'relative',
@@ -0,0 +1,16 @@
// Renders a shortcut the way the platform writes it: mac glyphs run together, Windows spells them with +.
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform);
const GLYPH: Record<string, string> = {
mod: IS_MAC ? '⌘' : 'Ctrl',
ctrl: IS_MAC ? '⌃' : 'Ctrl',
shift: IS_MAC ? '⇧' : 'Shift',
alt: IS_MAC ? '⌥' : 'Alt',
enter: IS_MAC ? '↩' : 'Enter',
del: IS_MAC ? '⌫' : 'Del',
tab: IS_MAC ? '⇥' : 'Tab',
};
export function chord(...keys: string[]): string {
return keys.map((k) => GLYPH[k] ?? k).join(IS_MAC ? '' : '+');
}
@@ -21,8 +21,11 @@ export interface CardRect {
height: number;
}
export type DockEntryKind = 'agent' | 'browser' | 'view' | 'workflow';
export interface DockEntry {
id: string;
kind: DockEntryKind;
label: string;
rect: CardRect;
tileBg: string;
@@ -66,6 +69,7 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, wor
const ChatIcon = pickIcon(title) || MessageCircle;
list.push({
id: card.session_id,
kind: 'agent',
label: title,
rect: card,
tileBg: hueFor(title),
@@ -77,6 +81,7 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, wor
const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0];
list.push({
id: bc.browser_id,
kind: 'browser',
label: activeTab?.title || 'Browser',
rect: bc,
tileBg: 'linear-gradient(135deg, #4f9fe8, #2f6ed4)',
@@ -90,6 +95,7 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, wor
const appName = output?.name || 'App';
list.push({
id: cardKey,
kind: 'view',
label: appName,
rect: vc,
tileBg: 'linear-gradient(135deg, #ef9552, #d96a2b)',
@@ -101,6 +107,7 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, wor
for (const [cardKey, wf] of Object.entries(workflowCards)) {
list.push({
id: cardKey,
kind: 'workflow',
label: 'Workflow',
rect: wf,
tileBg: 'linear-gradient(135deg, #ef7a70, #d94f45)',
@@ -0,0 +1,27 @@
import { bringToFront, recordClosedCard, removeCard, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { closeSession } from '@/shared/state/agentsSlice';
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import type { AppDispatch } from '@/shared/state/store';
import type { CardMenuRow } from './openCardContextMenu';
import type { DockEntry } from './dockEntries';
export function dockTileMenuRows(entry: DockEntry, dispatch: AppDispatch, onFocus: () => void): CardMenuRow[] {
return [
{ label: 'Show on canvas', onClick: onFocus },
{ label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: entry.id, type: entry.kind })) },
{ kind: 'separator' },
{
label: 'Close',
danger: true,
onClick: () => {
if (entry.kind === 'browser') { dispatch(recordClosedCard({ kind: 'browser', id: entry.id })); void removeBrowserCardCleanly(entry.id, dispatch); return; }
if (entry.kind === 'view') { dispatch(recordClosedCard({ kind: 'view', id: entry.id })); void removeViewCardCleanly(entry.id, dispatch); return; }
if (entry.kind === 'workflow') { dispatch(recordClosedCard({ kind: 'workflow', id: entry.id })); dispatch(removeWorkflowCard(entry.id)); return; }
dispatch(recordClosedCard({ kind: 'agent', id: entry.id }));
dispatch(removeCard(entry.id));
void dispatch(closeSession({ sessionId: entry.id }));
},
},
];
}
@@ -0,0 +1,64 @@
import type React from 'react';
// The one right-click grammar for the whole shell (cards, tab strip, dock, minimized rail, empty
// canvas). Surfaces describe rows; CardContextMenu owns rendering, placement, and keyboard nav.
export interface CardMenuAction {
kind?: 'action';
label: string;
/** Right-aligned chip. Build it with chord() so it matches the platform; never invent unbound keys. */
shortcut?: string;
icon?: React.ReactNode;
danger?: boolean;
disabled?: boolean;
/** Renders a leading check; use for toggles that are currently on. */
checked?: boolean;
/** One level only. Deep nesting is a menu smell. */
submenu?: CardMenuRow[];
onClick?: () => void;
}
export interface CardMenuSeparator {
kind: 'separator';
}
export interface CardMenuHeader {
kind: 'header';
/** Sentence case, never uppercase. */
label: string;
}
export type CardMenuRow = CardMenuAction | CardMenuSeparator | CardMenuHeader;
export interface CardMenuRequest {
x: number;
y: number;
items: CardMenuRow[];
/** Optional inline-rename affordance: shown as the first row with an editable input. */
rename?: { value: string; onCommit: (next: string) => void };
}
export const CARD_MENU_EVENT = 'openswarm:card-context-menu';
export function isMenuAction(row: CardMenuRow): row is CardMenuAction {
return row.kind === undefined || row.kind === 'action';
}
/** Text fields keep the OS menu: right-clicking a URL bar or composer is how you paste. */
export function isNativeMenuTarget(e: { target: EventTarget | null }): boolean {
const t = e.target as HTMLElement | null;
return !!t?.closest?.('input, textarea, [contenteditable="true"]');
}
interface MenuTriggerEvent {
clientX: number;
clientY: number;
preventDefault: () => void;
stopPropagation: () => void;
}
export function openCardContextMenu(e: MenuTriggerEvent, req: Omit<CardMenuRequest, 'x' | 'y'>): void {
e.preventDefault();
e.stopPropagation();
window.dispatchEvent(new CustomEvent(CARD_MENU_EVENT, { detail: { ...req, x: e.clientX, y: e.clientY } }));
}
@@ -0,0 +1,67 @@
import { duplicateSession, expandSession } from '@/shared/state/agentsSlice';
import { addViewCard, pasteBrowserCard, placeCard } from '@/shared/state/dashboardLayoutSlice';
import { store, type AppDispatch } from '@/shared/state/store';
import { getClipboardCards } from '@/shared/dashboardClipboard';
import type { CardType } from '../state/useDashboardSelection';
const PASTE_OFFSET = 40;
interface PasteTargets {
selectCard: (id: string, type: CardType, additive: boolean) => void;
deselectAll: () => void;
}
interface PasteArgs {
dispatch: AppDispatch;
dashboardId: string;
expandedSessionIds: string[];
selection: PasteTargets;
/** Canvas-space drop point; without it the copies land offset from their originals. */
at?: { x: number; y: number };
}
export async function pasteClipboardCards({ dispatch, dashboardId, expandedSessionIds, selection, at }: PasteArgs): Promise<void> {
const copied = getClipboardCards();
if (copied.length === 0) return;
// Right-click paste anchors the whole group at the cursor by shifting off the first card's corner.
const anchorX = copied[0].x;
const anchorY = copied[0].y;
selection.deselectAll();
const newSelection = new Map<string, CardType>();
for (const card of copied) {
const px = at ? at.x + (card.x - anchorX) : card.x + PASTE_OFFSET;
const py = at ? at.y + (card.y - anchorY) : card.y - PASTE_OFFSET;
if (card.type === 'agent') {
const action = await dispatch(duplicateSession({ sessionId: card.id, dashboardId }));
if (duplicateSession.fulfilled.match(action)) {
const newId = action.payload.id;
dispatch(placeCard({ sessionId: newId, x: px, y: py, width: card.width, height: card.height, expandedSessionIds }));
if (card.expanded) dispatch(expandSession(newId));
newSelection.set(newId, 'agent');
}
} else if (card.type === 'view') {
// Pasting an app whose card is already open creates a NEW independent instance (own runtime + ports) instead of no-op'ing.
const outputId = card.id.split('#')[0];
dispatch(addViewCard({ outputId, expandedSessionIds, x: px, y: py, width: card.width, height: card.height, newInstance: true }));
const viewCards = store.getState().dashboardLayout.viewCards;
let pastedKey = outputId;
for (const [key, vc] of Object.entries(viewCards)) {
if (vc.output_id === outputId && (vc.instance ?? 1) >= (viewCards[pastedKey]?.instance ?? 1)) pastedKey = key;
}
newSelection.set(pastedKey, 'view');
} else if (card.type === 'browser') {
const browserId = `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
dispatch(pasteBrowserCard({
id: browserId, tabs: card.meta.tabs || [], url: card.meta.url || '',
x: px, y: py, width: card.width, height: card.height,
}));
newSelection.set(browserId, 'browser');
}
}
for (const [id, type] of newSelection) selection.selectCard(id, type, true);
}
@@ -1,22 +1,15 @@
import { useEffect } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import {
duplicateSession,
expandSession,
type AgentSession,
} from '@/shared/state/agentsSlice';
import {
addViewCard,
pasteBrowserCard,
placeCard,
type CardPosition,
type ViewCardPosition,
type BrowserCardPosition,
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
ViewCardPosition,
BrowserCardPosition,
} from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
import { store } from '@/shared/state/store';
import { setClipboardCards, getClipboardCards, type ClipboardCard } from '@/shared/dashboardClipboard';
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
import { pasteClipboardCards } from './pasteClipboardCards';
import type { useDashboardSelection } from '../state/useDashboardSelection';
type Selection = ReturnType<typeof useDashboardSelection>;
@@ -102,68 +95,16 @@ export function useDashboardClipboard({
}, [selection.selectedIds, sessions, cards, viewCards, browserCards, outputs, expandedSessionIds]);
useEffect(() => {
const PASTE_OFFSET = 40;
const handlePaste = async (e: KeyboardEvent) => {
const handlePaste = (e: KeyboardEvent) => {
if (!isActive) return; // Don't fire shortcuts when dashboard is hidden
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'v') return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
const copied = getClipboardCards();
if (copied.length === 0) return;
if (getClipboardCards().length === 0) return;
e.preventDefault();
selection.deselectAll();
const newSelection = new Map<string, CardType>();
for (const card of copied) {
const px = card.x + PASTE_OFFSET;
const py = card.y - PASTE_OFFSET;
if (card.type === 'agent') {
const action = await dispatch(duplicateSession({ sessionId: card.id, dashboardId }));
if (duplicateSession.fulfilled.match(action)) {
const newId = action.payload.id;
dispatch(placeCard({
sessionId: newId,
x: px,
y: py,
width: card.width,
height: card.height,
expandedSessionIds,
}));
if (card.expanded) {
dispatch(expandSession(newId));
}
newSelection.set(newId, 'agent');
}
} else if (card.type === 'view') {
// Pasting an app whose card is already open creates a NEW independent instance (own runtime + ports) instead of no-op'ing.
const outputId = card.id.split('#')[0];
dispatch(addViewCard({ outputId, expandedSessionIds, x: px, y: py, width: card.width, height: card.height, newInstance: true }));
const viewCards = store.getState().dashboardLayout.viewCards;
let pastedKey = outputId;
for (const [key, vc] of Object.entries(viewCards)) {
if (vc.output_id === outputId && (vc.instance ?? 1) >= (viewCards[pastedKey]?.instance ?? 1)) pastedKey = key;
}
newSelection.set(pastedKey, 'view');
} else if (card.type === 'browser') {
const browserId = `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
dispatch(pasteBrowserCard({
id: browserId, tabs: card.meta.tabs || [], url: card.meta.url || '',
x: px, y: py, width: card.width, height: card.height,
}));
newSelection.set(browserId, 'browser');
}
}
if (newSelection.size > 0) {
for (const [id, type] of newSelection) {
selection.selectCard(id, type, true);
}
}
void pasteClipboardCards({ dispatch, dashboardId, expandedSessionIds, selection });
};
window.addEventListener('keydown', handlePaste);
return () => window.removeEventListener('keydown', handlePaste);
}, [dispatch, dashboardId, expandedSessionIds, selection]);
}, [dispatch, dashboardId, expandedSessionIds, selection, isActive]);
}
@@ -31,6 +31,7 @@ interface Props {
historyQuery: string;
onHistoryQueryChange: (q: string) => void;
onHistorySelect: (id: string) => void;
onHistoryContextMenu?: (e: React.MouseEvent, entry: { id: string; name: string }) => void;
onNewChat: () => void;
onWorkflowSelect: (id: string) => void;
onExpand: () => void;
@@ -50,7 +51,7 @@ interface Props {
export default function SchedulePopover({
mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange,
onHistorySelect, onNewChat, onWorkflowSelect, onExpand,
onHistorySelect, onHistoryContextMenu, onNewChat, onWorkflowSelect, onExpand,
allRuns, allRunsLoading, onRunOpen, workflowTitleFor,
historyScrollRef, onHistoryScroll,
hideTopChrome = false,
@@ -98,12 +99,9 @@ export default function SchedulePopover({
// Both Search and Schedule modes render at the same fixed dimensions so toggling chips doesn't resize the popover. Schedule sets the floor: its 7-day calendar needs ~620w x ~420h, search inherits the same.
const POPOVER_W = 620;
const CONTENT_H = 420;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', width: POPOVER_W, maxWidth: POPOVER_W, gap: 0.75, flexShrink: 0 }}>
{/* Floating mode chips. Hidden when the parent toolbar supplies its
own pill row (Image #32 / #54); kept around so the legacy callers
that surface Schedule mode still have a way in. */}
{/* Floating mode chips. Hidden when the parent toolbar supplies its own pill row; kept for legacy callers that surface Schedule mode. */}
{!hideTopChrome && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 0.5 }}>
<ModeChip label="Search" icon={<SearchIcon sx={{ fontSize: 14 }} />} active={mode === 'search'} onClick={() => onModeChange('search')} />
@@ -175,7 +173,7 @@ export default function SchedulePopover({
{bucket !== prevBucket && (
<Typography sx={{ px: 1, pt: idx === 0 ? 0.75 : 1.5, pb: 0.5, fontSize: '0.6563rem', fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.text.ghost }}>{bucket}</Typography>
)}
<Box onClick={() => onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 0.8, cursor: 'pointer', borderRadius: `${c.radius.md}px`, '&:hover': { bgcolor: c.bg.elevated } }}>
<Box onClick={() => onHistorySelect(entry.id)} onContextMenu={(e: React.MouseEvent) => onHistoryContextMenu?.(e, entry)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 0.8, cursor: 'pointer', borderRadius: `${c.radius.md}px`, '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '7px', bgcolor: c.bg.elevated, color: c.text.muted, flexShrink: 0 }}>
<ChatBubbleOutlineIcon sx={{ fontSize: 13 }} />
</Box>