From fd6338f9cbb86cd3ed4a5ef5977f571ef01450bc Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 4 Aug 2026 18:48:35 -0700 Subject: [PATCH] [eric] canvas: drags flag canvas interaction, idle-canvas drags skip tether work, the toolbar selector stops minting arrays --- .../pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx | 5 ++++- .../src/app/pages/Dashboard/canvas/TetherLayerHost.tsx | 7 ++++++- .../app/pages/Dashboard/hooks/interaction/useCardDrag.ts | 4 ++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx index b50f3cc2..22acfa61 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ToolbarActions.tsx @@ -20,6 +20,9 @@ import { useElementSelection } from '@/app/components/editor/ElementSelectionCon import { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { ComposerPlusMenu, ActiveTogglePills, PlusMenuItem } from './ComposerPlusMenu'; +// Stable empty: minting [] inside the selector re-rendered the toolbar on every store commit. +const EMPTY_MCPS: string[] = []; + interface Props { c: ClaudeTokens; elementSelection: ReturnType; @@ -48,7 +51,7 @@ export const ToolbarActions: React.FC = ({ // Lazy-load the skills list the first time the menu could need it; cheap and cached in the slice. const skills = useAppSelector((s) => s.skills.items); const skillsLoaded = useAppSelector((s) => s.skills.loaded); - const activeMcps = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId]?.active_mcps : undefined) ?? []); + const activeMcps = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId]?.active_mcps : undefined) ?? EMPTY_MCPS); React.useEffect(() => { if (!skillsLoaded) dispatch(fetchSkills()); }, [skillsLoaded, dispatch]); // Every composer action collapses into one "+" so the bar reads empty at rest; active toggles // (web search, selecting) still surface as a pill so their state stays visible. New capabilities diff --git a/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx b/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx index 637ac2a7..a3c83cfd 100644 --- a/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx @@ -9,7 +9,12 @@ import TetherLayer from './TetherLayer'; // whole dashboard tree (the ENG-88 input delay). const TetherLayerHost: React.FC<{ inputs: TetherInputs; c: ClaudeTokens }> = ({ inputs, c }) => { const [liveDrag, setLiveDrag] = useState(null); - useEffect(() => subscribeLiveDrag(setLiveDrag), []); + // Tethers only exist while something glows; on an idle canvas a drag frame should cost zero React work, so don't even subscribe. + const hasTethers = Object.keys(inputs.glowingAgentCards).length > 0 || Object.keys(inputs.glowingBrowserCards).length > 0; + useEffect(() => { + if (!hasTethers) { setLiveDrag(null); return undefined; } + return subscribeLiveDrag(setLiveDrag); + }, [hasTethers]); const tethers = useTethers(inputs, liveDrag); return ; }; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts index 798d267d..0caa5e7f 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts @@ -5,6 +5,7 @@ import { moveCards } from '@/shared/state/dashboardLayoutSlice'; import type { CardType, useDashboardSelection } from '../state/useDashboardSelection'; import type { CanvasActions } from './useCanvasControls'; import { publishLiveDrag } from './liveDragChannel'; +import { setCanvasInteractionActive } from '@/shared/canvasInteractionState'; type Selection = ReturnType; @@ -98,6 +99,8 @@ export function useCardDrag({ } // Arm the webview shield on the first real MOVE, not on pointerdown: a plain click also arms the drag machinery, and shielding then made the click-to-focus camera fit skip (it saw a "drag in progress"), so focusing a card took two clicks. On a real drag the shield still goes up before the pointer travels, so the webview neutralization + no-nudge + release-over-webview fixes all hold. Idempotent add. document.body.classList.add('dashboard-marquee-active'); + // Card drags count as canvas interaction: without this, a mid-drag transcript resize re-rendered the whole controller per change (the ResizeObserver bail never engaged). + setCanvasInteractionActive(true); // Start edge panning only once actual dragging begins; a live frame handle means the loop is already running. if (edgePanFrameRef.current === null) { edgePanFrameRef.current = requestAnimationFrame(tickEdgePan); @@ -116,6 +119,7 @@ export function useCardDrag({ canvasActions.commit(); activeDragCardRef.current = null; document.body.classList.remove('dashboard-marquee-active'); + setCanvasInteractionActive(false); isMultiDragRef.current = false; setMultiDragDelta(null); publishLiveDrag(null);