mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[eric] context menus: portal the card menu to body, widen the row schema, cover every shell surface
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user