diff --git a/electron/main.js b/electron/main.js index efdf1d84..0dea74cd 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1990,6 +1990,27 @@ function routeReloadShortcut(event, input) { } catch (_) {} } +// In-page browser shortcuts (zoom, find, tab-cycle) for a focused guest. Keydowns inside a +// guest never reach the host renderer, so we catch them here and forward the intent + the guest's +// webContents id so the renderer can target that exact browser. Attached to guests ONLY: on the host +// the renderer's own keydown handles canvas-vs-browser, and intercepting there would eat canvas zoom. +function routeBrowserShortcut(event, input, webContentsId) { + if (input.type !== 'keyDown' || input.alt) return; + const mod = input.meta || input.control; + const key = (input.key || '').toLowerCase(); + let action = null; + if (mod && !input.shift && (key === '=' || key === '+')) action = 'zoom-in'; + else if (mod && !input.shift && key === '-') action = 'zoom-out'; + else if (mod && !input.shift && key === '0') action = 'zoom-reset'; + else if (mod && !input.shift && key === 'f') action = 'find'; + else if (input.control && !input.meta && key === 'tab') action = input.shift ? 'tab-prev' : 'tab-next'; + if (!action) return; + event.preventDefault(); + try { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:browser-shortcut', { action, webContentsId }); + } catch (_) {} +} + app.on('web-contents-created', (_event, contents) => { // Block Cmd+W from closing the main window, whether the window chrome or one of // its embedded webviews has focus. OAuth popups (their own 'window' contents, @@ -1999,6 +2020,10 @@ app.on('web-contents-created', (_event, contents) => { contents.on('before-input-event', swallowCloseWindowShortcut); contents.on('before-input-event', routeReloadShortcut); } + if (contents.getType() === 'webview') { + const wcId = contents.id; + contents.on('before-input-event', (event, input) => routeBrowserShortcut(event, input, wcId)); + } // Override the user-agent on popup BrowserWindows (i.e. anything created // via window.open from the renderer, which includes the OAuth popup for @@ -2033,7 +2058,7 @@ app.on('web-contents-created', (_event, contents) => { contents.setWindowOpenHandler(({ url, disposition }) => { if (disposition === 'foreground-tab' || disposition === 'background-tab') { if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('webview-new-window', url, contents.id); + mainWindow.webContents.send('webview-new-window', url, contents.id, disposition); } return { action: 'deny' }; } diff --git a/electron/preload.js b/electron/preload.js index ae70b4bd..a6f7d6d3 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -103,7 +103,7 @@ contextBridge.exposeInMainWorld('openswarm', { }, onWebviewNewWindow: (cb) => { - const listener = (_event, url, webContentsId) => cb(url, webContentsId); + const listener = (_event, url, webContentsId, disposition) => cb(url, webContentsId, disposition); ipcRenderer.on('webview-new-window', listener); return () => ipcRenderer.removeListener('webview-new-window', listener); }, @@ -115,6 +115,13 @@ contextBridge.exposeInMainWorld('openswarm', { return () => ipcRenderer.removeListener('openswarm:reload-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); + ipcRenderer.on('openswarm:browser-shortcut', listener); + return () => ipcRenderer.removeListener('openswarm:browser-shortcut', listener); + }, + // Deep-link callback: fires when the OS opens the app with an // openswarm://auth?token=... URL (after Stripe-hosted checkout). onAuthUrl: (cb) => { diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 954abf53..fab7d4b9 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; import { openSettingsModal } from '@/shared/state/settingsSlice'; import { getLastInteractedBrowser, getKeepAliveBrowserIds, setLastInteractedBrowser, clearLastInteractedBrowser } from '@/shared/browserFocus'; import { getWebview } from '@/shared/browserRegistry'; +import { applyBrowserZoom } from '@/shared/browserZoom'; import Box from '@mui/material/Box'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; @@ -42,7 +43,7 @@ import { shallowEqual } from 'react-redux'; import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice'; import { Typewriter } from '@/app/components/feedback/Animated'; import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; -import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice'; +import { addBrowserCard, addBrowserTab, cycleBrowserTab } from '@/shared/state/dashboardLayoutSlice'; import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice'; import { fetchOutputs } from '@/shared/state/outputsSlice'; import { setInstalling } from '@/shared/state/updateSlice'; @@ -256,13 +257,14 @@ const AppShell: React.FC = () => { }; }, []); - const openUrlInBrowser = useCallback((url: string, webContentsId?: number) => { + const openUrlInBrowser = useCallback((url: string, webContentsId?: number, background?: boolean) => { const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/); if (dashMatch) { if (webContentsId != null) { const browserId = findBrowserByWebContentsId(webContentsId); if (browserId) { - dispatch(addBrowserTab({ browserId, url, makeActive: true })); + // Middle-click / background-tab disposition: add the tab but don't steal focus from the current one, like a real browser. + dispatch(addBrowserTab({ browserId, url, makeActive: !background })); return; } } @@ -316,12 +318,12 @@ const AppShell: React.FC = () => { if (!w.openswarm?.onWebviewNewWindow) return; let lastUrl = ''; let lastTime = 0; - return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number) => { + return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number, disposition?: string) => { const now = Date.now(); if (url === lastUrl && now - lastTime < 1000) return; lastUrl = url; lastTime = now; - openUrlInBrowser(url, webContentsId); + openUrlInBrowser(url, webContentsId, disposition === 'background-tab'); }); }, [openUrlInBrowser]); @@ -349,6 +351,43 @@ const AppShell: React.FC = () => { }); }, []); + // Zoom / find / tab-cycle from a focused browser GUEST (keydowns inside a webview can't reach this document, so main forwards them with the guest's id). Targets that exact browser; the host-focused counterparts live in the keydown below + useCanvasControls (zoom). + useEffect(() => { + const w = window as any; + if (!w.openswarm?.onBrowserShortcut) return; + return w.openswarm.onBrowserShortcut((payload: { action: string; webContentsId: number }) => { + const id = findBrowserByWebContentsId(payload.webContentsId) ?? getLastInteractedBrowser(); + if (!id) return; + switch (payload.action) { + case 'zoom-in': applyBrowserZoom(id, 1); break; + case 'zoom-out': applyBrowserZoom(id, -1); break; + case 'zoom-reset': applyBrowserZoom(id, 0); break; + case 'find': window.dispatchEvent(new CustomEvent('openswarm:browser-find', { detail: { browserId: id } })); break; + case 'tab-next': dispatch(cycleBrowserTab({ browserId: id, dir: 1 })); break; + case 'tab-prev': dispatch(cycleBrowserTab({ browserId: id, dir: -1 })); break; + } + }); + }, [dispatch]); + + // Host-focused Ctrl/Cmd+F (find) and Ctrl+Tab (cycle) when a browser is the last thing you touched. Zoom keys aren't here: they share the +/-/0 keys with canvas zoom, so useCanvasControls owns that branch. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const id = getLastInteractedBrowser(); + if (!id) return; + const t = e.target as HTMLElement | null; + const typing = t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || !!t?.isContentEditable; + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && (e.key || '').toLowerCase() === 'f' && !typing) { + e.preventDefault(); + window.dispatchEvent(new CustomEvent('openswarm:browser-find', { detail: { browserId: id } })); + } else if (e.ctrlKey && !e.metaKey && !e.altKey && e.key === 'Tab') { + e.preventDefault(); + dispatch(cycleBrowserTab({ browserId: id, dir: e.shiftKey ? -1 : 1 })); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [dispatch]); + useEffect(() => { try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {} }, [sidebarWidth]); diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 8ab1a462..7dc37324 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -47,6 +47,7 @@ import { type BrowserWebview, } from '@/shared/browserRegistry'; import { setLastInteractedBrowser } from '@/shared/browserFocus'; +import BrowserFindBar from './BrowserFindBar'; import { useBrowserActivity } from '@/shared/useBrowserActivity'; import { getActionLabel } from '@/shared/browserCommandHandler'; import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl'; @@ -216,6 +217,9 @@ const BrowserCard: React.FC = ({ // Electron webviews can't trigger OS platform auth; preload sends "passkey-detected" and we explain via modal. const [passkeyDialogOpen, setPasskeyDialogOpen] = useState(false); const [crashedTabs, setCrashedTabs] = useState>(new Set()); + // Ctrl/Cmd+F find bar; focusSignal re-focuses the input each time Ctrl+F fires while it's already open. + const [findOpen, setFindOpen] = useState(false); + const [findFocusSignal, setFindFocusSignal] = useState(0); const updateTabLocal = useCallback((tabId: string, update: Partial) => { setTabLocalStates((prev) => { const existing = prev[tabId] ?? { loading: false, canGoBack: false, canGoForward: false }; @@ -244,6 +248,17 @@ const BrowserCard: React.FC = ({ setRegistryActiveTab(browserId, activeTabId); }, [browserId, activeTabId]); + // Open the find bar when AppShell routes a Ctrl/Cmd+F to this browser; re-trigger re-focuses the input. + useEffect(() => { + const onFind = (e: Event) => { + if ((e as CustomEvent).detail?.browserId !== browserId) return; + setFindOpen(true); + setFindFocusSignal((n) => n + 1); + }; + window.addEventListener('openswarm:browser-find', onFind as EventListener); + return () => window.removeEventListener('openswarm:browser-find', onFind as EventListener); + }, [browserId]); + // A resumed webview remounts at about:blank; dropping the init markers lets doLoad re-fire. useEffect(() => { if (suspendedSnap) initializedTabs.current.clear(); @@ -1097,6 +1112,9 @@ const BrowserCard: React.FC = ({ {/* Browser body: stacked webviews */} + {findOpen && !suspendedSnap && ( + setFindOpen(false)} /> + )} {isElementSelectMode && ( )} diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserFindBar.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserFindBar.tsx new file mode 100644 index 00000000..32c71e6c --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/BrowserFindBar.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import Box from '@mui/material/Box'; +import InputBase from '@mui/material/InputBase'; +import IconButton from '@mui/material/IconButton'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import CloseIcon from '@mui/icons-material/Close'; +import { getWebview } from '@/shared/browserRegistry'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +interface BrowserFindBarProps { + browserId: string; + focusSignal: number; + onClose: () => void; +} + +// In-page find (Ctrl/Cmd+F): drives the active tab's webview findInPage with a Chrome-style match counter + up/down/Enter nav, clearing the highlight on close. +export default function BrowserFindBar({ browserId, focusSignal, onClose }: BrowserFindBarProps): React.ReactElement { + const c = useClaudeTokens(); + const [query, setQuery] = useState(''); + const [result, setResult] = useState<{ active: number; total: number }>({ active: 0, total: 0 }); + const inputRef = useRef(null); + + // Fresh search omits findNext (passing findNext:false to a webview eats the found-in-page result, an Electron quirk); navigate=true does next/prev. + const search = useCallback((text: string, navigate: boolean, forward: boolean) => { + const wv = getWebview(browserId); + if (!wv) return; + try { + if (!text) { + wv.stopFindInPage('clearSelection'); + setResult({ active: 0, total: 0 }); + return; + } + if (navigate) wv.findInPage(text, { findNext: true, forward }); + else wv.findInPage(text); + } catch { + // torn-down webview; nothing to find + } + }, [browserId]); + + useEffect(() => { + const wv = getWebview(browserId); + if (!wv) return; + const onFound = (e: any) => { + const r = e?.result; + if (r && typeof r.matches === 'number') { + setResult({ active: r.activeMatchOrdinal ?? 0, total: r.matches }); + } + }; + wv.addEventListener('found-in-page', onFound as any); + return () => { + try { + wv.removeEventListener('found-in-page', onFound as any); + wv.stopFindInPage('clearSelection'); + } catch { + // webview already gone + } + }; + }, [browserId]); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, [focusSignal]); + + const onChange = (e: React.ChangeEvent) => { + const text = e.target.value; + setQuery(text); + search(text, false, true); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (query) search(query, true, !e.shiftKey); + } + }; + + return ( + e.stopPropagation()} + sx={{ + position: 'absolute', + top: 8, + right: 12, + zIndex: 30, + display: 'flex', + alignItems: 'center', + gap: 0.5, + px: 1, + py: 0.5, + bgcolor: c.bg.surface, + border: `1px solid ${c.border.medium}`, + borderRadius: `${c.radius.md}px`, + boxShadow: c.shadow.lg, + }} + > + + + {query ? `${result.active}/${result.total}` : ''} + + search(query, true, false)} sx={{ color: c.text.secondary }}> + + + search(query, true, true)} sx={{ color: c.text.secondary }}> + + + + + + + ); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 4030df46..9a1d4cd1 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -1,5 +1,8 @@ import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react'; import { setCanvasInteractionActive } from '@/shared/canvasInteractionState'; +import { getLastInteractedBrowser } from '@/shared/browserFocus'; +import { getWebview } from '@/shared/browserRegistry'; +import { applyBrowserZoom } from '@/shared/browserZoom'; const MIN_ZOOM = 0.15; const MAX_ZOOM = 3.0; @@ -497,15 +500,21 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: setCmdHeld(true); } if (e.ctrlKey || e.metaKey) { + // If the last thing you touched was a browser card, +/-/0 zooms THAT page (like a real browser); otherwise it zooms the dashboard canvas. + const focusedBrowser = getLastInteractedBrowser(); + const browserWv = focusedBrowser ? getWebview(focusedBrowser) : undefined; if (e.key === '0') { e.preventDefault(); - resetZoomRef.current(); + if (browserWv) applyBrowserZoom(focusedBrowser as string, 0); + else resetZoomRef.current(); } else if (e.key === '=' || e.key === '+') { e.preventDefault(); - zoomInRef.current(); + if (browserWv) applyBrowserZoom(focusedBrowser as string, 1); + else zoomInRef.current(); } else if (e.key === '-') { e.preventDefault(); - zoomOutRef.current(); + if (browserWv) applyBrowserZoom(focusedBrowser as string, -1); + else zoomOutRef.current(); } } }; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts index 4a2ff1a3..c292a8f0 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts @@ -6,6 +6,7 @@ import { removeNote, removeWorkflowCard, closeWorkflowsHub } from '@/shared/stat import { closeWorkflowCard } from '@/shared/state/workflowsSlice'; import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import { getLastInteractedBrowser } from '@/shared/browserFocus'; import type { useDashboardSelection } from '../state/useDashboardSelection'; type Selection = ReturnType; @@ -117,6 +118,8 @@ export function useDashboardShortcuts({ const handleSearch = (e: KeyboardEvent) => { if (!isActive) return; // Don't fire shortcuts when dashboard is hidden if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'f') return; + // When you're in a browser card, Cmd+F is find-in-page (handled in AppShell), not card search. + if (getLastInteractedBrowser()) return; const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return; e.preventDefault(); diff --git a/frontend/src/shared/browserRegistry.ts b/frontend/src/shared/browserRegistry.ts index 85ccb627..21e5967d 100644 --- a/frontend/src/shared/browserRegistry.ts +++ b/frontend/src/shared/browserRegistry.ts @@ -27,6 +27,10 @@ export interface BrowserWebview extends HTMLElement { executeJavaScript: (code: string) => Promise; sendInputEvent: (event: any) => void; getWebContentsId: () => number; + getZoomLevel: () => number; + setZoomLevel: (level: number) => void; + findInPage: (text: string, options?: { forward?: boolean; findNext?: boolean; matchCase?: boolean }) => number; + stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => void; addEventListener: (event: string, listener: (...args: any[]) => void, options?: boolean | AddEventListenerOptions) => void; removeEventListener: (event: string, listener: (...args: any[]) => void, options?: boolean | EventListenerOptions) => void; } diff --git a/frontend/src/shared/browserZoom.ts b/frontend/src/shared/browserZoom.ts new file mode 100644 index 00000000..cfe8dcba --- /dev/null +++ b/frontend/src/shared/browserZoom.ts @@ -0,0 +1,25 @@ +import { getWebview } from '@/shared/browserRegistry'; + +// Zoom the active-tab page of a browser card like a real browser (Ctrl/Cmd +/-/0), independent of the +// dashboard canvas zoom. dir: 1 = in, -1 = out, 0 = reset. Step + clamp must match wherever this is +// called so guest-focused and host-focused zoom never drift apart. Clamp ~0.5x..2.5x. +const ZOOM_STEP = 0.5; +const ZOOM_MIN = -3; +const ZOOM_MAX = 5; + +export function applyBrowserZoom(browserId: string, dir: -1 | 0 | 1): void { + const wv = getWebview(browserId); + if (!wv) return; + try { + if (dir === 0) { + wv.setZoomLevel(0); + return; + } + const raw = typeof wv.getZoomLevel === 'function' ? wv.getZoomLevel() : 0; + const current = typeof raw === 'number' && isFinite(raw) ? raw : 0; + const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, current + dir * ZOOM_STEP)); + wv.setZoomLevel(next); + } catch { + // torn-down webview; nothing to zoom + } +} diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index f56d8dfb..cb38c603 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -1094,6 +1094,21 @@ const dashboardLayoutSlice = createSlice({ } }, + // Ctrl+Tab / Ctrl+Shift+Tab: move to the next/previous tab, wrapping around. dir 1 = forward. + cycleBrowserTab( + state, + action: PayloadAction<{ browserId: string; dir: 1 | -1 }> + ) { + const card = state.browserCards[action.payload.browserId]; + if (!card || card.tabs.length < 2) return; + const idx = card.tabs.findIndex((t) => t.id === card.activeTabId); + if (idx === -1) return; + const n = card.tabs.length; + const next = card.tabs[(idx + action.payload.dir + n) % n]; + card.activeTabId = next.id; + card.url = next.url; + }, + updateBrowserTabUrl( state, action: PayloadAction<{ browserId: string; tabId: string; url: string }> @@ -1454,6 +1469,7 @@ export const { addBrowserTab, removeBrowserTab, setActiveBrowserTab, + cycleBrowserTab, updateBrowserTabUrl, updateBrowserTabTitle, updateBrowserTabFavicon, diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 8d8ba8cb..7148492a 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -46,8 +46,9 @@ declare global { onDownloadProgress: (cb: (progress: OpenSwarmDownloadProgress) => void) => () => void; onUpdateDownloaded: (cb: (info: OpenSwarmUpdateInfo) => void) => () => void; onUpdateError: (cb: (message: string) => void) => () => void; - onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void; + onWebviewNewWindow: (cb: (url: string, webContentsId: number, disposition?: string) => void) => () => void; onReloadShortcut?: (cb: () => void) => () => void; + onBrowserShortcut?: (cb: (payload: { action: string; webContentsId: number }) => void) => () => void; openExternal: (url: string) => Promise; hardReset?: () => Promise; clearBrowserData?: () => Promise<{ ok: boolean }>;