[eric] canvas: drag frames stop re-rendering the page, only the tether layer follows the pointer

This commit is contained in:
ciregenz
2026-08-04 00:43:03 -07:00
parent 3d171b6aa0
commit 9b69830717
6 changed files with 54 additions and 21 deletions
@@ -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<typeof useDashboardSelection>;
type Canvas = ReturnType<typeof useCanvasControls>;
@@ -56,7 +56,7 @@ interface DashboardCanvasProps {
outputs: Record<string, Output>;
glowingAgentCards: Record<string, GlowingAgentCard>;
expandedSessionIds: string[];
tethers: Tether[];
tetherInputs: TetherInputs;
highlightedCardId: string | null;
autoFocusSessionId: string | null;
focusedCardId: string | null;
@@ -118,7 +118,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
outputs,
glowingAgentCards,
expandedSessionIds,
tethers,
tetherInputs,
highlightedCardId,
autoFocusSessionId,
focusedCardId,
@@ -407,8 +407,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
position: 'relative',
}}
>
{/* Tether lines between branched cards */}
<TetherLayer tethers={tethers} c={c} />
{/* Tether lines between branched cards; the host alone re-renders on drag frames */}
<TetherLayerHost inputs={tetherInputs} c={c} />
<DashboardCardLayer
dashboardId={dashboardId}
cards={cards}
@@ -0,0 +1,17 @@
import React, { useEffect, useState } from 'react';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { useTethers, type TetherInputs, type LiveDragInfo } from '../geometry/dashboardTethers';
import { subscribeLiveDrag } from '../hooks/interaction/liveDragChannel';
import TetherLayer from './TetherLayer';
// The ONE component that re-renders per drag frame: it subscribes to the live-drag channel and
// recomputes just the tether SVG, so a 120Hz card drag costs a handful of paths instead of the
// whole dashboard tree (the ENG-88 input delay).
const TetherLayerHost: React.FC<{ inputs: TetherInputs; c: ClaudeTokens }> = ({ inputs, c }) => {
const [liveDrag, setLiveDrag] = useState<LiveDragInfo | null>(null);
useEffect(() => subscribeLiveDrag(setLiveDrag), []);
const tethers = useTethers(inputs, liveDrag);
return <TetherLayer tethers={tethers} c={c} />;
};
export default React.memo(TetherLayerHost);
@@ -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<string, GlowingAgentCard>;
glowingBrowserCards: Record<string, GlowingBrowserCard>;
cards: Record<string, CardPosition>;
@@ -84,7 +84,6 @@ interface UseTethersArgs {
viewCards: Record<string, ViewCardPosition>;
outputs: Record<string, Output>;
expandedSessionIds: string[];
liveDragInfo: LiveDragInfo | null;
measuredHeightsRef: RefObject<Record<string, number>>;
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);
@@ -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<Listener>();
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); };
}
@@ -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<typeof useDashboardSelection>;
@@ -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<string | null>(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,
@@ -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<TetherInputs>(() => ({
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,