diff --git a/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts b/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts new file mode 100644 index 00000000..274c39ee --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts @@ -0,0 +1,156 @@ +// The ONE writer of a tiled card's on-screen rect. A tiled card lives INSIDE the pan/zoom layer (so +// its webview is never remounted, which would log the user out) yet has to hold still against the +// viewport, so something must undo the camera on every frame. That job used to be split between +// React, framer-motion and an imperative !important writer, and any frame where they disagreed left +// the tile frozen at a stale camera. Now React owns only the card's SIZE, which no camera can +// change, and this module owns exactly one property, `transform`, written in the same task that +// paints the canvas itself. Two sources can no longer hold two different cameras. + +export interface Camera { + panX: number; + panY: number; + zoom: number; +} + +export const TILE_ZONES: Record = { + fill: { x: 0, y: 0, w: 1, h: 1 }, + left: { x: 0, y: 0, w: 0.5, h: 1 }, + right: { x: 0.5, y: 0, w: 0.5, h: 1 }, + top: { x: 0, y: 0, w: 1, h: 0.5 }, + bottom: { x: 0, y: 0.5, w: 1, h: 0.5 }, + tl: { x: 0, y: 0, w: 0.5, h: 0.5 }, + tr: { x: 0.5, y: 0, w: 0.5, h: 0.5 }, + bl: { x: 0, y: 0.5, w: 0.5, h: 0.5 }, + br: { x: 0.5, y: 0.5, w: 0.5, h: 0.5 }, + t3l: { x: 0, y: 0, w: 1 / 3, h: 1 }, + t3c: { x: 1 / 3, y: 0, w: 1 / 3, h: 1 }, + t3r: { x: 2 / 3, y: 0, w: 1 / 3, h: 1 }, +}; + +// macOS Sequoia leaves a small gap between tiled windows; we match it. +const GAP = 8; + +export interface ZoneRect { + x: number; + y: number; + w: number; + h: number; +} + +interface Workspace { + x0: number; + w: number; + h: number; +} + +// Pure layout, never the camera, so a pan/zoom frame reads nothing back out of the DOM. +let workspace: Workspace | null = null; + +function measureWorkspace(): Workspace { + const el = document.querySelector('[data-canvas-viewport]'); + const r = el?.getBoundingClientRect(); + if (!r || !(r.width > 0 && r.height > 0)) return { x0: 0, w: window.innerWidth, h: window.innerHeight }; + let x0 = 0; + const dock = document.querySelector('[data-desktop-dock]'); + if (dock) { + const dr = dock.getBoundingClientRect(); + // macOS model: a tiled window starts beside the Dock, never underneath it. + if (dr.width > 0 && dr.right > r.left && dr.left < r.left + r.width * 0.25) x0 = dr.right - r.left + GAP; + } + return { x0, w: r.width, h: r.height }; +} + +// Screen-space rect (relative to the canvas viewport) that a zone occupies. +export function zoneRect(zone: string): ZoneRect | null { + const ws = workspace ?? (workspace = measureWorkspace()); + if (zone === 'fullscreen') { + return { x: ws.x0 + GAP, y: GAP, w: ws.w - ws.x0 - GAP * 2, h: ws.h - GAP * 2 }; + } + const z = TILE_ZONES[zone]; + if (!z) return null; + const usableW = ws.w - ws.x0; + return { + x: ws.x0 + z.x * usableW + GAP, + y: z.y * ws.h + GAP, + w: z.w * usableW - GAP * 2, + h: z.h * ws.h - GAP * 2, + }; +} + +interface TiledEntry { + el: HTMLElement; + zone: string; + // The card's own canvas-space origin, which React keeps owning. The transform below is only the + // delta from there to the zone, so tiling never rewrites a stored position and untiling never jumps. + originX: number; + originY: number; +} + +const entries = new Map(); +const workspaceListeners = new Set<() => void>(); +let lastCamera: Camera = { panX: 0, panY: 0, zoom: 1 }; +let observer: ResizeObserver | null = null; + +function applyEntry(entry: TiledEntry, cam: Camera): void { + const r = zoneRect(entry.zone); + if (!r) return; + const s = 1 / cam.zoom; + const tx = (r.x - cam.panX) * s - entry.originX; + const ty = (r.y - cam.panY) * s - entry.originY; + entry.el.style.transform = `translate(${tx}px, ${ty}px) scale(${s})`; +} + +// Called by the canvas's one camera-to-DOM writer, in the same task as the canvas transform. +export function syncTiledGeometry(cam: Camera): void { + lastCamera = cam; + for (const entry of entries.values()) applyEntry(entry, cam); +} + +function onWorkspaceChanged(): void { + workspace = null; + for (const entry of entries.values()) applyEntry(entry, lastCamera); + workspaceListeners.forEach((fn) => fn()); +} + +// Lets a tiled card re-render for its new SIZE when the workspace changes shape. +export function subscribeTiledWorkspace(fn: () => void): () => void { + workspaceListeners.add(fn); + return () => { workspaceListeners.delete(fn); }; +} + +function startObserving(): void { + if (observer) return; + observer = new ResizeObserver(onWorkspaceChanged); + const vp = document.querySelector('[data-canvas-viewport]'); + if (vp) observer.observe(vp); + const dock = document.querySelector('[data-desktop-dock]'); + if (dock) observer.observe(dock); + window.addEventListener('resize', onWorkspaceChanged); +} + +function stopObserving(): void { + observer?.disconnect(); + observer = null; + window.removeEventListener('resize', onWorkspaceChanged); +} + +export function registerTiledCard(id: string, zone: string, origin: { x: number; y: number }, cam: Camera): void { + const el = document.querySelector(`[data-select-id="${CSS.escape(id)}"]`); + if (!el) return; + const entry: TiledEntry = { el, zone, originX: origin.x, originY: origin.y }; + entries.set(id, entry); + startObserving(); + // Tiling usually commits alongside chrome collapsing, so a cached workspace is untrustworthy here. + workspace = null; + lastCamera = cam; + applyEntry(entry, cam); +} + +export function unregisterTiledCard(id: string): void { + const entry = entries.get(id); + if (!entry) return; + // Hand the property straight back to React's styles; nothing else ever wrote it. + entry.el.style.transform = ''; + entries.delete(id); + if (entries.size === 0) stopObserving(); +} diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 76daa970..9e7aabd9 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -35,7 +35,7 @@ import { clearTiledCard, } from '@/shared/state/dashboardLayoutSlice'; import WindowControls, { ARC_CHIP_SX } from './WindowControls'; -import { useTiledStyle } from './tileZones'; +import { useTiledCard } from './useTiledCard'; import { useCardTiling } from './useCardTiling'; import AgentNarratorPill from '../desktop/AgentNarratorPill'; import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu'; @@ -660,19 +660,7 @@ const AgentCard: React.FC = ({ }; const isFullscreen = tileZone === 'fullscreen'; - // Fullscreen pins the card to the viewport, so while tiled the geometry must track canvas pan/zoom. - // Chat cards read the camera via a getter (not props) to avoid re-rendering on every pan tick, so - // we subscribe to the pan event ONLY while tiled (one card at most), and read fresh camera then. - const [tileTick, setTileTick] = useState(0); - useEffect(() => { - if (!tileZone) return undefined; - const onPan = (): void => setTileTick((t) => t + 1); - window.addEventListener('openswarm:canvas-pan-changed', onPan); - return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan); - }, [tileZone]); - void tileTick; - const cam = getCanvasState(); - const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, session.id); + const isTiled = !!tileZone; // 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(() => { @@ -751,6 +739,7 @@ const AgentCard: React.FC = ({ const activeY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy); const activeW = localResize?.w ?? cardWidth; const activeH = localResize?.h ?? cardHeight; + const tiledSize = useTiledCard({ cardId: session.id, zone: tileZone, active: true, originX: activeX, originY: activeY, getCamera: getCanvasState }); const isBranchSpawn = spawnFrom?.type === 'branch'; const spawnInitial = spawnFrom @@ -778,14 +767,14 @@ const AgentCard: React.FC = ({ onBringToFront?.(session.id, 'agent')} style={{ position: 'absolute', - zIndex: tiledStyle ? 999990 : isDragging || isResizing ? 999999 : cardZOrder, + zIndex: isTiled ? 999990 : isDragging || isResizing ? 999999 : cardZOrder, }} > = ({ // 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 && { + ...(expanded && !isTiled && !pillMode && { '&::before': { content: '""', position: 'absolute', @@ -835,10 +824,9 @@ const AgentCard: React.FC = ({ contain: 'layout style', // Each card gets its own compositor layer; hover-cross used to cost 100-200ms PRESENTATION by re-painting the whole canvas. willChange: 'transform', - width: pillMode ? 'fit-content' : tiledStyle ? tiledStyle.width : (localResize ? activeW : Math.max(cardWidth, MIN_W)), - height: tiledStyle ? tiledStyle.height : (localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto')), - transform: tiledStyle ? tiledStyle.transform : undefined, - transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, + width: pillMode ? 'fit-content' : tiledSize ? tiledSize.width : (localResize ? activeW : Math.max(cardWidth, MIN_W)), + height: tiledSize ? tiledSize.height : (localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto')), + transformOrigin: tiledSize ? '0 0' : undefined, bgcolor: c.bg.surface, border: isHighlighted ? `2px solid ${c.accent.primary}` @@ -941,16 +929,16 @@ const AgentCard: React.FC = ({ ...(expanded && { // Warm near-neutral dark (the Claude/ChatGPT family) instead of the saturated plum: long // reading sessions want a quiet ground; the accent system carries the brand color. - bgcolor: tiledStyle ? 'rgb(33,31,36)' : 'rgba(33,31,36,0.88)', - ...(tiledStyle ? {} : { + bgcolor: isTiled ? 'rgb(33,31,36)' : 'rgba(33,31,36,0.88)', + ...(isTiled ? {} : { backdropFilter: 'blur(24px) saturate(150%)', WebkitBackdropFilter: 'blur(24px) saturate(150%)', }), border: isFullscreen ? 'none' : isSelected ? '2px solid #3b82f6' : '1px solid rgba(255,255,255,0.08)', - borderRadius: tiledStyle ? '12px' : '20px', + borderRadius: isTiled ? '12px' : '20px', boxShadow: '0 18px 48px rgba(0,0,0,0.4)', // The hover header floats ABOVE the card; the root must not clip it (the chat body clips itself). - ...(tiledStyle ? {} : { overflow: 'visible' }), + ...(isTiled ? {} : { overflow: 'visible' }), }), }} > @@ -995,7 +983,7 @@ const AgentCard: React.FC = ({ {/* Grab band: the top sliver of an expanded card drags it, matching the "grab the window by its top edge" instinct; the pop-above header remains the labeled handle. */} - {expanded && !tiledStyle && !pillMode && ( + {expanded && !isTiled && !pillMode && ( = ({ onLostPointerCapture={abortDrag} sx={{ ...(expanded - ? tiledStyle + ? isTiled ? { // Fullscreen/tiled: no room above the card, so the scrim rides on top. Never hidden: in fullscreen the title and the lights are the only way out. position: 'absolute', @@ -1124,7 +1112,7 @@ const AgentCard: React.FC = ({ alignItems: 'center', gap: 1, // Expanded titles wear the same glass bubble as the collapsed pill; a bare label floating over the canvas read as a stray caption. - ...(expanded && !tiledStyle && { + ...(expanded && !isTiled && { alignSelf: 'flex-start', flex: '0 1 auto', // mr auto or the row's space-between flings the bubble to the far edge, away from the lights. @@ -1138,7 +1126,7 @@ const AgentCard: React.FC = ({ WebkitBackdropFilter: GLASS_SURFACE_BLUR, boxShadow: '0 6px 20px rgba(0,0,0,0.3)', }), - ...(!(expanded && !tiledStyle) && { borderRadius: 1 }), + ...(!(expanded && !isTiled) && { borderRadius: 1 }), }} > = ({ display: 'flex', flexDirection: 'column', overflow: 'hidden', - borderRadius: tiledStyle ? undefined : '20px', + borderRadius: isTiled ? undefined : '20px', }} > diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 5be4ddc1..1023410a 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -42,7 +42,7 @@ import { type BrowserTab, } from '@/shared/state/dashboardLayoutSlice'; import WindowControls from './WindowControls'; -import { useTiledStyle } from './tileZones'; +import { useTiledCard } from './useTiledCard'; import { useCardTiling } from './useCardTiling'; import { getMinimizedShot, saveMinimizedShot } from '../desktop/minimizedShots'; import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; @@ -234,17 +234,7 @@ const BrowserCard: React.FC = ({ }, [dispatch, browserId]); const tiling = useCardTiling({ cardId: browserId, getCanvasState, commitPosition: commitCardPosition }); const tileZone = tiling.zone; - // Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter. - const [tileTick, setTileTick] = useState(0); - useEffect(() => { - if (!tileZone) return undefined; - const onPan = (): void => setTileTick((t) => t + 1); - window.addEventListener('openswarm:canvas-pan-changed', onPan); - return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan); - }, [tileZone]); - void tileTick; - const cam = getCanvasState(); - const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, browserId); + const isTiled = !!tileZone; const onTile = tiling.applyZone; // ---- In-chat dock: while docked to an expanded chat, the card overlays the chat's slot rect. @@ -1015,12 +1005,13 @@ const BrowserCard: React.FC = ({ ? `0 0 0 1px #3b82f6, ${c.shadow.md}` : c.shadow.md; - const dockActive = !!dockRect && !dragging && !localResize && !tiledStyle && !keepAliveHidden && !isMinimized; + const dockActive = !!dockRect && !dragging && !localResize && !isTiled && !keepAliveHidden && !isMinimized; // Chat collapsed: its docked browser parks off-screen and lives on as the pill's frozen shot, // instead of teleporting back to wherever it sat before docking. The park waits for that shot: // an off-screen guest never paints again, and capturePage on one never settles (Electron 42). - const wantsDockPark = !!dockedTo && !!dockParentCard && !dockParentExpanded && !dragging && !tiledStyle && !isMinimized && !keepAliveHidden; + const wantsDockPark = !!dockedTo && !!dockParentCard && !dockParentExpanded && !dragging && !isTiled && !isMinimized && !keepAliveHidden; const dockParked = wantsDockPark && pillShotSettled; + const tiledSize = useTiledCard({ cardId: browserId, zone: tileZone, active: !keepAliveHidden && !isMinimized && !dockParked, originX: displayX, originY: displayY, getCamera: getCanvasState }); const pillShotPaintable = !!pillShotOwner && !dockParked && !isMinimized && !keepAliveHidden && !suspendedSnap; useEffect(() => { if (!pillShotPaintable) return undefined; @@ -1098,12 +1089,12 @@ const BrowserCard: React.FC = ({ contain: 'layout style', // Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale. willChange: 'transform', - left: keepAliveHidden || isMinimized || dockParked ? -100000 : (tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX)), - top: tiledStyle && !(keepAliveHidden || isMinimized || dockParked) ? tiledStyle.top : dockActive ? dockRect!.y : (dragging ? cardY : displayY), - transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined), - transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, - width: tiledStyle ? tiledStyle.width : dockActive ? dockRect!.w : displayW, - height: tiledStyle ? tiledStyle.height : dockActive ? dockRect!.h : displayH, + left: keepAliveHidden || isMinimized || dockParked ? -100000 : (dockActive ? dockRect!.x : (dragging ? cardX : displayX)), + top: dockActive ? dockRect!.y : (dragging ? cardY : displayY), + transform: tiledSize ? undefined : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined), + transformOrigin: tiledSize ? '0 0' : undefined, + width: tiledSize ? tiledSize.width : dockActive ? dockRect!.w : displayW, + height: tiledSize ? tiledSize.height : dockActive ? dockRect!.h : displayH, borderRadius: tileZone === 'fullscreen' ? '12px' : dockActive ? '10px' : `${c.radius.lg}px`, border: agentBorder, bgcolor: c.bg.surface, @@ -1111,7 +1102,7 @@ const BrowserCard: React.FC = ({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder, + zIndex: isTiled ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder, transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease', '&:hover .resize-handle': { opacity: 1 }, ...(isHighlighted && { diff --git a/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx b/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx index 82369dd2..5ccd90d4 100644 --- a/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { useTiledStyle } from './tileZones'; +import { useTiledCard } from './useTiledCard'; import { useCardTiling } from './useCardTiling'; import { useCanvasWindowResize } from './useCanvasWindowResize'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; @@ -66,17 +66,6 @@ const CanvasWindowCard: React.FC = ({ }) => { const c = useClaudeTokens(); const tiling = useCardTiling({ cardId, getCanvasState, commitPosition: onCommitPosition }); - // A tile pins the card to the viewport, so its geometry must track pan/zoom like the tiled - // agent/browser cards; reuse the exact same helper. Subscribe to pan only while tiled. - const [, forceTick] = useState(0); - useEffect(() => { - if (!tiling.isTiled) return undefined; - const onPan = (): void => forceTick((t) => t + 1); - window.addEventListener('openswarm:canvas-pan-changed', onPan); - return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan); - }, [tiling.isTiled]); - const cam = getCanvasState(); - const tiledStyle = useTiledStyle(tiling.zone, cam.panX, cam.panY, cam.zoom, getCanvasState, cardId); // ---- Drag (title bar is the handle) ---- const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); @@ -169,6 +158,7 @@ const CanvasWindowCard: React.FC = ({ const dy = (localResize?.y ?? localDragPos?.y ?? cardY) + mdDy; const dw = localResize?.w ?? cardWidth; const dh = localResize?.h ?? cardHeight; + const tiledSize = useTiledCard({ cardId, zone: tiling.zone, active: !minimized, originX: dx, originY: dy, getCamera: getCanvasState }); const border = isHighlighted ? `2px solid ${highlightColor}` : isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.subtle}`; const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); @@ -196,14 +186,12 @@ const CanvasWindowCard: React.FC = ({ willChange: 'transform', // Parked windows go off-canvas rather than unmounting, so Settings keeps its form and Workflows its view state. pointerEvents: minimized ? 'none' : undefined, - // Belt and braces: leaving fullscreen tears down the tiled-style hook, whose cleanup strips the inline left/top React just wrote, and visibility is the one park signal it never touches. visibility: minimized ? 'hidden' : undefined, - left: minimized ? -100000 : tiledStyle ? tiledStyle.left : dx, - top: minimized ? -100000 : tiledStyle ? tiledStyle.top : dy, - width: tiledStyle ? tiledStyle.width : dw, - height: tiledStyle ? tiledStyle.height : dh, - transform: minimized ? undefined : tiledStyle ? tiledStyle.transform : undefined, - transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, + left: minimized ? -100000 : dx, + top: minimized ? -100000 : dy, + width: tiledSize ? tiledSize.width : dw, + height: tiledSize ? tiledSize.height : dh, + transformOrigin: tiledSize ? '0 0' : undefined, background, border: tiling.isFullscreen ? 'none' : border, borderRadius: c.radius.lg, @@ -211,7 +199,7 @@ const CanvasWindowCard: React.FC = ({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, + zIndex: tiledSize ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease', }} > diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 4422020e..c817ef38 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -24,7 +24,7 @@ import WindowControls from './WindowControls'; import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu'; import { viewCardMenuRows } from './viewCardMenuRows'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; -import { useTiledStyle } from './tileZones'; +import { useTiledCard } from './useTiledCard'; import { useCardTiling } from './useCardTiling'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { API_BASE, getAuthToken } from '@/shared/config'; @@ -167,17 +167,7 @@ const DashboardViewCard: React.FC = ({ useEffect(() => { if (previewDeferred && (isSelected || interactive)) dispatch(activateViewCardPreview(cardKey)); }, [previewDeferred, isSelected, interactive, cardKey, dispatch]); - // Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter. - const [tileTick, setTileTick] = useState(0); - useEffect(() => { - if (!tileZone) return undefined; - const onPan = (): void => setTileTick((t) => t + 1); - window.addEventListener('openswarm:canvas-pan-changed', onPan); - return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan); - }, [tileZone]); - void tileTick; - const cam = getCanvasState(); - const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, cardKey); + const isTiled = !!tileZone; const isFullscreen = tileZone === 'fullscreen'; // ---- In-chat dock (mirrors BrowserCard): while docked to an expanded chat, overlay its slot rect. @@ -635,7 +625,8 @@ const DashboardViewCard: React.FC = ({ const dragTx = dragging ? displayX - cardX : 0; const dragTy = dragging ? displayY - cardY : 0; - const dockActive = !!dockRect && !dragging && !localResize && !tiledStyle && !isMinimized; + const dockActive = !!dockRect && !dragging && !localResize && !isTiled && !isMinimized; + const tiledSize = useTiledCard({ cardId: cardKey, zone: tileZone, active: !isMinimized, originX: displayX, originY: displayY, getCamera: getCanvasState }); return ( = ({ // Minimized apps live in the right-edge rail, so the card itself parks off-canvas at full size // (same trick as browser cards) and restores to exactly the geometry it left. pointerEvents: isMinimized ? 'none' : undefined, - left: isMinimized ? -100000 : (tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX)), - top: isMinimized ? -100000 : (tiledStyle ? tiledStyle.top : (dragging ? cardY : displayY)), - width: tiledStyle ? tiledStyle.width : displayW, - height: tiledStyle ? tiledStyle.height : displayH, - transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined), - transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, + left: isMinimized ? -100000 : (dockActive ? dockRect!.x : (dragging ? cardX : displayX)), + top: isMinimized ? -100000 : (dragging ? cardY : displayY), + width: tiledSize ? tiledSize.width : displayW, + height: tiledSize ? tiledSize.height : displayH, + transform: tiledSize ? undefined : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined), + transformOrigin: tiledSize ? '0 0' : undefined, borderRadius: isFullscreen ? '12px' : `${c.radius.lg}px`, border: isHighlighted ? `2px solid ${c.accent.primary}` @@ -702,7 +693,7 @@ const DashboardViewCard: React.FC = ({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder, + zIndex: isTiled ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder, transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease', '&:hover .resize-handle': { opacity: 1 }, ...(isHighlighted && { diff --git a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx index d92c4437..3d0f2462 100644 --- a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx +++ b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx @@ -1,6 +1,7 @@ import React, { useRef, useState } from 'react'; import Box from '@mui/material/Box'; -import { TILE_GROUPS, TILE_ZONES, ZONE_LABELS } from './tileZones'; +import { TILE_ZONES } from '../canvas/tiledGeometry'; +import { TILE_GROUPS, ZONE_LABELS } from './tileZones'; interface WindowControlsProps { onClose: () => void; diff --git a/frontend/src/app/pages/Dashboard/cards/tileZones.ts b/frontend/src/app/pages/Dashboard/cards/tileZones.ts index 15eb3f21..a09979a4 100644 --- a/frontend/src/app/pages/Dashboard/cards/tileZones.ts +++ b/frontend/src/app/pages/Dashboard/cards/tileZones.ts @@ -1,25 +1,4 @@ -import React from 'react'; - -// macOS-style tiling zones (fractions of the workspace) + the math to render a tiled card at a -// screen-space viewport region WITHOUT leaving the transformed canvas layer, so a browser/app -// card's webview is never remounted (which would log the user out). The card stays a child of the -// pan/zoom layer; we counter-transform (scale 1/zoom) and place it in canvas coords so it lands -// pixel-exact at the viewport region at 100% content scale. Recompute on pan/zoom to stay put. - -export const TILE_ZONES: Record = { - fill: { x: 0, y: 0, w: 1, h: 1 }, - left: { x: 0, y: 0, w: 0.5, h: 1 }, - right: { x: 0.5, y: 0, w: 0.5, h: 1 }, - top: { x: 0, y: 0, w: 1, h: 0.5 }, - bottom: { x: 0, y: 0.5, w: 1, h: 0.5 }, - tl: { x: 0, y: 0, w: 0.5, h: 0.5 }, - tr: { x: 0.5, y: 0, w: 0.5, h: 0.5 }, - bl: { x: 0, y: 0.5, w: 0.5, h: 0.5 }, - br: { x: 0.5, y: 0.5, w: 0.5, h: 0.5 }, - t3l: { x: 0, y: 0, w: 1 / 3, h: 1 }, - t3c: { x: 1 / 3, y: 0, w: 1 / 3, h: 1 }, - t3r: { x: 2 / 3, y: 0, w: 1 / 3, h: 1 }, -}; +// The zone catalog the tiling MENUS render. The geometry itself lives in canvas/tiledGeometry. export const ZONE_LABELS: Record = { fill: 'Fill', left: 'Left half', right: 'Right half', top: 'Top half', bottom: 'Bottom half', @@ -34,176 +13,3 @@ export const TILE_GROUPS: { label: string; zones: string[] }[] = [ { label: 'Quarters', zones: ['tl', 'tr', 'bl', 'br'] }, { label: 'Thirds', zones: ['t3l', 't3c', 't3r'] }, ]; - -// macOS Sequoia leaves a small gap between tiled windows; we match it. -const GAP = 8; - -export interface TiledStyle { - left: number; - top: number; - width: number; - height: number; - transform: string; - transformOrigin: string; -} - -// The workspace = the canvas viewport element (already below the app header), measured live so we -// never hardcode chrome sizes that drift. Its screen origin cancels out of the math below because -// the card shares the viewport's coordinate system. x0 insets the LEFT edge past the floating dock -// rail (macOS model: tiled windows start beside the Dock, never under it), which is what keeps -// fullscreen AND half/quarter tiles clear of it. -function workspaceRect(): { x0: number; w: number; h: number } { - const el = document.querySelector('[data-canvas-viewport]'); - const r = el?.getBoundingClientRect(); - if (!r || !(r.width > 0 && r.height > 0)) return { x0: 0, w: window.innerWidth, h: window.innerHeight }; - let x0 = 0; - const dock = document.querySelector('[data-desktop-dock]'); - if (dock) { - const dr = dock.getBoundingClientRect(); - if (dr.width > 0 && dr.right > r.left && dr.left < r.left + r.width * 0.25) x0 = dr.right - r.left + GAP; - } - return { x0, w: r.width, h: r.height }; -} - -export function computeTiledStyle(zone: string, panX: number, panY: number, zoom: number): TiledStyle | null { - // 'fullscreen' = the card expands into the ENTIRE dashboard, floating on the same gap the tile - // zones use. Anchored to the VIEWPORT (not the window) so docked chrome, like the pinned sidebar, - // squeezes the card instead of being covered by it (the canvas viewport shrinks with the sidebar). - if (zone === 'fullscreen') { - const { x0, w: vpW, h: vpH } = workspaceRect(); - return { - left: (x0 + GAP - panX) / zoom, - top: (GAP - panY) / zoom, - width: vpW - x0 - GAP * 2, - height: vpH - GAP * 2, - transform: `scale(${1 / zoom})`, - transformOrigin: 'top left', - }; - } - const z = TILE_ZONES[zone]; - if (!z) return null; - const { x0, w: vpW, h: vpH } = workspaceRect(); - const usableW = vpW - x0; - // Screen region (vpX + GAP, vpY + GAP, ...) converted to canvas coords: card lives inside the - // pan/zoom layer, so screen = viewportOrigin + pan + canvasPos*zoom, and viewportOrigin cancels. - return { - left: (x0 + z.x * usableW + GAP - panX) / zoom, - top: (z.y * vpH + GAP - panY) / zoom, - width: z.w * usableW - GAP * 2, - height: z.h * vpH - GAP * 2, - transform: `scale(${1 / zoom})`, - transformOrigin: 'top left', - }; -} - -// 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, - // 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; - const el = document.querySelector('[data-canvas-viewport]'); - const ro = new ResizeObserver(() => bump()); - if (el) ro.observe(el); - const onResize = (): void => bump(); - window.addEventListener('resize', onResize); - // The chrome collapse commits in the same flush that set the zone, so the first compute - // measures the pre-collapse viewport; re-measure after layout settles. Timeouts, not rAF: - // rAF (and ResizeObserver delivery, which rides it) freezes in non-focused tabs. - // 700ms outlives the banner Collapse (350ms) plus easing tail; RO covers focused tabs live. - 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; - // left/top belong on whichever element OWNS position: AgentCard splits into a position:absolute - // motion wrapper around a position:relative inner Box (writing left/top on the inner Box ADDS to - // the wrapper's, rendering the tile at exactly double the offset); Browser/View cards are one - // element. Size + counter-scale always live on the select-id element, mirroring the sx path. - const posEl = getComputedStyle(el).position === 'absolute' || getComputedStyle(el).position === 'fixed' - ? el - : (el.parentElement as HTMLElement | null) ?? el; - const posProps = ['left', 'top']; - const sizeProps = ['width', 'height', 'transform', 'transform-origin', 'transition']; - // Drop ONLY the overrides this hook still owns: React re-asserts these props without the important - // flag when a card leaves its zone, and deleting those left the window with no geometry at all. - const clearOwn = (target: HTMLElement, props: string[]): void => { - props.forEach((pr) => { if (target.style.getPropertyPriority(pr) === 'important') target.style.removeProperty(pr); }); - }; - const clearAll = (): void => { - clearOwn(posEl, posProps); - clearOwn(el, sizeProps); - }; - const apply = (): void => { - // A parked card (kept alive off-screen / minimized) must keep its sx parking position. - if (el.getAttribute('data-keepalive-hidden') === '1') { clearAll(); return; } - const cam = getLive(); - const s = computeTiledStyle(zone, cam.panX, cam.panY, cam.zoom); - if (!s) return; - // 'important' so framer-motion's own left/top writes (computed off the LAGGING React-committed - // camera) can't land after us and shift the tile by the stale-camera delta. - posEl.style.setProperty('left', `${s.left}px`, 'important'); - posEl.style.setProperty('top', `${s.top}px`, 'important'); - el.style.setProperty('width', `${s.width}px`, 'important'); - el.style.setProperty('height', `${s.height}px`, 'important'); - el.style.setProperty('transform', s.transform, 'important'); - el.style.setProperty('transform-origin', s.transformOrigin, 'important'); - // Transitions OUTRANK inline important in the cascade, and in an occluded window they pin at - // t=0 forever, freezing the tile at stale geometry. Tiled cards track the camera instantly - // by design, so geometry must never animate here. - el.style.setProperty('transition', 'none', 'important'); - }; - apply(); - window.addEventListener('openswarm:canvas-pan-changed', apply); - window.addEventListener('resize', apply); - // An occluded window freezes rAF (framer, the tracker below), so geometry set while hidden can - // land stale; re-apply the moment the window is visible again. - document.addEventListener('visibilitychange', apply); - window.addEventListener('focus', apply); - // The workspace can change size WITHOUT a window resize (chrome collapsing on fullscreen-enter, - // a banner appearing). These inline writes OVERRIDE the class styles, so they must re-measure on - // the same signals the React path uses or the tile keeps the pre-collapse viewport (the - // fullscreen card that stopped short of the bottom edge). - const vp = document.querySelector('[data-canvas-viewport]'); - const ro = new ResizeObserver(apply); - if (vp) ro.observe(vp); - const timers = [60, 250, 700].map((ms) => window.setTimeout(apply, ms)); - // animateTo moves the camera WITHOUT emitting pan-changed, and the important-priority writes - // above block React's late self-heal, so a tiled card must track the live camera itself: - // a write-on-change rAF loop (frozen while hidden is fine; nothing moves visually then). - let raf = 0; - let lastKey = ''; - const tick = (): void => { - const cam = getLive(); - const size = workspaceRect(); - const key = `${cam.panX}:${cam.panY}:${cam.zoom}:${size.x0}:${size.w}:${size.h}`; - if (key !== lastKey) { lastKey = key; apply(); } - raf = window.requestAnimationFrame(tick); - }; - raf = window.requestAnimationFrame(tick); - return () => { - window.removeEventListener('openswarm:canvas-pan-changed', apply); - window.removeEventListener('resize', apply); - document.removeEventListener('visibilitychange', apply); - window.removeEventListener('focus', apply); - ro.disconnect(); - timers.forEach((tm) => window.clearTimeout(tm)); - window.cancelAnimationFrame(raf); - clearAll(); - }; - }, [zone, getLive, selectId]); - return zone ? computeTiledStyle(zone, panX, panY, zoom) : null; -} diff --git a/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts b/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts index 944ec877..5ca609ed 100644 --- a/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts +++ b/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { setTiledCard, clearTiledCard } from '@/shared/state/dashboardLayoutSlice'; -import { computeTiledStyle } from './tileZones'; +import { zoneRect } from '../canvas/tiledGeometry'; // THE tiling rule set. Every card that can sit on the canvas (chat, app, browser, workflow, the // Settings window, the Workflows window) routes through this hook, so tiling behaves identically @@ -50,14 +50,14 @@ export function useCardTiling({ cardId, getCanvasState, commitPosition }: CardTi else dispatch(setTiledCard({ cardId, zone: next })); }, [dispatch, cardId]); - // The rect a tiled card occupies, in canvas coords. computeTiledStyle sizes in SCREEN px (the card - // is counter-scaled by 1/zoom), so the footprint has to come back through the zoom. + // The rect a tiled card occupies, in canvas coords. zoneRect is in SCREEN px (the card is + // counter-scaled by 1/zoom), so the footprint has to come back through the zoom. const tiledFrame = useCallback((): CardFrame | null => { if (!zone) return null; const cam = getCanvasState(); - const style = computeTiledStyle(zone, cam.panX, cam.panY, cam.zoom); - if (!style) return null; - return { x: style.left, y: style.top, w: style.width / cam.zoom, h: style.height / cam.zoom }; + const r = zoneRect(zone); + if (!r) return null; + return { x: (r.x - cam.panX) / cam.zoom, y: (r.y - cam.panY) / cam.zoom, w: r.w / cam.zoom, h: r.h / cam.zoom }; }, [zone, getCanvasState]); const untileForResize = useCallback((): CardFrame | null => { diff --git a/frontend/src/app/pages/Dashboard/cards/useTiledCard.ts b/frontend/src/app/pages/Dashboard/cards/useTiledCard.ts new file mode 100644 index 00000000..65926bfb --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/useTiledCard.ts @@ -0,0 +1,40 @@ +import { useEffect, useLayoutEffect, useReducer, useRef } from 'react'; +import { registerTiledCard, subscribeTiledWorkspace, unregisterTiledCard, zoneRect, type Camera } from '../canvas/tiledGeometry'; + +export interface TiledSize { + width: number; + height: number; +} + +interface TiledCardArgs { + cardId: string; + zone: string | undefined; + /** False while the card is parked (minimized, docked, kept alive off-screen), where React owns geometry outright. */ + active: boolean; + /** The card's canvas-space left/top exactly as React renders it, tiled or not. */ + originX: number; + originY: number; + getCamera: () => Camera; +} + +// Hands back the card's tiled SIZE and nothing else. The camera-dependent half of the rect is +// written by canvas/tiledGeometry alone, so a tile can never be pinned to one camera while the +// canvas paints another. +export function useTiledCard({ cardId, zone, active, originX, originY, getCamera }: TiledCardArgs): TiledSize | null { + const [, bump] = useReducer((n: number) => n + 1, 0); + const cameraRef = useRef(getCamera); + cameraRef.current = getCamera; + const on = !!zone && active; + + useEffect(() => (on ? subscribeTiledWorkspace(bump) : undefined), [on]); + + useLayoutEffect(() => { + if (!on || !zone) return undefined; + registerTiledCard(cardId, zone, { x: originX, y: originY }, cameraRef.current()); + return () => unregisterTiledCard(cardId); + }, [on, zone, cardId, originX, originY]); + + if (!on || !zone) return null; + const r = zoneRect(zone); + return r ? { width: r.w, height: r.h } : null; +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 390683e5..c8a3caf6 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -6,6 +6,7 @@ import { getLastInteractedBrowser } from '@/shared/browserFocus'; import { getScrollFocusedCard } from '@/shared/cardScrollFocus'; import { getWebview } from '@/shared/browserRegistry'; import { applyBrowserZoom } from '@/shared/browserZoom'; +import { syncTiledGeometry } from '../../canvas/tiledGeometry'; const MIN_ZOOM = 0.15; const MAX_ZOOM = 3.0; @@ -89,6 +90,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: // Dot RADIUS lives in the committed backgroundImage and lags to gesture-end; at 1-4px dots the mid-pinch error is invisible and skipping the per-frame gradient rebuild keeps this handler pure style writes. grid.style.backgroundSize = `${spacing}px ${spacing}px`; } + // Tiled cards are counter-transformed against this exact camera, in this exact task: same write, + // same frame, so the tile and the canvas can never be painted from two different cameras. + syncTiledGeometry(stateRef.current); }, []); // Per-frame camera write during a gesture: DOM + live ref only, NO React commit. Dragging cards re-pin to the cursor off the pan-changed event, same signal the old per-frame commit produced.