diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index b61c88a0..5d661e6a 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -7,7 +7,7 @@ import { getScrollFocusedCard } from '@/shared/cardScrollFocus'; import { getWebview } from '@/shared/browserRegistry'; import { applyBrowserZoom } from '@/shared/browserZoom'; import { syncTiledGeometry } from '../../canvas/tiledGeometry'; -import { revealZoom } from '../../canvas/revealZoom'; +import { revealZoom, REVEAL_MIN_ZOOM } from '../../canvas/revealZoom'; const MIN_ZOOM = 0.15; // The floor for AUTOMATIC reveals only. revealCards takes min(current, fit), which can only ever go @@ -19,6 +19,10 @@ const MAX_ZOOM = 3.0; const ZOOM_IN_FACTOR = 1.1; const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR; const FIT_PADDING = 200; +// Tidy frames everything at once, so it gets its own tighter margin than a single-card fit. The wider +// x inset is the left dock, which floats over the canvas and would otherwise sit on the first column. +const TIDY_PADDING = { x: 120, y: 56 }; +const TIDY_MIN_ZOOM = REVEAL_MIN_ZOOM; // Card-framing (spawn, click-to-focus, arrow-nav) snaps as fast as the zoom buttons so a new card lands under you now, not after a lazy glide. const FIT_DURATION = 150; // Must outlast FIT_DURATION so the drift re-snap lands after the glide, never mid-flight. @@ -691,6 +695,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: maxZoom?: number, minZoom?: number, centered?: boolean, + padding?: { x: number; y: number }, ): { panX: number; panY: number; zoom: number } | null => { const viewport = viewportRef.current; if (!viewport || cardRects.length === 0) return null; @@ -711,8 +716,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const contentWidth = maxX - minX; const contentHeight = maxY - minY; - const availW = vRect.width - FIT_PADDING * 2; - const availH = vRect.height - FIT_PADDING * 2; + const padX = padding?.x ?? FIT_PADDING; + const padY = padding?.y ?? FIT_PADDING; + const availW = vRect.width - padX * 2; + const availH = vRect.height - padY * 2; const ceiling = maxZoom ?? MAX_ZOOM; const floor = minZoom ?? MIN_ZOOM; const targetZoom = clamp( @@ -720,12 +727,14 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: floor, ceiling, ); - const targetPanX = - (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom; + // Content wider/taller than the viewport only happens when the zoom floor bit; centering then hides both ends, so anchor the start of it instead. + const targetPanX = contentWidth * targetZoom > vRect.width + ? padX - minX * targetZoom + : (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom; // A single card normally top-biases (header up top, no dead space below). On creation we want the opposite: the new card dead-centered "in front of you", so `centered` forces true vertical centering. const topBiased = cardRects.length === 1 && !centered; - const targetPanY = topBiased - ? FIT_PADDING * 0.4 - minY * targetZoom + const targetPanY = topBiased || contentHeight * targetZoom > vRect.height + ? padY * 0.4 - minY * targetZoom : (vRect.height - contentHeight * targetZoom) / 2 - minY * targetZoom; return { panX: targetPanX, panY: targetPanY, zoom: targetZoom }; @@ -740,10 +749,11 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: animate?: boolean, minZoom?: number, centered?: boolean, + padding?: { x: number; y: number }, ) => { cancelAnimation(); - const target = computeFitTarget(cardRects, maxZoom, minZoom, centered); + const target = computeFitTarget(cardRects, maxZoom, minZoom, centered, padding); if (!target) { // Keep current camera; snapping to (0,0,1) used to desync the minimap. if (cardRects.length === 0 || !viewportRef.current) { @@ -761,7 +771,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: // Settle pass: cancelAnimation() must be able to cancel it, else back-to-back fitToCards races and the first settle overwrites the second target. settleTimerRef.current = window.setTimeout(() => { settleTimerRef.current = null; - const fresh = computeFitTarget(cardRects, maxZoom, minZoom, centered); + const fresh = computeFitTarget(cardRects, maxZoom, minZoom, centered, padding); if (!fresh) return; const cur2 = stateRef.current; const drift = @@ -777,6 +787,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: [cancelAnimation, animateTo, computeFitTarget, setCanvasState], ); + // The camera half of Tidy: frame the freshly gridded cards close in (the 200px fit padding was + // eating a third of the viewport), never below readable, and never magnified past life size. + const fitTidy = useCallback( + (cardRects: Array<{ x: number; y: number; width: number; height: number }>) => { + fitToCards(cardRects, 1, true, TIDY_MIN_ZOOM, false, TIDY_PADDING); + }, + [fitToCards], + ); + // Figma-style spawn camera: never zoom IN, never move if the cards are already on screen; otherwise the minimal pan that reveals them, zooming out only when they cannot fit at the current zoom. const revealCards = useCallback( (cardRects: Array<{ x: number; y: number; width: number; height: number }>) => { @@ -833,9 +852,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const getLiveState = useCallback((): CanvasState => stateRef.current, []); const actions = useMemo(() => ({ - zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, + zoomIn, zoomOut, resetZoom, fitToView, fitToCards, fitTidy, revealCards, animateTo, cancelAnimation, setState: setCanvasState, panBy, commit: commitLive, syncTransform: applyLiveToDom, getLiveState, - }), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]); + }), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, fitTidy, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]); return { ...state, diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts index 88c25191..750cd57e 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts @@ -111,6 +111,7 @@ export function useDashboardCardActions({ const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers, workflowCards: tidiedWorkflows, workflowsHub: tidiedHub, + workflowsMonitorCard: tidiedMonitor, settingsCard: tidiedSettings, } = store.getState().dashboardLayout; const allRects = [ ...Object.values(tidied).map((c) => ({ @@ -120,9 +121,12 @@ export function useDashboardCardActions({ ...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), ...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), ...Object.values(tidiedWorkflows).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), - ...(tidiedHub ? [{ x: tidiedHub.x, y: tidiedHub.y, width: tidiedHub.width, height: tidiedHub.height }] : []), + // The hub, the monitor and Settings get tidied into the grid too, so the camera has to know about them or it frames a stale box. + ...[tidiedHub, tidiedMonitor, tidiedSettings] + .filter((c): c is NonNullable => !!c) + .map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), ]; - canvasActions.fitToCards(allRects); + canvasActions.fitTidy(allRects); }, [dispatch, canvasActions]); return { diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index b835e99b..85d970d1 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -345,10 +345,11 @@ export function findOpenGridCell( occupiedRects: Rect[], newW: number, newH: number, + colLimit?: number, ): { x: number; y: number } { const cellW = DEFAULT_CARD_W + GRID_GAP; const cellH = DEFAULT_CARD_H + GRID_GAP; - const maxCols = Math.max( + const maxCols = colLimit ?? Math.max( 1, Math.floor((window.innerWidth - GRID_ORIGIN.x) / cellW) || GRID_COLS_FALLBACK, ); @@ -365,6 +366,34 @@ export function findOpenGridCell( } } +// Tidy packs into the grid shape that fills the SCREEN best. The default column count is derived from +// window.innerWidth, which is screen pixels pretending to be world units: it laid 8 cards out as a +// 2-wide, 4-tall ribbon that the camera then had to pull back to 41% to show. +export function tidyColumnCount(itemSizes: Array<{ w: number; h: number }>): number { + const cellW = DEFAULT_CARD_W + GRID_GAP; + const cellH = DEFAULT_CARD_H + GRID_GAP; + let cells = 0; + let widest = 1; + for (const s of itemSizes) { + const cols = Math.max(1, Math.ceil(s.w / cellW)); + cells += cols * Math.max(1, Math.ceil(s.h / cellH)); + widest = Math.max(widest, cols); + } + const vw = window.innerWidth || 1440; + const vh = window.innerHeight || 900; + let best = widest; + let bestZoom = 0; + for (let cols = widest; cols <= Math.max(widest, cells); cols++) { + const rows = Math.ceil(cells / cols); + const zoom = Math.min(vw / (cols * cellW), vh / (rows * cellH)); + if (zoom > bestZoom) { + bestZoom = zoom; + best = cols; + } + } + return best; +} + // Like findOpenGridCell but biased to stay near a proposed (x,y) anchor. Used when the backend hands us a card with a position that's already occupied (sub-agent or sub-browser spawning on top of its parent or a sibling). Spirals outward from the anchor on a grid, snapping to cell-aligned positions so the result still looks intentional, not dropped from orbit. Caps the spiral search at ~1000 cells to avoid pathological work in adversarial layouts, falls back to findOpenGridCell after that. Cost: O(rects × cells_scanned). Spawn events are rare (not per-frame), so this only runs when a new card appears. Typical scan resolves in <10 cells, well below the cap. No perf impact on steady-state UI. export function findOpenSpotNear( anchorX: number, @@ -768,19 +797,19 @@ const dashboardLayoutSlice = createSlice({ ]; allItems.sort((a, b) => a.y - b.y || a.x - b.x); + const sizeOf = (item: typeof allItems[number]): { w: number; h: number } => ({ + w: item.storedW, + h: item.kind === 'agent' && expanded.has(item.id) + ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) + : item.storedH, + }); + const cols = tidyColumnCount(allItems.map(sizeOf)); const placedRects: Rect[] = []; for (const item of allItems) { - let w: number, h: number; - if (item.kind === 'agent') { - w = item.storedW; - h = expanded.has(item.id) ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) : item.storedH; - } else { - w = item.storedW; - h = item.storedH; - } + const { w, h } = sizeOf(item); - const pos = findOpenGridCell(placedRects, w, h); + const pos = findOpenGridCell(placedRects, w, h, cols); placedRects.push({ x: pos.x, y: pos.y, w, h }); if (item.kind === 'agent') {