[eric] canvas: dragging a card is compositor-only per frame, and the shell's heavy children bail instead of re-rendering per move

This commit is contained in:
ciregenz
2026-08-03 21:23:36 -07:00
parent 0826b72686
commit 0d98a09cda
7 changed files with 40 additions and 26 deletions
@@ -221,6 +221,24 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
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<DashboardCanvasProps> = ({
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<DashboardCanvasProps> = ({
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<DashboardCanvasProps> = ({
onNewAgentBounceEnd={onNewAgentBounceEnd}
onFitToView={onFitToView}
onTidy={onTidy}
onDeleteSelected={() => {
deleteSelectedCards(selection.selectedIds, dispatch);
selection.deselectAll();
}}
onDeleteSelected={handleDeleteSelected}
hasSelection={selection.selectedIds.size > 0}
onSearchPaletteClose={onSearchPaletteClose}
toolbarPrefill={toolbarPrefill}
@@ -255,4 +255,4 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
);
};
export default DashboardCardLayer;
export default React.memo(DashboardCardLayer);
@@ -193,4 +193,4 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
);
};
export default DashboardOverlays;
export default React.memo(DashboardOverlays);
@@ -486,7 +486,9 @@ const AgentCard: React.FC<Props> = ({
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<Props> = ({
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<Props> = ({
// 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;
@@ -261,4 +261,4 @@ function DesktopDock({
);
}
export default DesktopDock;
export default React.memo(DesktopDock);
@@ -122,4 +122,4 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto
);
}
export default MinimizedStack;
export default React.memo(MinimizedStack);
@@ -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]);
}