diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index ffde4ab1..a31517ce 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -55,6 +55,7 @@ import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext'; import { GLASS_SURFACE, GLASS_SURFACE_BLUR, GLASS_SURFACE_TEXT } from '@/shared/styles/glassSurface'; import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough'; +import { useRenderRing } from '../hooks/interaction/useRenderRing'; import { useStreamingMessage } from '@/shared/state/streamingSlice'; import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState'; import { setCardSidecar } from '@/shared/state/workflowsSlice'; @@ -723,6 +724,8 @@ const AgentCard: React.FC = ({ ); // Drafts collapse to the pill like everything else; only a pending approval keeps the full card, since you have to see what you are approving. const pillMode = !expanded && !hasPending && !tileZone; + // Pre-render a ring of canvas around the camera so a pan lands on already-drawn pills (ENG-301). + const ringNear = useRenderRing(cardX, cardY, cardWidth, cardHeight, getCanvasState, pillMode && !isFullscreen); // Two-phase expand: mounting a long transcript synchronously inside the expand click blocked its paint for ~630ms (the measured INP worst case), so the click paints the expanded shell first and the chat mounts on the next frame. // Keep-alive: once mounted, the chat STAYS mounted across collapse (hidden, not unmounted). The transcript windowing bounds its kept DOM to ~a screen of bubbles, and re-expand becomes a display toggle instead of a full subtree rebuild + WS reconnect. @@ -872,7 +875,9 @@ const AgentCard: React.FC = ({ // expanded card is the one you are reading. contain-intrinsic-size keeps a skipped card's // box the size it would have been, so tethers and fit-to-view still measure it correctly. ...(pillMode && !expanded && !isFullscreen && !tiledSize ? { - contentVisibility: 'auto', + // Inside the camera ring: force-render so arriving pans hit pre-drawn pixels (ENG-301, + // Eric's render-ahead call); outside it: keep the ENG-261 skip that holds big boards. + contentVisibility: ringNear ? 'visible' : 'auto', // `auto` = remember the size this card last really rendered at, falling back to the guess // only before its first paint. A pill is fit-content/auto sized, so a fixed guess is wrong // for every card and costs a relayout each time one scrolls in: measured 27.8ms p95 with a diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useRenderRing.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useRenderRing.ts new file mode 100644 index 00000000..de2f8980 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useRenderRing.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from 'react'; + +// Chromium only pre-renders content-visibility:auto elements a sliver past the viewport, so a pan +// always lands on never-drawn cards and pays first-raster right under the gesture (ENG-301's +// 100-700ms singles). Eric's call: keep a RING of canvas around the camera always rendered, so by +// the time you arrive it is already drawn. Cards inside the ring force 'visible'; far cards keep +// 'auto' (that skip is what holds 150-card boards at frame budget, ENG-261). Enter at ENTER_VP +// viewports, leave at LEAVE_VP, so boundary jitter never flips a card back and forth. +const ENTER_VP = 1.5; +const LEAVE_VP = 2.25; +// Newly-near cards render at most this many per frame: a fast pan crossing 30 cards must warm them +// over a few frames, not stall the gesture frame it happens in. +const PROMOTIONS_PER_FRAME = 3; + +let p_pending: Array<() => void> = []; +let p_drainScheduled = false; + +function p_drain(): void { + p_drainScheduled = false; + const batch = p_pending.splice(0, PROMOTIONS_PER_FRAME); + for (const fn of batch) fn(); + if (p_pending.length > 0) { + p_drainScheduled = true; + requestAnimationFrame(p_drain); + } +} + +function p_enqueuePromotion(fn: () => void): void { + p_pending.push(fn); + if (!p_drainScheduled) { + p_drainScheduled = true; + requestAnimationFrame(p_drain); + } +} + +export function useRenderRing( + cardX: number, + cardY: number, + cardWidth: number, + cardHeight: number, + getCanvasState: () => { panX: number; panY: number; zoom: number }, + active: boolean, +): boolean { + const [near, setNear] = useState(false); + const nearRef = useRef(false); + const rectRef = useRef({ x: cardX, y: cardY, w: cardWidth, h: cardHeight }); + rectRef.current = { x: cardX, y: cardY, w: cardWidth, h: cardHeight }; + + useEffect(() => { + if (!active) return undefined; + let raf = 0; + const evaluate = (): void => { + raf = 0; + const { panX, panY, zoom } = getCanvasState(); + const vw = window.innerWidth; + const vh = window.innerHeight; + // Viewport in canvas coordinates. + const vx = -panX / zoom; + const vy = -panY / zoom; + const vwC = vw / zoom; + const vhC = vh / zoom; + const marginVp = nearRef.current ? LEAVE_VP : ENTER_VP; + const mx = vwC * marginVp; + const my = vhC * marginVp; + const r = rectRef.current; + const isNear = + r.x + r.w > vx - mx && r.x < vx + vwC + mx && + r.y + r.h > vy - my && r.y < vy + vhC + my; + if (isNear !== nearRef.current) { + nearRef.current = isNear; + // Leaving the ring is free (back to skipped); entering pays a render, so it rides the + // per-frame promotion budget instead of landing all at once mid-gesture. + if (isNear) p_enqueuePromotion(() => setNear(true)); + else setNear(false); + } + }; + const schedule = (): void => { + if (!raf) raf = requestAnimationFrame(evaluate); + }; + schedule(); + window.addEventListener('openswarm:canvas-pan-changed', schedule); + window.addEventListener('resize', schedule); + return () => { + window.removeEventListener('openswarm:canvas-pan-changed', schedule); + window.removeEventListener('resize', schedule); + if (raf) cancelAnimationFrame(raf); + }; + }, [getCanvasState, active]); + + return near; +}