From 1a0a2c0b2c16ea0a84c507c512eb786dbd262f2a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 13:16:26 -0700 Subject: [PATCH] [eric] canvas: tiled cards restyle in the same task as every camera write (zoom jitter gone); chat header gets a hover runway so the pop-above handle is actually grabbable --- .../app/pages/Dashboard/cards/AgentCard.tsx | 15 ++++++- .../app/pages/Dashboard/cards/BrowserCard.tsx | 2 +- .../Dashboard/cards/DashboardViewCard.tsx | 2 +- .../app/pages/Dashboard/cards/NoteCard.tsx | 2 +- .../app/pages/Dashboard/cards/tileZones.ts | 39 ++++++++++++++++++- .../pages/Workflows/app/WorkflowsAppCard.tsx | 2 +- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 2b9bcf4a..1106af45 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -641,7 +641,7 @@ const AgentCard: React.FC = ({ }, [tileZone]); void tileTick; const cam = getCanvasState(); - const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom); + const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, session.id); // A collapsed chat can never stay tiled: collapsing while fullscreen left a white full-window shell // (the header collapse control still fires in full size view). Seal the state instead of the path. useEffect(() => { @@ -792,6 +792,19 @@ const AgentCard: React.FC = ({ }} sx={{ position: 'relative', + // Hover runway for the pop-above header: the header is pointer-events:none until the CARD + // is hovered, but it floats ABOVE the card's box, so without this strip the pointer leaving + // the card to reach it dropped :hover and the header died mid-approach (chats ungrabbable). + ...(expanded && !tiledStyle && !pillMode && { + '&::before': { + content: '""', + position: 'absolute', + left: 0, + right: 0, + top: -40, + height: 40, + }, + }), // contain: streaming chat updates inside don't reflow the dashboard. Skipping `paint` here because the highlighted/selected/glow boxShadows legitimately extend past the card border, `paint` containment would clip those visuals. contain: 'layout style', // Each card gets its own compositor layer; hover-cross used to cost 100-200ms PRESENTATION by re-painting the whole canvas. diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 35a7204f..1e741356 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -234,7 +234,7 @@ const BrowserCard: React.FC = ({ }, [tileZone]); void tileTick; const cam = getCanvasState(); - const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom); + const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, browserId); const onTile = useCallback((zone: string): void => { if (zone === 'restore') dispatch(clearTiledCard(browserId)); else dispatch(setTiledCard({ cardId: browserId, zone })); diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index b2068313..a64caf35 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -164,7 +164,7 @@ const DashboardViewCard: React.FC = ({ }, [tileZone]); void tileTick; const cam = getCanvasState(); - const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom); + const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, cardKey); const isFullscreen = tileZone === 'fullscreen'; // Keep the live preview mounted only when the user can actually see/use this app card. Always live diff --git a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx index 6949edce..148d72b5 100644 --- a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx @@ -270,7 +270,7 @@ const NoteCard: React.FC = ({ }, [tileZone]); void tileTick; const cam = getCanvasState(); - const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom); + const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, noteId); const isFullscreen = tileZone === 'fullscreen'; const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; diff --git a/frontend/src/app/pages/Dashboard/cards/tileZones.ts b/frontend/src/app/pages/Dashboard/cards/tileZones.ts index 63d389ae..31fdcbb3 100644 --- a/frontend/src/app/pages/Dashboard/cards/tileZones.ts +++ b/frontend/src/app/pages/Dashboard/cards/tileZones.ts @@ -78,7 +78,17 @@ export function computeTiledStyle(zone: string, panX: number, panY: number, zoom // Tiled geometry depends on live DOM measurements, so re-render when the workspace resizes: // the chrome collapsing on fullscreen-enter, a window resize, a banner appearing. The initial // ResizeObserver fire also re-measures right after the same-commit layout change that set the zone. -export function useTiledStyle(zone: string | undefined, panX: number, panY: number, zoom: number): TiledStyle | null { +export function useTiledStyle( + zone: string | undefined, + panX: number, + panY: number, + zoom: number, + // Live sync: with these, every camera write restyles the card element IN THE SAME TASK as the + // canvas transform. The React path alone lands a commit behind the compositor, so tiled cards + // visibly wobbled against the viewport on every zoom/pan frame. + getLive?: () => { panX: number; panY: number; zoom: number }, + selectId?: string, +): TiledStyle | null { const [, bump] = React.useReducer((n: number) => n + 1, 0); React.useEffect(() => { if (!zone) return undefined; @@ -94,5 +104,32 @@ export function useTiledStyle(zone: string | undefined, panX: number, panY: numb const timers = [60, 250, 700].map((ms) => window.setTimeout(() => bump(), ms)); return () => { ro.disconnect(); window.removeEventListener('resize', onResize); timers.forEach((t) => window.clearTimeout(t)); }; }, [zone]); + React.useEffect(() => { + if (!zone || !getLive || !selectId) return undefined; + const el = document.querySelector(`[data-select-id="${CSS.escape(selectId)}"]`) as HTMLElement | null; + if (!el) return undefined; + const props = ['left', 'top', 'width', 'height', 'transform', 'transform-origin']; + const apply = (): void => { + // A parked card (kept alive off-screen / minimized) must keep its sx parking position. + if (el.getAttribute('data-keepalive-hidden') === '1') { props.forEach((pr) => el.style.removeProperty(pr)); return; } + const cam = getLive(); + const s = computeTiledStyle(zone, cam.panX, cam.panY, cam.zoom); + if (!s) return; + el.style.left = `${s.left}px`; + el.style.top = `${s.top}px`; + el.style.width = `${s.width}px`; + el.style.height = `${s.height}px`; + el.style.transform = s.transform; + el.style.transformOrigin = s.transformOrigin; + }; + apply(); + window.addEventListener('openswarm:canvas-pan-changed', apply); + window.addEventListener('resize', apply); + return () => { + window.removeEventListener('openswarm:canvas-pan-changed', apply); + window.removeEventListener('resize', apply); + props.forEach((pr) => el.style.removeProperty(pr)); + }; + }, [zone, getLive, selectId]); return zone ? computeTiledStyle(zone, panX, panY, zoom) : null; } diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index ae8c01be..1e138d7d 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -65,7 +65,7 @@ const WorkflowsAppCard: React.FC = ({ return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan); }, [isFullscreen]); const cam = getCanvasState(); - const fsStyle = useTiledStyle(isFullscreen ? 'fullscreen' : undefined, cam.panX, cam.panY, cam.zoom); + const fsStyle = useTiledStyle(isFullscreen ? 'fullscreen' : undefined, cam.panX, cam.panY, cam.zoom, getCanvasState, 'workflows-hub'); // ---- Drag (title bar is the handle) ----