From 5c9b29cd0e238c3fade81b51f89f6dcd3bb3b53e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 22 Jul 2026 13:58:42 -0700 Subject: [PATCH] [eric] dashboard: suspend app-card live previews when off-screen or too small (frees renderers, kills zoomed-out lag) --- .../Dashboard/cards/DashboardViewCard.tsx | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 6da0a007..9b18f12f 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -47,6 +47,15 @@ const CORNER_SIZE = 14; const MIN_W = 320; const MIN_H = 200; +// App-card preview suspend: a live Vite webview is the heaviest thing on the canvas, and unlike browser +// cards these never suspended, so 4 reveal apps + a zoomed-out canvas all ran live = jank. A built app +// has no login/scroll state worth keeping, so we just unmount the webview when the card is off-screen or +// too small to read, and remount (reload) on return. Asymmetric: resume instantly, suspend after a beat +// so panning past a card doesn't reload it. +const APP_PREVIEW_MIN_PX = 260; // below this on-screen width the live page is indistinguishable from a still +const APP_PREVIEW_MARGIN_PX = 400; // resume once the card is within this of the viewport +const APP_SUSPEND_SETTLE_MS = 1200; + const CURSOR_MAP: Record = { n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize', nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize', @@ -152,6 +161,63 @@ const DashboardViewCard: React.FC = ({ const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom); const isFullscreen = tileZone === 'fullscreen'; + // Keep the live preview mounted only when the user can actually see/use this app card. Always live + // when it's being interacted with, driven by an agent, tiled, or selected; otherwise gated on being + // on-screen at a readable size. A suspended card shows its last frame (or a calm placeholder). + const alwaysLive = interactive || isSelected || isFullscreen || !!tileZone || showAgentGlow; + const [previewLive, setPreviewLive] = useState(true); + const [suspendSnapshot, setSuspendSnapshot] = useState(null); + const previewLiveRef = useRef(true); + const suspendTimerRef = useRef(null); + useEffect(() => { + const evaluate = (): void => { + let want: boolean; + if (alwaysLive) { + want = true; + } else { + const now = getCanvasState(); + const vpEl = document.querySelector('[data-canvas-viewport]'); + const vpW = vpEl ? (vpEl as HTMLElement).clientWidth : window.innerWidth; + const vpH = vpEl ? (vpEl as HTMLElement).clientHeight : window.innerHeight; + if (cardWidth * now.zoom < APP_PREVIEW_MIN_PX) { + want = false; + } else { + const m = APP_PREVIEW_MARGIN_PX / now.zoom; + const vx = -now.panX / now.zoom - m; + const vy = -now.panY / now.zoom - m; + const vw = vpW / now.zoom + 2 * m; + const vh = vpH / now.zoom + 2 * m; + want = cardX < vx + vw && cardX + cardWidth > vx && cardY < vy + vh && cardY + cardHeight > vy; + } + } + if (want === previewLiveRef.current) return; + if (want) { + if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; } + previewLiveRef.current = true; + setSuspendSnapshot(null); + setPreviewLive(true); + } else if (!suspendTimerRef.current) { + suspendTimerRef.current = window.setTimeout(() => { + suspendTimerRef.current = null; + // Grab the last frame BEFORE unmounting so the parked card shows the app, not a blank box. + void (async () => { + try { const snap = await previewRef.current?.capture?.(); if (snap) setSuspendSnapshot(snap); } catch { /* no frame = calm placeholder */ } + previewLiveRef.current = false; + setPreviewLive(false); + })(); + }, APP_SUSPEND_SETTLE_MS); + } + }; + evaluate(); + window.addEventListener('openswarm:canvas-pan-changed', evaluate); + window.addEventListener('resize', evaluate); + return () => { + window.removeEventListener('openswarm:canvas-pan-changed', evaluate); + window.removeEventListener('resize', evaluate); + if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; } + }; + }, [alwaysLive, cardX, cardY, cardWidth, cardHeight, getCanvasState]); + // Deselecting the card exits interact mode (click anywhere else on canvas). useEffect(() => { if (!isSelected && interactive) dispatch(setActiveViewCardId(null)); @@ -685,6 +751,8 @@ const DashboardViewCard: React.FC = ({ inputData={inputData} backendResult={backendResult} interactive={interactive} + previewLive={previewLive} + suspendSnapshot={suspendSnapshot} onAppClicked={() => dispatch(setActiveViewCardId(cardKey))} onRuntimeLog={handleRuntimeLog} /> @@ -830,9 +898,11 @@ const DashboardOutputPreview: React.FC<{ inputData: Record; backendResult: any; interactive: boolean; + previewLive: boolean; + suspendSnapshot: string | null; onAppClicked: () => void; onRuntimeLog?: (line: RuntimeLogLine) => void; -}> = ({ previewRef, output, cardKey, instance = 1, inputData, backendResult, interactive, onAppClicked, onRuntimeLog }) => { +}> = ({ previewRef, output, cardKey, instance = 1, inputData, backendResult, interactive, previewLive, suspendSnapshot, onAppClicked, onRuntimeLog }) => { const tokens = useClaudeTokens(); const dispatch = useAppDispatch(); const workspaceId = output.workspace_id ?? null; @@ -936,6 +1006,22 @@ const DashboardOutputPreview: React.FC<{ return ; } + // Parked (off-screen or too small to read): show the last frame (or a calm placeholder) instead of a + // live webview, so a zoomed-out canvas of apps doesn't run every Vite preview at once. Resumes on view. + if (!previewLive) { + return ( + + {suspendSnapshot ? ( + + ) : ( + + {output.name || 'App'} + + )} + + ); + } + return (