From 9b698307170a02b0c7084a13faf6ff53bc0c5c35 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 4 Aug 2026 00:43:03 -0700 Subject: [PATCH] [eric] canvas: drag frames stop re-rendering the page, only the tether layer follows the pointer --- .../Dashboard/canvas/DashboardCanvas.tsx | 12 ++++++------ .../Dashboard/canvas/TetherLayerHost.tsx | 17 +++++++++++++++++ .../Dashboard/geometry/dashboardTethers.ts | 8 +++----- .../hooks/interaction/liveDragChannel.ts | 19 +++++++++++++++++++ .../hooks/interaction/useCardDrag.ts | 7 +++---- .../hooks/state/useDashboardController.ts | 12 ++++++------ 6 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx create mode 100644 frontend/src/app/pages/Dashboard/hooks/interaction/liveDragChannel.ts diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 85ffe600..a0b73ead 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -3,7 +3,7 @@ import Box from '@mui/material/Box'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { addViewCard, clearTiledCard, toggleMinimizeCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice'; import DashboardHeader from './DashboardHeader'; -import TetherLayer from './TetherLayer'; +import TetherLayerHost from './TetherLayerHost'; import DashboardCardLayer from './DashboardCardLayer'; import DashboardOverlays from './DashboardOverlays'; import CardContextMenu from '../desktop/CardContextMenu'; @@ -30,7 +30,7 @@ import type { CardType, useDashboardSelection } from '../hooks/state/useDashboar import type { useCanvasControls } from '../hooks/interaction/useCanvasControls'; import { useWebviewSuspend } from '../hooks/interaction/useWebviewSuspend'; import { deleteSelectedCards } from '../hooks/interaction/deleteSelectedCards'; -import type { Tether } from '../geometry/dashboardTethers'; +import type { TetherInputs } from '../geometry/dashboardTethers'; type Selection = ReturnType; type Canvas = ReturnType; @@ -56,7 +56,7 @@ interface DashboardCanvasProps { outputs: Record; glowingAgentCards: Record; expandedSessionIds: string[]; - tethers: Tether[]; + tetherInputs: TetherInputs; highlightedCardId: string | null; autoFocusSessionId: string | null; focusedCardId: string | null; @@ -118,7 +118,7 @@ const DashboardCanvas: React.FC = ({ outputs, glowingAgentCards, expandedSessionIds, - tethers, + tetherInputs, highlightedCardId, autoFocusSessionId, focusedCardId, @@ -407,8 +407,8 @@ const DashboardCanvas: React.FC = ({ position: 'relative', }} > - {/* Tether lines between branched cards */} - + {/* Tether lines between branched cards; the host alone re-renders on drag frames */} + = ({ inputs, c }) => { + const [liveDrag, setLiveDrag] = useState(null); + useEffect(() => subscribeLiveDrag(setLiveDrag), []); + const tethers = useTethers(inputs, liveDrag); + return ; +}; + +export default React.memo(TetherLayerHost); diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 438268ef..51004774 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -29,7 +29,7 @@ interface GlowingBrowserCard { label?: string; } -interface LiveDragInfo { +export interface LiveDragInfo { cardId: string; dx: number; dy: number; @@ -73,7 +73,7 @@ function rectCenter(r: CanvasRect): { x: number; y: number } { return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; } -interface UseTethersArgs { +export interface TetherInputs { glowingAgentCards: Record; glowingBrowserCards: Record; cards: Record; @@ -84,7 +84,6 @@ interface UseTethersArgs { viewCards: Record; outputs: Record; expandedSessionIds: string[]; - liveDragInfo: LiveDragInfo | null; measuredHeightsRef: RefObject>; measuredHeightsTick: number; sessionList: AgentSession[]; @@ -106,7 +105,6 @@ export function useTethers({ viewCards, outputs, expandedSessionIds, - liveDragInfo, measuredHeightsRef, measuredHeightsTick, sessionList, @@ -114,7 +112,7 @@ export function useTethers({ workflowsMonitorCard, workflowsMonitorLabel, monitorRunSessionId, -}: UseTethersArgs): Tether[] { +}: TetherInputs, liveDragInfo: LiveDragInfo | null): Tether[] { return useMemo(() => { const sessionById = new Map(sessionList.map((s) => [s.id, s])); const expandedSet = new Set(expandedSessionIds); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/liveDragChannel.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/liveDragChannel.ts new file mode 100644 index 00000000..e340e3e4 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/liveDragChannel.ts @@ -0,0 +1,19 @@ +// Per-frame drag deltas travel OUTSIDE React: a drag move fires at pointer rate (120Hz on ProMotion), +// and holding this in page-level state re-rendered the whole dashboard per frame (the ENG-88 input +// delay). Publishers write here; the one consumer that must follow live (the tether layer) subscribes +// and re-renders alone. + +import type { LiveDragInfo } from '../../geometry/dashboardTethers'; + +type Listener = (info: LiveDragInfo | null) => void; + +const listeners = new Set(); + +export function publishLiveDrag(info: LiveDragInfo | null): void { + for (const listener of listeners) listener(info); +} + +export function subscribeLiveDrag(listener: Listener): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts index 4f9a4c17..16bb2d75 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts @@ -4,6 +4,7 @@ import { useAppDispatch } from '@/shared/hooks'; import { moveCards } from '@/shared/state/dashboardLayoutSlice'; import type { CardType, useDashboardSelection } from '../state/useDashboardSelection'; import type { CanvasActions } from './useCanvasControls'; +import { publishLiveDrag } from './liveDragChannel'; type Selection = ReturnType; @@ -33,7 +34,6 @@ export function useCardDrag({ const dispatch = useAppDispatch(); const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null); - const [liveDragInfo, setLiveDragInfo] = useState<{ cardId: string; dx: number; dy: number } | null>(null); const activeDragCardRef = useRef(null); const isMultiDragRef = useRef(false); @@ -100,7 +100,7 @@ export function useCardDrag({ setMultiDragDelta({ dx, dy }); } if (activeDragCardRef.current) { - setLiveDragInfo({ cardId: activeDragCardRef.current, dx, dy }); + publishLiveDrag({ cardId: activeDragCardRef.current, dx, dy }); } }, [tickEdgePan]); @@ -112,7 +112,7 @@ export function useCardDrag({ document.body.classList.remove('dashboard-marquee-active'); isMultiDragRef.current = false; setMultiDragDelta(null); - setLiveDragInfo(null); + publishLiveDrag(null); }, [stopEdgePan, canvasActions]); const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => { @@ -143,7 +143,6 @@ export function useCardDrag({ return { multiDragDelta, - liveDragInfo, handleCardDragStart, handleCardDragMove, handleCardDragEnd, diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 0e076678..2c516c58 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -9,7 +9,7 @@ import { getCardRect } from '../../geometry/getCardRect'; import { computeContentBounds } from '../../geometry/contentBounds'; import { useDashboardUiState } from './useDashboardUiState'; import { useLayoutSave } from './useLayoutSave'; -import { useTethers } from '../../geometry/dashboardTethers'; +import type { TetherInputs } from '../../geometry/dashboardTethers'; import { useArrowNav } from '../interaction/useArrowNav'; import { useDashboardShortcuts } from '../interaction/useDashboardShortcuts'; import { useDashboardClipboard } from '../interaction/useDashboardClipboard'; @@ -105,7 +105,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { const { multiDragDelta, - liveDragInfo, handleCardDragStart, handleCardDragMove, handleCardDragEnd, @@ -305,7 +304,9 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { measuredHeightsTick, }); - const tethers = useTethers({ + // Bundled for the canvas's TetherLayerHost, which re-renders ALONE on drag frames; holding drag + // state here re-rendered the whole page per pointer move (the ENG-88 input delay). + const tetherInputs = useMemo(() => ({ glowingAgentCards, glowingBrowserCards, cards, @@ -316,7 +317,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { viewCards, outputs, expandedSessionIds, - liveDragInfo, measuredHeightsRef, measuredHeightsTick, sessionList, @@ -324,13 +324,13 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { workflowsMonitorCard, workflowsMonitorLabel, monitorRunSessionId, - }); + }), [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, measuredHeightsRef, measuredHeightsTick, sessionList, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, monitorRunSessionId]); return { c, dashboardId, dashboardName, canvas, selection, sessions, sessionList, cards, viewCards, browserCards, keepAliveBrowserCards, outputs, glowingAgentCards, workflowCards, workflowsHub, - expandedSessionIds, tethers, highlightedCardId, autoFocusSessionId, + expandedSessionIds, tetherInputs, highlightedCardId, autoFocusSessionId, focusedCardId, multiDragDelta, shakeDirection, neighborDirections, toolbarOpen, searchPaletteOpen, newAgentBounce, canvasEmpty, toolbarRef, spawnOriginsRef, revealSpawnedRef, measuredHeightsRef, getCanvasState,