From 0d98a09cda88d7b07687c07a8f1e24b7da66b18d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 3 Aug 2026 21:23:36 -0700 Subject: [PATCH] [eric] canvas: dragging a card is compositor-only per frame, and the shell's heavy children bail instead of re-rendering per move --- .../Dashboard/canvas/DashboardCanvas.tsx | 41 +++++++++++-------- .../Dashboard/canvas/DashboardCardLayer.tsx | 2 +- .../Dashboard/canvas/DashboardOverlays.tsx | 2 +- .../app/pages/Dashboard/cards/AgentCard.tsx | 10 ++++- .../pages/Dashboard/desktop/DesktopDock.tsx | 2 +- .../Dashboard/desktop/MinimizedStack.tsx | 2 +- .../hooks/state/useDashboardSelection.ts | 7 ++-- 7 files changed, 40 insertions(+), 26 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 032e51b1..85ffe600 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -221,6 +221,24 @@ const DashboardCanvas: React.FC = ({ return () => window.removeEventListener('mousemove', onMove); }, [fullscreenCardId]); + // Stable identities for the memoized shell children: an inline closure or Array.from() here would hand them a fresh prop every render, and a card drag re-renders this component per frame (liveDragInfo), which is exactly when they must bail. + const selectedIdList = React.useMemo(() => Array.from(selection.selectedIds.keys()) as string[], [selection.selectedIds]); + const handleRestoreCard = React.useCallback((cardId: string, rect: { x: number; y: number; width: number; height: number }) => { + canvas.actions.fitToCards([rect], 1.15, true); + onHighlightCard?.(cardId); + }, [canvas.actions, onHighlightCard]); + const handleFocusCard = React.useCallback((cardId: string, rect: { x: number; y: number; width: number; height: number }) => { + // A parked card sits off-canvas, so flying to its stored rect would land on empty space; unpark it first. + if (minimizedCards[cardId]) dispatch(toggleMinimizeCard({ cardId })); + canvas.actions.fitToCards([rect], 1.15, true); + onHighlightCard?.(cardId); + }, [minimizedCards, dispatch, canvas.actions, onHighlightCard]); + const handleToggleApps = React.useCallback(() => setAppsWindowOpen((v) => !v), []); + const handleDeleteSelected = React.useCallback(() => { + deleteSelectedCards(selection.selectedIds, dispatch); + selection.deselectAll(); + }, [selection, dispatch]); + // 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(); @@ -278,11 +296,8 @@ const DashboardCanvas: React.FC = ({ browserCards={browserCards} viewCards={viewCards} outputs={outputs} - selectedIds={Array.from(selection.selectedIds.keys())} - onRestore={(cardId, rect) => { - canvas.actions.fitToCards([rect], 1.15, true); - onHighlightCard?.(cardId); - }} + selectedIds={selectedIdList} + onRestore={handleRestoreCard} /> )} @@ -294,14 +309,9 @@ const DashboardCanvas: React.FC = ({ browserCards={browserCards} workflowCards={workflowCards} outputs={outputs} - selectedIds={Array.from(selection.selectedIds.keys())} - onFocusCard={(cardId, rect) => { - // A parked card sits off-canvas, so flying to its stored rect would land on empty space; unpark it first. - if (minimizedCards[cardId]) dispatch(toggleMinimizeCard({ cardId })); - canvas.actions.fitToCards([rect], 1.15, true); - onHighlightCard?.(cardId); - }} - onApplications={() => setAppsWindowOpen((v) => !v)} + selectedIds={selectedIdList} + onFocusCard={handleFocusCard} + onApplications={handleToggleApps} onAddBrowser={onAddBrowser} /> )} @@ -466,10 +476,7 @@ const DashboardCanvas: React.FC = ({ onNewAgentBounceEnd={onNewAgentBounceEnd} onFitToView={onFitToView} onTidy={onTidy} - onDeleteSelected={() => { - deleteSelectedCards(selection.selectedIds, dispatch); - selection.deselectAll(); - }} + onDeleteSelected={handleDeleteSelected} hasSelection={selection.selectedIds.size > 0} onSearchPaletteClose={onSearchPaletteClose} toolbarPrefill={toolbarPrefill} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index b6bf901b..88dbcf8a 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -255,4 +255,4 @@ const DashboardCardLayer: React.FC = ({ ); }; -export default DashboardCardLayer; +export default React.memo(DashboardCardLayer); diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 4302182c..9420a277 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -193,4 +193,4 @@ const DashboardOverlays: React.FC = ({ ); }; -export default DashboardOverlays; +export default React.memo(DashboardOverlays); diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index b881350a..9a70ad62 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -486,7 +486,9 @@ const AgentCard: React.FC = ({ const panDy = (cs.panY - ds.startPanY) / z; const dx = rawDx / z - panDx; const dy = rawDy / z - panDy; - setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy }); + // Imperative compositor move, ZERO React work per frame: a setState here re-rendered the whole card (an expanded chat re-runs its full transcript) on every pointermove and every edge-pan tick, which is the drag lag. The motion.div stays parked at its start position; this transform carries the delta; React commits once, at drag end. + const el = cardBoxRef.current as HTMLElement | null; + if (el) el.style.transform = `translate3d(${dx}px, ${dy}px, 0)`; onDragMove?.(dx, dy, clientX, clientY); }, [onDragMove, getCanvasState]); @@ -537,6 +539,9 @@ const AgentCard: React.FC = ({ justDraggedRef.current = true; requestAnimationFrame(() => { justDraggedRef.current = false; }); } + // Drop the imperative drag transform in the SAME frame the committed position lands, or the card paints double-offset for a beat. + const el = cardBoxRef.current as HTMLElement | null; + if (el) el.style.transform = ''; onDragEnd?.(dx, dy, didDrag.current); dragState.current = null; didDrag.current = false; @@ -727,7 +732,8 @@ const AgentCard: React.FC = ({ // f7's collapsed state: a session's browser (spawned by it or docked into it) shows under the pill. const browserShot = useBrowserPillShot(session.id, pillMode && !pillArtifact); - const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); + // justDraggedRef: the motion.div parks at the START position for the whole imperative drag, so the end-of-drag commit must snap (not spring) to the final spot or the card visibly re-glides from where the drag began. + const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta) || justDraggedRef.current; const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0; diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index ed7dbbd7..844ed142 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -261,4 +261,4 @@ function DesktopDock({ ); } -export default DesktopDock; +export default React.memo(DesktopDock); diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx index 7d4c7fa4..ee48ae23 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -122,4 +122,4 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto ); } -export default MinimizedStack; +export default React.memo(MinimizedStack); diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts index 5bda5ff6..70d179cb 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect, RefObject } from 'react'; +import { useState, useCallback, useMemo, useRef, useEffect, RefObject } from 'react'; import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice'; import { viewCardKey } from '@/shared/state/dashboardLayoutSlice'; @@ -289,7 +289,8 @@ export function useDashboardSelection( document.head.appendChild(style); }, []); - return { + // Stable identity: consumers (the memoized card layer, the drag hook) receive this whole object as a prop, and a fresh literal per render re-rendered them all on every controller commit, including each frame of a card drag. + return useMemo(() => ({ selectedIds, selectedArray, marquee, @@ -300,5 +301,5 @@ export function useDashboardSelection( handleCanvasMouseDown, handleCanvasMouseMove, handleCanvasMouseUp, - }; + }), [selectedIds, selectedArray, marquee, isSelected, selectCard, deselectAll, selectAll, handleCanvasMouseDown, handleCanvasMouseMove, handleCanvasMouseUp]); }