From 77b3595264041493a267bc89174feeb6e32e7f9c Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 15:13:32 -0700 Subject: [PATCH] [eric] context menus: portal the card menu to body, widen the row schema, cover every shell surface --- .../app/pages/Dashboard/DashboardToolbar.tsx | 24 +- .../Dashboard/canvas/DashboardCanvas.tsx | 21 +- .../Dashboard/canvas/DashboardOverlays.tsx | 2 - .../pages/Dashboard/canvas/canvasMenuRows.ts | 36 ++ .../Dashboard/canvas/useCanvasContextMenu.ts | 50 +++ .../app/pages/Dashboard/cards/AgentCard.tsx | 19 +- .../app/pages/Dashboard/cards/BrowserCard.tsx | 35 +- .../Dashboard/cards/DashboardViewCard.tsx | 33 +- .../Dashboard/cards/agentCardMenuRows.ts | 59 ++++ .../Dashboard/cards/browserCardMenuRows.ts | 97 ++++++ .../app/pages/Dashboard/cards/tileMenuRows.ts | 34 ++ .../pages/Dashboard/cards/viewCardMenuRows.ts | 61 ++++ .../Dashboard/desktop/CardContextMenu.tsx | 316 +++++++++++++----- .../pages/Dashboard/desktop/CardMenuPanel.tsx | 130 +++++++ .../pages/Dashboard/desktop/DesktopDock.tsx | 6 + .../Dashboard/desktop/MinimizedStack.tsx | 11 + .../pages/Dashboard/desktop/MinimizedTile.tsx | 4 +- .../src/app/pages/Dashboard/desktop/chord.ts | 16 + .../pages/Dashboard/desktop/dockEntries.tsx | 7 + .../Dashboard/desktop/dockTileMenuRows.ts | 27 ++ .../Dashboard/desktop/openCardContextMenu.ts | 64 ++++ .../hooks/interaction/pasteClipboardCards.ts | 67 ++++ .../interaction/useDashboardClipboard.ts | 81 +---- .../app/pages/Workflows/SchedulePopover.tsx | 10 +- 24 files changed, 998 insertions(+), 212 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/canvas/canvasMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts create mode 100644 frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/cards/browserCardMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/desktop/CardMenuPanel.tsx create mode 100644 frontend/src/app/pages/Dashboard/desktop/chord.ts create mode 100644 frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/desktop/openCardContextMenu.ts create mode 100644 frontend/src/app/pages/Dashboard/hooks/interaction/pasteClipboardCards.ts diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 6d12db4c..fbb7f528 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -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( 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( historyQuery={historyQuery} onHistoryQueryChange={setHistoryQuery} onHistorySelect={handleHistorySelect} + onHistoryContextMenu={handleHistoryContextMenu} onNewChat={() => { handleCloseHistory(); onNewAgent(); }} onWorkflowSelect={(wid) => { dispatch(openWorkflowsApp({ workflowId: wid })); diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 1dd3f047..0f2fe60f 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -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 = ({ 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 = ({ 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 = ({ toolbarPrefillMode={toolbarPrefillMode} /> + + {/* Sibling of everything: the menu used to live inside the help pill's z:10 box (so any card + brought to front painted over it) and inside the fullscreen display:none wrapper. */} + ); diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 868bb33f..fcd863d9 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -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 = ({ {!anyFullscreen && ( - )} diff --git a/frontend/src/app/pages/Dashboard/canvas/canvasMenuRows.ts b/frontend/src/app/pages/Dashboard/canvas/canvasMenuRows.ts new file mode 100644 index 00000000..fedb5a43 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/canvasMenuRows.ts @@ -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 }, + ]; +} diff --git a/frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts b/frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts new file mode 100644 index 00000000..7cdb9794 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts @@ -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; + canvasEmpty: boolean; + viewportRef: React.RefObject; + 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]); +} diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index cee6384c..c1edd867 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -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 = ({ 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 diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index db6029c4..c5928fad 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -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 = ({ 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 = ({ 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 = ({ openCardContextMenu(e, { + items: browserTabMenuRows({ browserId, dispatch, tab, tabCount: tabs.length, homepage: browserHomepage }), + })} onPointerDown={handleTabPointerDown} onPointerMove={handleTabPointerMove} onPointerUp={handleTabPointerUp} diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 1bbf07e6..e511e8ef 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -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 = ({ }; const [reloadMenuRect, setReloadMenuRect] = useState(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 = ({ 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 = ({ , document.body, )} + + {shareOpen && setShareOpen(false)} />} ); }; diff --git a/frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts new file mode 100644 index 00000000..c34e591a --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts @@ -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 })); } }, + ]; +} diff --git a/frontend/src/app/pages/Dashboard/cards/browserCardMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/browserCardMenuRows.ts new file mode 100644 index 00000000..385d332e --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/browserCardMenuRows.ts @@ -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 })); + }, + }, + ]; +} diff --git a/frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts new file mode 100644 index 00000000..cd0a9c5b --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts @@ -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 = { + 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; +} diff --git a/frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts new file mode 100644 index 00000000..9cc7f8d9 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts @@ -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))); + }, + }, + ]; +} diff --git a/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx b/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx index 6e73b91b..13ff4845 100644 --- a/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx @@ -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 : 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): 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(null); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(''); + const [rootPlacement, setRootPlacement] = useState(null); + const [shown, setShown] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + const [openIndex, setOpenIndex] = useState(null); + const [subActiveIndex, setSubActiveIndex] = useState(null); + const [subPlacement, setSubPlacement] = useState(null); + const rootRef = useRef(null); + const subRef = useRef(null); + const returnFocusTo = useRef(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 ( - 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 ? ( - ) => 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', - }} - /> - ) : ( - setRenaming(true)}> - Rename - - ) - )} - {menu.items.map((item) => ( - => ({ + 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( + e.preventDefault()} sx={{ position: 'fixed', inset: 0, zIndex: TOP_LAYER, pointerEvents: 'none' }}> + + { + 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 ? ( + ) => 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', + }} + /> + ) : ( + { 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 + + ))} + + + {submenu && ( + + runRow(submenu[i])} onHover={setSubActiveIndex} /> - ))} - + )} + , + document.body, ); } diff --git a/frontend/src/app/pages/Dashboard/desktop/CardMenuPanel.tsx b/frontend/src/app/pages/Dashboard/desktop/CardMenuPanel.tsx new file mode 100644 index 00000000..f8f1de38 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/CardMenuPanel.tsx @@ -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(function CardMenuPanel( + { items, activeIndex, openIndex, onActivate, onHover, width, children }, + ref, +) { + return ( + + {children} + {items.map((row, index) => { + if (row.kind === 'separator') { + return ; + } + if (row.kind === 'header') { + return ( + + {row.label} + + ); + } + 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 ( + 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 && ( + + )} + {row.icon !== undefined && ( + {row.icon} + )} + + {row.label} + + {row.shortcut && ( + + {row.shortcut} + + )} + {row.submenu && } + + ); + })} + + ); +}); + +export default CardMenuPanel; diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index 8e6f7997..5ef207be 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -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, diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx index ffaf6526..aeac4cda 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -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) }, + ], + })} /> ))} diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx index e9a38c59..e0222bd9 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx @@ -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 }: = { + 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 ? '' : '+'); +} diff --git a/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx b/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx index 7e6887ef..c8b05d07 100644 --- a/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx @@ -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)', diff --git a/frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts b/frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts new file mode 100644 index 00000000..b81bf079 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts @@ -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 })); + }, + }, + ]; +} diff --git a/frontend/src/app/pages/Dashboard/desktop/openCardContextMenu.ts b/frontend/src/app/pages/Dashboard/desktop/openCardContextMenu.ts new file mode 100644 index 00000000..3c5713dc --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/openCardContextMenu.ts @@ -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): void { + e.preventDefault(); + e.stopPropagation(); + window.dispatchEvent(new CustomEvent(CARD_MENU_EVENT, { detail: { ...req, x: e.clientX, y: e.clientY } })); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/pasteClipboardCards.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/pasteClipboardCards.ts new file mode 100644 index 00000000..1123db2f --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/pasteClipboardCards.ts @@ -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 { + 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(); + + 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); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts index d3f7e0b8..984bd89c 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts @@ -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; @@ -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(); - - 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]); } diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx index 48e8b6b4..5d83fc1a 100644 --- a/frontend/src/app/pages/Workflows/SchedulePopover.tsx +++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx @@ -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 ( - {/* 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 && ( } active={mode === 'search'} onClick={() => onModeChange('search')} /> @@ -175,7 +173,7 @@ export default function SchedulePopover({ {bucket !== prevBucket && ( {bucket} )} - 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 } }}> + 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 } }}>