diff --git a/electron/main.js b/electron/main.js index 1c48b9d5..fc69b505 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2254,6 +2254,18 @@ function routeNewTabShortcut(event, input) { } catch (_) {} } +// Cmd/Ctrl+1..9: focus the Nth dock tile, Arc-style. Routed through main so it works from a focused webview too. +function routeDockShortcut(event, input) { + if (input.type !== 'keyDown') return; + if (!(input.meta || input.control) || input.shift || input.alt) return; + const key = input.key || ''; + if (key < '1' || key > '9' || key.length !== 1) return; + event.preventDefault(); + try { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:dock-shortcut', Number(key) - 1); + } catch (_) {} +} + // Cmd/Ctrl+R: the default menu's Reload accelerator reloads the WHOLE app even when a browser webview is focused (the "Ctrl+R reloads OpenSwarm, not the browser" complaint). preventDefault kills that accelerator (same electron#19279 path as Cmd+W, dispatched against whichever webContents is focused, hence both main window AND guests); the renderer then reloads the last-interacted browser, or the app if none. Shift+R (force reload) is left alone. function routeReloadShortcut(event, input) { if (input.type !== 'keyDown') return; @@ -2458,6 +2470,7 @@ app.on('web-contents-created', (_event, contents) => { contents.on('before-input-event', swallowCloseWindowShortcut); contents.on('before-input-event', routeReloadShortcut); contents.on('before-input-event', routeNewTabShortcut); + contents.on('before-input-event', routeDockShortcut); } // The main app window (created while this flag is set) gets a text-focused native menu; OAuth // popups are 'window' contents created with the flag OFF, so they keep the OS default. diff --git a/electron/preload.js b/electron/preload.js index df9a07ab..978d18bb 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -92,6 +92,12 @@ contextBridge.exposeInMainWorld('openswarm', { revealBundle: (folderPath) => ipcRenderer.invoke('help:reveal-bundle', folderPath), // Reveal a user-attached file in Finder/Explorer (reveal-only; main checks existence). revealPath: (filePath) => ipcRenderer.invoke('files:reveal', filePath), + // Cmd/Ctrl+1..9: focus the Nth dock tile (0-based index arrives here). + onDockShortcut: (cb) => { + const listener = (_event, index) => cb(index); + ipcRenderer.on('openswarm:dock-shortcut', listener); + return () => ipcRenderer.removeListener('openswarm:dock-shortcut', listener); + }, // Native OS notification for a finished workflow run, posted by the MAIN process // so it survives a minimized/hidden/backgrounded renderer (the renderer's own diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 2eb51e53..6e9a5866 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -2,6 +2,8 @@ import React, { useEffect, type RefObject } from 'react'; import Box from '@mui/material/Box'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { addViewCard, addBrowserTab, clearTiledCard, toggleMinimizeCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice'; +import { store } from '@/shared/state/store'; +import { buildDockEntries } from '../desktop/dockEntries'; import DashboardHeader from './DashboardHeader'; import TetherLayerHost from './TetherLayerHost'; import { useLiveMultiDrag } from '../hooks/interaction/useLiveMultiDrag'; @@ -296,7 +298,7 @@ const DashboardCanvas: React.FC = ({ // (including from focused guests), so these bridges are the only firing path, no double-handling. const browserHomepage = useAppSelector((st) => st.settings.data.browser_homepage ?? 'https://www.google.com'); React.useEffect(() => { - const w = window as unknown as { openswarm?: { onCloseShortcut?: (cb: () => void) => () => void; onNewTabShortcut?: (cb: () => void) => () => void } }; + const w = window as unknown as { openswarm?: { onCloseShortcut?: (cb: () => void) => () => void; onNewTabShortcut?: (cb: () => void) => () => void; onDockShortcut?: (cb: (index: number) => void) => () => void } }; const offs: Array<() => void> = []; if (w.openswarm?.onCloseShortcut) offs.push(w.openswarm.onCloseShortcut(() => handleDeleteSelected())); if (w.openswarm?.onNewTabShortcut) { @@ -306,8 +308,16 @@ const DashboardCanvas: React.FC = ({ else onAddBrowser(); })); } + if (w.openswarm?.onDockShortcut) { + offs.push(w.openswarm.onDockShortcut((index: number) => { + // Same order the dock draws, so Cmd+N matches what the user sees top-to-bottom. + const entries = buildDockEntries({ sessions: store.getState().agents.sessions, cards, viewCards, browserCards, workflowCards, outputs }); + const entry = entries[index]; + if (entry) handleFocusCard(entry.id, entry.rect); + })); + } return () => { offs.forEach((off) => off()); }; - }, [handleDeleteSelected, browserCards, browserHomepage, dispatch, onAddBrowser]); + }, [handleDeleteSelected, browserCards, browserHomepage, dispatch, onAddBrowser, cards, viewCards, workflowCards, outputs, handleFocusCard]); // Gestures write the transform imperatively (no React commit per frame), so a foreign render mid-gesture would paint the stale committed transform for a frame. Re-applying live after EVERY render seals that; do not remove. React.useLayoutEffect(() => { diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index d2d3a058..27385e55 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -102,6 +102,7 @@ declare global { notify?: (payload: OpenSwarmNotifyRequest) => Promise; onNotificationAction?: (cb: (payload: OpenSwarmNotifyAction) => void) => () => void; revealPath?: (filePath: string) => Promise<{ ok: boolean }>; + onDockShortcut?: (cb: (index: number) => void) => () => void; } interface Window {