diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 034b4341..6176dfe5 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -9,8 +9,8 @@ import RestartAltIcon from '@mui/icons-material/RestartAlt'; import CloseIcon from '@mui/icons-material/Close'; import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; import { Output, SERVE_BASE } from '@/shared/state/outputsSlice'; -import { setViewCardPosition, setViewCardSize, removeViewCard } from '@/shared/state/dashboardLayoutSlice'; -import { useAppDispatch } from '@/shared/hooks'; +import { setViewCardPosition, setViewCardSize, removeViewCard, setActiveViewCardId } from '@/shared/state/dashboardLayoutSlice'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { API_BASE, getAuthToken } from '@/shared/config'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import ViewPreview, { ViewPreviewHandle } from '@/app/pages/Views/ViewPreview'; @@ -75,6 +75,23 @@ const DashboardViewCard: React.FC = ({ const dispatch = useAppDispatch(); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); const previewRef = useRef(null); + const activeViewCardId = useAppSelector((s) => s.dashboardLayout.activeViewCardId); + const interactive = activeViewCardId === output.id; + + // Deselecting the card exits interact mode (click anywhere else on canvas). + useEffect(() => { + if (!isSelected && interactive) dispatch(setActiveViewCardId(null)); + }, [isSelected, interactive, dispatch]); + + // Escape exits interact mode. + useEffect(() => { + if (!interactive) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') dispatch(setActiveViewCardId(null)); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [interactive, dispatch]); const [inputData] = useState>(() => getDefault(output.input_schema)); const [backendResult] = useState | null>(null); @@ -288,7 +305,9 @@ const DashboardViewCard: React.FC = ({ borderRadius: `${c.radius.lg}px`, border: isHighlighted ? `2px solid ${c.accent.primary}` - : isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`, + : interactive + ? `2px solid ${c.accent.primary}` + : isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`, bgcolor: c.bg.surface, boxShadow: isHighlighted ? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15` @@ -406,6 +425,8 @@ const DashboardViewCard: React.FC = ({ output={output} inputData={inputData} backendResult={backendResult} + interactive={interactive} + onAppClicked={() => dispatch(setActiveViewCardId(output.id))} /> @@ -487,7 +508,9 @@ const DashboardOutputPreview: React.FC<{ output: Output; inputData: Record; backendResult: any; -}> = ({ previewRef, output, inputData, backendResult }) => { + interactive: boolean; + onAppClicked: () => void; +}> = ({ previewRef, output, inputData, backendResult, interactive, onAppClicked }) => { const tokens = useClaudeTokens(); const dispatch = useAppDispatch(); const workspaceId = output.workspace_id ?? null; @@ -587,6 +610,8 @@ const DashboardOutputPreview: React.FC<{ frontendCode={output.files?.['index.html'] ?? ''} inputData={inputData} backendResult={backendResult} + interactive={interactive} + onAppClicked={onAppClicked} /> ); }; diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index 59e31b6b..e38ca116 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -55,6 +55,10 @@ interface Props { onConsoleMessage?: (level: string, text: string) => void; /** Fires once the embedded app has actually painted, so cold-start placeholders don't unmount during the vite-ready to first-paint gap. */ onContentLoad?: () => void; + /** True when the user has clicked into the app; preload stops forwarding canvas gestures and lets the app handle all events. */ + interactive?: boolean; + /** Fired when the preload reports a mousedown inside the guest, so the host can flip the card into interactive mode. */ + onAppClicked?: () => void; } function buildSrcdoc( @@ -92,6 +96,8 @@ const ViewPreview = forwardRef(({ style, onConsoleMessage, onContentLoad, + interactive = false, + onAppClicked, }, ref) => { const iframeRef = useRef(null); const webviewRef = useRef(null); @@ -219,22 +225,72 @@ const ViewPreview = forwardRef(({ } }, [srcdoc, useWebview]); - // Forward webview-console events (preload wraps console.*) to onConsoleMessage; iframe path has no equivalent. + // Listen for preload IPC: console forwarding, canvas wheel forwarding + // (matches BrowserCard so apps share the same dashboard pan/zoom defaults), + // and the app-clicked notification that flips the card into interact mode. useEffect(() => { - if (!useWebview || !onConsoleMessage) return; + if (!useWebview) return; const wv = webviewRef.current; if (!wv) return; const handler = (e: any) => { - if (e?.channel !== 'webview-console') return; - const arg = Array.isArray(e.args) ? e.args[0] : undefined; - if (!arg) return; - onConsoleMessage(arg.level || 'log', arg.text || ''); + if (e?.channel === 'webview-console') { + if (!onConsoleMessage) return; + const arg = Array.isArray(e.args) ? e.args[0] : undefined; + if (!arg) return; + onConsoleMessage(arg.level || 'log', arg.text || ''); + return; + } + if (e?.channel === 'canvas-wheel-zoom') { + const payload = e.args?.[0] || {}; + const wvRect = wv.getBoundingClientRect(); + const fx = typeof payload.fracX === 'number' ? payload.fracX : 0.5; + const fy = typeof payload.fracY === 'number' ? payload.fracY : 0.5; + window.dispatchEvent( + new CustomEvent('openswarm:canvas-wheel-zoom', { + detail: { + deltaY: payload.deltaY ?? 0, + deltaMode: payload.deltaMode ?? 0, + clientX: wvRect.left + fx * wvRect.width, + clientY: wvRect.top + fy * wvRect.height, + }, + }), + ); + return; + } + if (e?.channel === 'canvas-wheel-pan') { + const payload = e.args?.[0] || {}; + window.dispatchEvent( + new CustomEvent('openswarm:canvas-wheel-pan', { + detail: { + deltaX: payload.deltaX ?? 0, + deltaY: payload.deltaY ?? 0, + deltaMode: payload.deltaMode ?? 0, + }, + }), + ); + return; + } + if (e?.channel === 'app-clicked') { + onAppClicked?.(); + return; + } }; wv.addEventListener?.('ipc-message', handler); return () => { try { wv.removeEventListener?.('ipc-message', handler); } catch (_e) {} }; - }, [useWebview, onConsoleMessage, iframeSrc]); + }, [useWebview, onConsoleMessage, onAppClicked, iframeSrc]); + + // Mirror `interactive` into a ref so the once-per-load did-finish-load + // listener can read the latest value when it pushes initial state. + const interactiveRef = useRef(interactive); + interactiveRef.current = interactive; + useEffect(() => { + if (!useWebview) return; + const wv = webviewRef.current; + if (!wv) return; + try { wv.send?.('openswarm:set-interactive', { interactive }); } catch (_e) {} + }, [useWebview, interactive]); // Webviews use did-finish-load instead of onLoad; did-fail-load retries with 500ms to 5s backoff (Vite may not have bound yet when frontend_url arrives). useEffect(() => { @@ -256,6 +312,7 @@ const ViewPreview = forwardRef(({ retryDelay = 500; cancelRetry(); handleNavigationLoad(); + try { wv.send?.('openswarm:set-interactive', { interactive: interactiveRef.current }); } catch (_) {} }; const onFail = (e: any) => { // Guard on isMainFrame (subresource 404s fire too) and ERR_ABORTED (user-cancel). diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index fee90e3e..f3534ebc 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -98,6 +98,8 @@ export interface DashboardLayoutState { suspendedBrowserCards: Record; /** Transient: spawned cards that are about to be removed; surfaces the fade + Keep pill. */ endingBrowserCards: Record; + /** Transient: id of the view card the user has clicked into; preload stops forwarding canvas gestures while set. */ + activeViewCardId: string | null; } const initialState: DashboardLayoutState = { @@ -116,6 +118,7 @@ const initialState: DashboardLayoutState = { pendingFocusNoteId: null, suspendedBrowserCards: {}, endingBrowserCards: {}, + activeViewCardId: null, }; interface LayoutPayload { @@ -565,6 +568,11 @@ const dashboardLayoutSlice = createSlice({ removeViewCard(state, action: PayloadAction) { delete state.viewCards[action.payload]; + if (state.activeViewCardId === action.payload) state.activeViewCardId = null; + }, + + setActiveViewCardId(state, action: PayloadAction) { + state.activeViewCardId = action.payload; }, addBrowserCard(state, action: PayloadAction<{ url: string; expandedSessionIds?: string[] }>) { @@ -1080,6 +1088,7 @@ export const { setViewCardPosition, setViewCardSize, removeViewCard, + setActiveViewCardId, addBrowserCard, addBrowserCardFromBackend, setBrowserCardPosition,