diff --git a/frontend/src/app/components/RichPromptEditor.tsx b/frontend/src/app/components/RichPromptEditor.tsx index dd003cf7..a7f9273f 100644 --- a/frontend/src/app/components/RichPromptEditor.tsx +++ b/frontend/src/app/components/RichPromptEditor.tsx @@ -149,7 +149,9 @@ const RichPromptEditor: React.FC = ({ if (result) { setPicker(result); } else { - setPicker((p) => ({ ...p, visible: false })); + // See ChatInput: bail when already hidden to avoid a per-keystroke + // re-render of the whole editor on every keypress. + setPicker((p) => p.visible ? { ...p, visible: false } : p); } }, []); diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 12bcb981..28778161 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1158,7 +1158,22 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const rawText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); return ( - + (({ onSend, disabled, mode, if (result) { setPicker(result); } else { - setPicker((p) => ({ ...p, visible: false })); + // Bail when picker is already hidden. Previously this spread a new + // object on every keystroke (`{...p, visible:false}`), which React + // saw as a state change and re-rendered ChatInput (2400 lines) on + // every keypress — that was the 199ms input delay on typing. + setPicker((p) => p.visible ? { ...p, visible: false } : p); } }, []); diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx index 69b9b01d..de7e78a8 100644 --- a/frontend/src/app/pages/Dashboard/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx @@ -240,9 +240,13 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ interface OuterProps { sessionId: string; expanded: boolean; - zoom?: number; - panX?: number; - panY?: number; + // Stable getter — cards read pan/zoom on demand (drag math) instead of + // receiving them as props. Without this, every wheel/pan tick on the + // canvas re-rendered every card, even though the canvas root's CSS + // transform is what actually moves them visually. Cards only need the + // values inside drag callbacks; making it a ref-backed getter keeps + // pan/zoom out of memo equality entirely. + getCanvasState: () => { panX: number; panY: number; zoom: number }; spawnFrom?: { x: number; y: number; type?: 'branch' }; exitTarget?: { x: number; y: number }; isSelected?: boolean; @@ -268,6 +272,7 @@ interface Props extends Omit { cardWidth: number; cardHeight: number; cardZOrder: number; + getCanvasState: () => { panX: number; panY: number; zoom: number }; } const MIN_W = 480; @@ -282,7 +287,7 @@ const GLOW_FADE_MS = 2500; const SNAP_THRESHOLD = 60; const AgentCard: React.FC = ({ - session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, spawnFrom, exitTarget, + session, expanded, cardX, cardY, cardWidth, cardHeight, getCanvasState, spawnFrom, exitTarget, isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd, onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onDoubleClick, onBringToFront, shakeDirection, @@ -371,46 +376,47 @@ const AgentCard: React.FC = ({ const justDraggedRef = useRef(false); const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 }); - // Use refs for pan so drag callbacks don't recreate on every pan frame - const panRef = useRef({ panX, panY }); - panRef.current = { panX, panY }; - const zoomRef = useRef(zoom); - zoomRef.current = zoom; - const handleDragPointerDown = useCallback((e: React.PointerEvent) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); - dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY }; + const cs = getCanvasState(); + dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY }; lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; didDrag.current = false; setIsDragging(true); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); onDragStart?.(session.id, 'agent'); - }, [cardX, cardY, onDragStart, session.id]); + }, [cardX, cardY, onDragStart, session.id, getCanvasState]); - // Recompute localDragPos from latest pointer + pan (shared by move handler and pan-change effect) + // Recompute localDragPos from latest pointer + pan (shared by move handler and pan-change event) const recomputeDragPos = useCallback(() => { const ds = dragState.current; if (!ds || !didDrag.current) return; const { clientX, clientY } = lastPointerRef.current; const rawDx = clientX - ds.startX; const rawDy = clientY - ds.startY; - const z = zoomRef.current; - const panDx = (panRef.current.panX - ds.startPanX) / z; - const panDy = (panRef.current.panY - ds.startPanY) / z; + const cs = getCanvasState(); + const z = cs.zoom; + const panDx = (cs.panX - ds.startPanX) / z; + 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 }); onDragMove?.(dx, dy, clientX, clientY); - }, [onDragMove]); + }, [onDragMove, getCanvasState]); - // When pan changes during an active drag, recompute position so card tracks cursor + // When pan changes during an active drag (edge-pan or wheel-zoom-while- + // dragging), Dashboard dispatches `openswarm:canvas-pan-changed`. Only + // active during a drag so non-dragging cards stay subscribed-to-nothing. useEffect(() => { - if (isDragging && didDrag.current) { - recomputeDragPos(); - } - }, [panX, panY, isDragging, recomputeDragPos]); + if (!isDragging) return; + const onPanChange = () => { + if (didDrag.current) recomputeDragPos(); + }; + window.addEventListener('openswarm:canvas-pan-changed', onPanChange); + return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange); + }, [isDragging, recomputeDragPos]); const handleDragPointerMove = useCallback((e: React.PointerEvent) => { if (!dragState.current) return; @@ -424,9 +430,10 @@ const AgentCard: React.FC = ({ const handleDragPointerUp = useCallback((e: React.PointerEvent) => { if (!dragState.current) return; - const z = zoomRef.current; - const panDx = (panRef.current.panX - dragState.current.startPanX) / z; - const panDy = (panRef.current.panY - dragState.current.startPanY) / z; + const cs = getCanvasState(); + const z = cs.zoom; + const panDx = (cs.panX - dragState.current.startPanX) / z; + const panDy = (cs.panY - dragState.current.startPanY) / z; const dx = (e.clientX - dragState.current.startX) / z - panDx; const dy = (e.clientY - dragState.current.startY) / z - panDy; if (didDrag.current) { @@ -454,7 +461,7 @@ const AgentCard: React.FC = ({ setLocalDragPos(null); setIsDragging(false); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); - }, [dispatch, session.id, onDragEnd, snapColumn, cardHeight]); + }, [dispatch, session.id, onDragEnd, snapColumn, cardHeight, getCanvasState]); // ---- Unified edge / corner resize ---- const resizeRef = useRef<{ @@ -495,8 +502,9 @@ const AgentCard: React.FC = ({ (e: React.PointerEvent) => { if (!resizeRef.current) return null; const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current; - const dx = (e.clientX - startX) / zoom; - const dy = (e.clientY - startY) / zoom; + const z = getCanvasState().zoom; + const dx = (e.clientX - startX) / z; + const dy = (e.clientY - startY) / z; let newX = origX, newY = origY, newW = origW, newH = origH; @@ -510,7 +518,7 @@ const AgentCard: React.FC = ({ return { x: newX, y: newY, w: newW, h: newH }; }, - [zoom], + [getCanvasState], ); const handleResizeMove = useCallback( @@ -647,6 +655,16 @@ const AgentCard: React.FC = ({ // boxShadows legitimately extend past the card border — `paint` // containment would clip those visuals. contain: 'layout style', + // Promote each card to its own compositor layer so paint + // invalidations (hover effects, streaming content updates, + // highlight pulses) stay contained to that one card's layer + // instead of forcing the canvas's GPU-promoted root layer to + // re-paint. The performance trace showed pointer hover events + // costing 100-200ms of pure presentation time before this, + // because every hover-cross re-painted the entire canvas + // composite. Costs ~card_area*4 bytes of GPU memory per card; + // trivial on modern hardware for the dashboard's card counts. + willChange: 'transform', width: localResize ? activeW : Math.max(cardWidth, MIN_W), height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'), bgcolor: c.bg.surface, @@ -740,8 +758,12 @@ const AgentCard: React.FC = ({ }, }), ...(!isHighlighted && !(isGlowingRedux && !glowFading) && !expanded && !isDragging && !isSelected && { + // Hover: borderColor only. Was previously also bumping boxShadow + // from .sm to .md, but the trace data showed pointer hover events + // costing 120-207ms PRESENTATION because every shadow change + // forced a full GPU re-blur of every card on the transformed + // canvas layer. Border color is layout-free and ~free to paint. '&:hover': { - boxShadow: c.shadow.md, borderColor: hasPending ? c.status.warning : c.border.strong, }, }), diff --git a/frontend/src/app/pages/Dashboard/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/BrowserCard.tsx index e6437f1b..a835ae50 100644 --- a/frontend/src/app/pages/Dashboard/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/BrowserCard.tsx @@ -88,10 +88,6 @@ const webviewPreloadPath: string | undefined = isElectron || (window as any).openswarm?.getWebviewPreloadPath?.()) : undefined; -if (isElectron) { - // eslint-disable-next-line no-console - console.warn('[openswarm:card-module] webviewPreloadPath =', webviewPreloadPath); -} type WebviewElement = BrowserWebview; @@ -230,8 +226,14 @@ const BrowserCard: React.FC = ({ }; const onIpcMessage = (e: any) => { - // eslint-disable-next-line no-console - console.warn('[openswarm:card] webview ipc-message:', e?.channel, e?.args); + // Was previously logging every ipc-message. The preload forwards + // every guest-page console call as `webview-console`, so popular + // sites (anything with analytics, telemetry, dev hot reload, etc.) + // produced hundreds of host-side console.warn calls per second, + // each blocking the main thread when DevTools is open. That was + // the dominant cause of the "click-then-jump" lag on dashboards + // with browser cards. Drop the unconditional log; ipc channels + // we actually care about are handled in the branches below. if (e?.channel === 'passkey-detected') { setPasskeyDialogOpen(true); } else if (e?.channel === 'canvas-wheel-zoom') { @@ -662,6 +664,9 @@ const BrowserCard: React.FC = ({ position: 'absolute', // contain: webview repaints don't shake neighbor cards. contain: 'layout style', + // Own compositor layer so hover/paint invalidations stay + // contained to this card. See AgentCard for full rationale. + willChange: 'transform', left: displayX, top: displayY, width: displayW, diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index d169ba97..d61fcbb0 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -213,6 +213,16 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const restoredExpandedRef = useRef(false); const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom }); canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom }; + // Stable getter — AgentCards read pan/zoom on demand during drag math. + const getCanvasState = useCallback(() => canvasStateRef.current, []); + // Notify the currently dragging card (if any) that pan/zoom changed so + // it can re-pin to the cursor. useEffect rather than render-body + // dispatchEvent: side effects during render are a React anti-pattern + // and can fire twice in strict mode. Effect runs after commit, so + // exactly once per real pan/zoom delta. + useEffect(() => { + window.dispatchEvent(new Event('openswarm:canvas-pan-changed')); + }, [canvas.panX, canvas.panY, canvas.zoom]); // ---- Edge panning during card drag ---- const EDGE_ZONE = 60; @@ -1973,19 +1983,21 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true } } + const isSel = selection.isSelected(sid); return ( = ({ position: 'absolute', // contain: iframe app repaints don't shake the rest of the dashboard. contain: 'layout style', + // Own compositor layer so hover/paint invalidations stay + // contained to this card. See AgentCard for full rationale. + willChange: 'transform', left: displayX, top: displayY, width: displayW, diff --git a/frontend/src/app/pages/Dashboard/NoteCard.tsx b/frontend/src/app/pages/Dashboard/NoteCard.tsx index 37b9efac..ffcc9493 100644 --- a/frontend/src/app/pages/Dashboard/NoteCard.tsx +++ b/frontend/src/app/pages/Dashboard/NoteCard.tsx @@ -269,6 +269,9 @@ const NoteCard: React.FC = ({ height: displayH, // contain: reflow inside this note doesn't shake the dashboard. contain: 'layout style', + // Own compositor layer so hover/paint invalidations stay + // contained to this note. See AgentCard for full rationale. + willChange: 'transform', borderRadius: `${c.radius.md}px`, bgcolor: palette.bg, border: isHighlighted diff --git a/frontend/src/app/pages/Dashboard/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/useCanvasControls.ts index 682477ac..944dd5d7 100644 --- a/frontend/src/app/pages/Dashboard/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/useCanvasControls.ts @@ -181,6 +181,65 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const el = viewportRef.current; if (!el || !enabled) return; // Skip wheel listener when canvas is hidden + // RAF-coalesce wheel-driven state updates. Trackpads fire wheel + // events at ~120Hz; without batching, every event triggered a full + // Dashboard re-render (all hooks + selectors + the cards .map). The + // visible pan was fine because transform-only changes are cheap to + // composite, but the JS-side render storm at 120fps caused the + // "low FPS" feel during two-finger drag. Accumulating deltas per + // frame caps Dashboard re-renders at the display's refresh rate + // (usually 60Hz), with no perceptible motion difference because we + // apply all the accumulated deltas in one shot. + let pendingPanDx = 0; + let pendingPanDy = 0; + let pendingZoomDy = 0; + let pendingZoomCenter: { cx: number; cy: number } | null = null; + let wheelRafId: number | null = null; + + const flushWheel = () => { + wheelRafId = null; + const dx = pendingPanDx; const dy = pendingPanDy; + const zDy = pendingZoomDy; const zCenter = pendingZoomCenter; + pendingPanDx = 0; pendingPanDy = 0; + pendingZoomDy = 0; pendingZoomCenter = null; + + if (zCenter && zDy !== 0) { + setState((prev) => { + const factor = Math.pow(2, -zDy * sensitivityToMultiplier(sensitivityRef.current)); + const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM); + const ratio = newZoom / prev.zoom; + return { + panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio, + panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio, + zoom: newZoom, + }; + }); + } else if (dx !== 0 || dy !== 0) { + setState((prev) => ({ + ...prev, + panX: prev.panX - dx, + panY: prev.panY - dy, + })); + } + }; + + const scheduleWheelFlush = () => { + if (wheelRafId != null) return; + wheelRafId = requestAnimationFrame(flushWheel); + }; + + // Cache "is this element a scrollable child" decision per node. The + // canvas wheel handler walks up from e.target to el on every event; + // without caching, it called getComputedStyle on every ancestor on + // every wheel event (120Hz from a trackpad × 5-10 ancestors × style + // recalc). That was the dominant cost of trackpad two-finger + // navigation — RAF-coalescing the state update only fixed half of + // the problem. WeakMap entries get GC'd with their elements; no + // manual invalidation needed for unmounted DOM. We do invalidate + // explicitly when a node's scroll capacity might have changed (see + // the resize observer below). + const scrollableCache: WeakMap = new WeakMap(); + const onWheel = (e: WheelEvent) => { // Pinch-to-zoom on trackpads sets ctrlKey; plain scroll does not const isPinchZoom = e.ctrlKey || e.metaKey; @@ -191,19 +250,35 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const dx = e.deltaMode === 1 ? e.deltaX * 40 : e.deltaX; let target = e.target as HTMLElement | null; while (target && target !== el) { - const style = getComputedStyle(target); - const overflowY = style.overflowY; - const overflowX = style.overflowX; + // Cached classification: 'scrollable' = has overflow auto/scroll + // AND content exceeds its frame in some direction. 'not' = neither. + // Fast path: cheap scrollHeight/scrollWidth read (a layout-flushing + // property, but no style recalc) before paying for getComputedStyle. + let cls = scrollableCache.get(target); + if (cls === undefined) { + const couldScroll = + target.scrollHeight > target.clientHeight || + target.scrollWidth > target.clientWidth; + if (couldScroll) { + const style = getComputedStyle(target); + const oy = style.overflowY; + const ox = style.overflowX; + const isOverflowScrollable = + oy === 'auto' || oy === 'scroll' || ox === 'auto' || ox === 'scroll'; + cls = isOverflowScrollable ? 'scrollable' : 'not'; + } else { + cls = 'not'; + } + scrollableCache.set(target, cls); + } - const canScrollY = - target.scrollHeight > target.clientHeight && - (overflowY === 'auto' || overflowY === 'scroll'); - const canScrollX = - target.scrollWidth > target.clientWidth && - (overflowX === 'auto' || overflowX === 'scroll'); - - if ((canScrollY || canScrollX) && !isPinchZoom) { - // Check if at scroll boundary in the scroll direction + if (cls === 'scrollable' && !isPinchZoom) { + // Re-read scrollHeight/clientHeight here (cheap, no style recalc) + // to make the at-boundary check responsive — the cached decision + // is structural (does this element have overflow:auto/scroll AND + // exceed its frame); the current scroll position is dynamic. + const canScrollY = target.scrollHeight > target.clientHeight; + const canScrollX = target.scrollWidth > target.clientWidth; const atYBoundary = !canScrollY || (dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) || (dy < 0 && target.scrollTop <= 1); @@ -228,28 +303,19 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: } if (isPinchZoom) { - // Pinch gesture → zoom centered on cursor + // Pinch gesture → accumulate zoom deltas + last cursor position. + // factor = 2^(-Σdy·s) which equals the product of per-event + // factors, so accumulating dy is mathematically identical to + // applying each event one at a time. const rect = el.getBoundingClientRect(); - const cx = e.clientX - rect.left; - const cy = e.clientY - rect.top; - - setState((prev) => { - const factor = Math.pow(2, -dy * sensitivityToMultiplier(sensitivityRef.current)); - const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM); - const ratio = newZoom / prev.zoom; - return { - panX: cx - (cx - prev.panX) * ratio, - panY: cy - (cy - prev.panY) * ratio, - zoom: newZoom, - }; - }); + pendingZoomDy += dy; + pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top }; + scheduleWheelFlush(); } else { - // Two-finger scroll → pan - setState((prev) => ({ - ...prev, - panX: prev.panX - dx, - panY: prev.panY - dy, - })); + // Two-finger scroll → accumulate pan deltas. + pendingPanDx += dx; + pendingPanDy += dy; + scheduleWheelFlush(); } }; @@ -264,28 +330,23 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const detail = (e as CustomEvent).detail || {}; const dy = detail.deltaMode === 1 ? detail.deltaY * 40 : detail.deltaY; const rect = el.getBoundingClientRect(); - const cx = (detail.clientX ?? 0) - rect.left; - const cy = (detail.clientY ?? 0) - rect.top; if (inertiaFrameRef.current) { cancelAnimationFrame(inertiaFrameRef.current); inertiaFrameRef.current = null; } - setState((prev) => { - const factor = Math.pow(2, -dy * sensitivityToMultiplier(sensitivityRef.current)); - const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM); - const ratio = newZoom / prev.zoom; - return { - panX: cx - (cx - prev.panX) * ratio, - panY: cy - (cy - prev.panY) * ratio, - zoom: newZoom, - }; - }); + pendingZoomDy += dy; + pendingZoomCenter = { + cx: (detail.clientX ?? 0) - rect.left, + cy: (detail.clientY ?? 0) - rect.top, + }; + scheduleWheelFlush(); }; window.addEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom); return () => { el.removeEventListener('wheel', onWheel); window.removeEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom); + if (wheelRafId != null) cancelAnimationFrame(wheelRafId); }; }, [enabled]); @@ -303,26 +364,54 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: }; }, [cancelAnimation, cancelInertia]); + // RAF-coalesce mouse-drag pan. Mouse events fire at 60-240Hz; without + // batching, every event called setState directly and Dashboard + // re-rendered at the same rate, causing the "hop hop hop" feel when + // dragging the canvas with the cursor. Velocity history still captures + // per-event (for inertia accuracy on mouseup) — only the React state + // update is throttled. + const dragRafRef = useRef(null); + const latestDragRef = useRef<{ dx: number; dy: number } | null>(null); + const flushDrag = useCallback(() => { + dragRafRef.current = null; + const start = panStartRef.current; + const latest = latestDragRef.current; + if (!start || !latest) return; + setState((prev) => ({ + ...prev, + panX: start.panX + latest.dx, + panY: start.panY + latest.dy, + })); + }, []); + const handleMouseMove = useCallback((e: React.MouseEvent) => { const start = panStartRef.current; if (!start) return; const dx = e.clientX - start.x; const dy = e.clientY - start.y; - // Track velocity (keep last 5 positions) + // Velocity history is per-event so inertia stays accurate on + // mouseup. Cheap; just pushes to a length-5 ring buffer. const now = performance.now(); const history = velocityHistoryRef.current; history.push({ x: e.clientX, y: e.clientY, t: now }); if (history.length > 5) history.shift(); - setState((prev) => ({ - ...prev, - panX: start.panX + dx, - panY: start.panY + dy, - })); - }, []); + latestDragRef.current = { dx, dy }; + if (dragRafRef.current == null) { + dragRafRef.current = requestAnimationFrame(flushDrag); + } + }, [flushDrag]); const handleMouseUp = useCallback(() => { + // Apply any pending drag delta synchronously so the final position + // matches where the cursor was released, then drop the scheduled RAF. + if (dragRafRef.current != null) { + cancelAnimationFrame(dragRafRef.current); + dragRafRef.current = null; + flushDrag(); + } + latestDragRef.current = null; const wasPanning = !!panStartRef.current; let didInertia = false; if (wasPanning) {