From 19926b5dead5bd29630c6bf2ae9121e192591d8a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 6 Aug 2026 12:34:29 -0700 Subject: [PATCH] [eric] shortcuts: Cmd+T new tab or browser, Cmd+W closes the focused card undoably, routed through main like Cmd+R --- electron/main.js | 18 +++++++++++++++++ electron/preload.js | 14 +++++++++++++ .../Dashboard/canvas/DashboardCanvas.tsx | 20 ++++++++++++++++++- frontend/src/types/electron.d.ts | 2 ++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/electron/main.js b/electron/main.js index e6057a11..869ddfb5 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2234,9 +2234,26 @@ function swallowCloseWindowShortcut(event, input) { (input.key || '').toLowerCase() === 'w' ) { event.preventDefault(); + // Arc semantics: the swallowed close becomes "close the focused card" in the renderer (undoable via Cmd+Z). + if (!input.shift) { + try { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:close-shortcut'); + } catch (_) {} + } } } +// Cmd/Ctrl+T: new tab in the last-interacted browser, or a new browser card (Arc muscle memory). +function routeNewTabShortcut(event, input) { + if (input.type !== 'keyDown') return; + if (!(input.meta || input.control) || input.shift || input.alt) return; + if ((input.key || '').toLowerCase() !== 't') return; + event.preventDefault(); + try { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:newtab-shortcut'); + } 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; @@ -2398,6 +2415,7 @@ app.on('web-contents-created', (_event, contents) => { if (isCreatingMainWindow || contents.getType() === 'webview') { contents.on('before-input-event', swallowCloseWindowShortcut); contents.on('before-input-event', routeReloadShortcut); + contents.on('before-input-event', routeNewTabShortcut); } // 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 1f0c1389..82cbf4dd 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -188,6 +188,20 @@ contextBridge.exposeInMainWorld('openswarm', { return () => ipcRenderer.removeListener('openswarm:reload-shortcut', listener); }, + // Cmd/Ctrl+W with the window-close swallowed in main: the renderer closes the focused card instead. + onCloseShortcut: (cb) => { + const listener = () => cb(); + ipcRenderer.on('openswarm:close-shortcut', listener); + return () => ipcRenderer.removeListener('openswarm:close-shortcut', listener); + }, + + // Cmd/Ctrl+T: new tab in the last-interacted browser, else a new browser card. + onNewTabShortcut: (cb) => { + const listener = () => cb(); + ipcRenderer.on('openswarm:newtab-shortcut', listener); + return () => ipcRenderer.removeListener('openswarm:newtab-shortcut', listener); + }, + // In-page browser shortcuts (zoom/find/tab-cycle) from a focused guest webview, carrying the guest's webContents id so the renderer targets that exact browser. onBrowserShortcut: (cb) => { const listener = (_event, payload) => cb(payload); diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index e98a1642..5a3a04da 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -1,7 +1,7 @@ import React, { useEffect, type RefObject } from 'react'; import Box from '@mui/material/Box'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { addViewCard, clearTiledCard, toggleMinimizeCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice'; +import { addViewCard, addBrowserTab, clearTiledCard, toggleMinimizeCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice'; import DashboardHeader from './DashboardHeader'; import TetherLayerHost from './TetherLayerHost'; import { useLiveMultiDrag } from '../hooks/interaction/useLiveMultiDrag'; @@ -33,6 +33,7 @@ import type { CardType, useDashboardSelection } from '../hooks/state/useDashboar import type { useCanvasControls } from '../hooks/interaction/useCanvasControls'; import { useWebviewSuspend } from '../hooks/interaction/useWebviewSuspend'; import { deleteSelectedCards } from '../hooks/interaction/deleteSelectedCards'; +import { getLastInteractedBrowser } from '@/shared/browserFocus'; import type { TetherInputs } from '../geometry/dashboardTethers'; type Selection = ReturnType; @@ -291,6 +292,23 @@ const DashboardCanvas: React.FC = ({ if (newestDeletable) deleteSelectedCards(new Map([[newestDeletable.id, newestDeletable.type]]), dispatch); }, [selection, dispatch, newestDeletable]); + // Cmd/Ctrl+W and Cmd/Ctrl+T arrive as IPC echoes: main preventDefaults both before any DOM keydown + // (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 offs: Array<() => void> = []; + if (w.openswarm?.onCloseShortcut) offs.push(w.openswarm.onCloseShortcut(() => handleDeleteSelected())); + if (w.openswarm?.onNewTabShortcut) { + offs.push(w.openswarm.onNewTabShortcut(() => { + const browserId = getLastInteractedBrowser(); + if (browserId && browserCards[browserId]) dispatch(addBrowserTab({ browserId, url: browserHomepage, makeActive: true })); + else onAddBrowser(); + })); + } + return () => { offs.forEach((off) => off()); }; + }, [handleDeleteSelected, browserCards, browserHomepage, dispatch, onAddBrowser]); + // 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(() => { canvas.actions.syncTransform(); diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 290b3419..cecea20d 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -76,6 +76,8 @@ declare global { onUpdateError: (cb: (message: string) => void) => () => void; onWebviewNewWindow: (cb: (url: string, webContentsId: number, disposition?: string) => void) => () => void; onReloadShortcut?: (cb: () => void) => () => void; + onCloseShortcut?: (cb: () => void) => () => void; + onNewTabShortcut?: (cb: () => void) => () => void; onBrowserShortcut?: (cb: (payload: { action: string; webContentsId: number }) => void) => () => void; openExternal: (url: string) => Promise; harvestUsage?: (provider: string) => Promise<{ ok: boolean; total: number; titles: string[]; memories: string[] } | null>;