[eric] tiling: one writer owns a tiled card's rect, so a stale camera can't freeze or whiten it

This commit is contained in:
ciregenz
2026-07-30 20:45:54 -07:00
parent d9c2b728d5
commit 0c201eb2a2
10 changed files with 260 additions and 295 deletions
@@ -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<string, { x: number; y: number; w: number; h: number }> = {
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<string, TiledEntry>();
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<HTMLElement>(`[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();
}
@@ -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<Props> = ({
};
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<Props> = ({
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<Props> = ({
<motion.div
layout={false}
initial={spawnInitial}
animate={{ opacity: 1, scale: 1, left: tiledStyle ? tiledStyle.left : activeX, top: tiledStyle ? tiledStyle.top : activeY }}
animate={{ opacity: 1, scale: 1, left: activeX, top: activeY }}
exit={exitAnimation}
// While tiled the card is pinned to the viewport: position must track pan instantly, never spring.
transition={tiledStyle ? { ...spawnTransition, left: { duration: 0 }, top: { duration: 0 } } : spawnTransition}
transition={isTiled ? { ...spawnTransition, left: { duration: 0 }, top: { duration: 0 } } : spawnTransition}
onPointerDownCapture={() => onBringToFront?.(session.id, 'agent')}
style={{
position: 'absolute',
zIndex: tiledStyle ? 999990 : isDragging || isResizing ? 999999 : cardZOrder,
zIndex: isTiled ? 999990 : isDragging || isResizing ? 999999 : cardZOrder,
}}
>
<Box
@@ -821,7 +810,7 @@ const AgentCard: React.FC<Props> = ({
// 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<Props> = ({
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<Props> = ({
...(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<Props> = ({
{/* 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 && (
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
@@ -1056,7 +1044,7 @@ const AgentCard: React.FC<Props> = ({
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<Props> = ({
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<Props> = ({
WebkitBackdropFilter: GLASS_SURFACE_BLUR,
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
}),
...(!(expanded && !tiledStyle) && { borderRadius: 1 }),
...(!(expanded && !isTiled) && { borderRadius: 1 }),
}}
>
<InlineEditableTitle
@@ -1226,7 +1214,7 @@ const AgentCard: React.FC<Props> = ({
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRadius: tiledStyle ? undefined : '20px',
borderRadius: isTiled ? undefined : '20px',
}}
>
<DarkTokensScope>
@@ -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<Props> = ({
}, [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<Props> = ({
? `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<Props> = ({
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<Props> = ({
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 && {
@@ -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<CanvasWindowCardProps> = ({
}) => {
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<CanvasWindowCardProps> = ({
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<CanvasWindowCardProps> = ({
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<CanvasWindowCardProps> = ({
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',
}}
>
@@ -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<Props> = ({
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<Props> = ({
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 (
<Box
@@ -675,12 +666,12 @@ const DashboardViewCard: React.FC<Props> = ({
// 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<Props> = ({
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 && {
@@ -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;
@@ -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<string, { x: number; y: number; w: number; h: number }> = {
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<string, string> = {
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;
}
@@ -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 => {
@@ -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;
}
@@ -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.