diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index af6f0ca1..76daa970 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -32,11 +32,11 @@ import { clearGlowingAgentCard, removeCard, recordClosedCard, - setTiledCard, clearTiledCard, } from '@/shared/state/dashboardLayoutSlice'; import WindowControls, { ARC_CHIP_SX } from './WindowControls'; -import { useTiledStyle, computeTiledStyle } from './tileZones'; +import { useTiledStyle } from './tileZones'; +import { useCardTiling } from './useCardTiling'; import AgentNarratorPill from '../desktop/AgentNarratorPill'; import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu'; import { agentCardMenuRows } from './agentCardMenuRows'; @@ -446,20 +446,29 @@ const AgentCard: React.FC = ({ const justDraggedRef = useRef(false); const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 }); - const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[session.id]); + const commitPosition = useCallback((x: number, y: number) => { + dispatch(setCardPosition({ sessionId: session.id, x, y })); + }, [dispatch, session.id]); + const tiling = useCardTiling({ cardId: session.id, getCanvasState, commitPosition }); + const tileZone = tiling.zone; const handleDragPointerDown = useCallback((e: React.PointerEvent) => { if (e.button !== 0) return; - if (tileZone) return; e.preventDefault(); e.stopPropagation(); const cs = getCanvasState(); - dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY }; + const popped = tiling.untileForDrag(e.clientX, e.clientY, cardWidth); + dragState.current = { + startX: e.clientX, startY: e.clientY, + origX: popped?.x ?? cardX, origY: popped?.y ?? cardY, + startPanX: cs.panX, startPanY: cs.panY, + }; + if (popped) setLocalDragPos(popped); lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; didDrag.current = false; setIsDragging(true); try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone */ } onDragStart?.(session.id, 'agent'); - }, [cardX, cardY, onDragStart, session.id, getCanvasState, tileZone]); + }, [cardX, cardY, cardWidth, onDragStart, session.id, getCanvasState, tiling]); const recomputeDragPos = useCallback(() => { const ds = dragState.current; @@ -564,17 +573,10 @@ const AgentCard: React.FC = ({ let effectiveY = cardY; let effectiveW = Math.max(cardWidth, MIN_W); let effectiveH = expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : cardHeight; - // Grabbing an edge of a TILED chat exits the tile and resizes from exactly where it sat, - // macOS-style; without this the handles resized the stale free-position geometry. - if (tileZone) { - const cam = getCanvasState(); - const ts = computeTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom); - if (ts) { - effectiveX = ts.left; effectiveY = ts.top; - effectiveW = ts.width / cam.zoom; effectiveH = ts.height / cam.zoom; - setLocalResize({ x: effectiveX, y: effectiveY, w: effectiveW, h: effectiveH }); - dispatch(clearTiledCard(session.id)); - } + const popped = tiling.untileForResize(); + if (popped) { + effectiveX = popped.x; effectiveY = popped.y; effectiveW = popped.w; effectiveH = popped.h; + setLocalResize({ x: effectiveX, y: effectiveY, w: effectiveW, h: effectiveH }); } resizeRef.current = { dir, @@ -588,7 +590,7 @@ const AgentCard: React.FC = ({ setIsResizing(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); }, - [cardX, cardY, cardWidth, cardHeight, expanded, tileZone, dispatch, session.id], + [cardX, cardY, cardWidth, cardHeight, expanded, tiling], ); const computeResize = useCallback( @@ -678,11 +680,9 @@ const AgentCard: React.FC = ({ }, [tileZone, expanded, dispatch, session.id]); const onMinimize = (): void => { dispatch(collapseSession(session.id)); }; const onTile = (zone: string): void => { - if (zone === 'restore') dispatch(clearTiledCard(session.id)); - else { - if (!expanded) dispatch(expandSession(session.id)); - dispatch(setTiledCard({ cardId: session.id, zone })); - } + // A collapsed chat has nothing to fill a zone with, so tiling one opens it first. + if (zone !== 'restore' && !expanded) dispatch(expandSession(session.id)); + tiling.applyZone(zone); }; @@ -1114,7 +1114,7 @@ const AgentCard: React.FC = ({ onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', mr: 0.75, flexShrink: 0 }} > - handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} /> + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> = ({ ); const browserAgentSession = useAppSelector(selectBrowserAgentSession); const isMinimized = useAppSelector((s) => Boolean(s.dashboardLayout.minimizedCards[browserId])); - const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[browserId]); + const commitCardPosition = useCallback((x: number, y: number) => { + dispatch(setBrowserCardPosition({ browserId, x, y })); + }, [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(() => { @@ -243,10 +245,7 @@ const BrowserCard: React.FC = ({ void tileTick; const cam = getCanvasState(); const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, browserId); - const onTile = useCallback((zone: string): void => { - if (zone === 'restore') dispatch(clearTiledCard(browserId)); - else dispatch(setTiledCard({ cardId: browserId, zone })); - }, [dispatch, browserId]); + const onTile = tiling.applyZone; // ---- In-chat dock: while docked to an expanded chat, the card overlays the chat's slot rect. // Pure geometry in the shared canvas layer (same DOM node), so the webview never remounts. @@ -780,17 +779,22 @@ const BrowserCard: React.FC = ({ const handleDragPointerDown = useCallback((e: React.PointerEvent) => { if (e.button !== 0) return; - if (tileZone) return; e.preventDefault(); e.stopPropagation(); const cs = getCanvasState(); - dragState.current = { startX: e.clientX, startY: e.clientY, origX: dockRect?.x ?? cardX, origY: dockRect?.y ?? cardY, startPanX: cs.panX, startPanY: cs.panY }; + const popped = tiling.untileForDrag(e.clientX, e.clientY, cardWidth); + dragState.current = { + startX: e.clientX, startY: e.clientY, + origX: popped?.x ?? dockRect?.x ?? cardX, origY: popped?.y ?? dockRect?.y ?? cardY, + startPanX: cs.panX, startPanY: cs.panY, + }; + if (popped) setLocalDragPos(popped); lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; didDrag.current = false; setIsDragging(true); try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone */ } onDragStart?.(browserId, 'browser'); - }, [cardX, cardY, onDragStart, browserId, getCanvasState, tileZone, dockRect]); + }, [cardX, cardY, cardWidth, onDragStart, browserId, getCanvasState, tiling, dockRect]); const recomputeDragPos = useCallback(() => { const ds = dragState.current; @@ -912,27 +916,16 @@ const BrowserCard: React.FC = ({ if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); - // Grabbing an edge of a TILED card exits the tile and resizes from exactly where it sat, - // macOS-style; without this the handles resized the stale free-position geometry. - let origX = cardX, origY = cardY, origW = cardWidth, origH = cardHeight; - const zone = store.getState().dashboardLayout.tiledCards[browserId]; - if (zone) { - const cam = getCanvasState(); - const ts = computeTiledStyle(zone, cam.panX, cam.panY, cam.zoom); - if (ts) { - origX = ts.left; origY = ts.top; origW = ts.width / cam.zoom; origH = ts.height / cam.zoom; - setLocalResize({ x: origX, y: origY, w: origW, h: origH }); - dispatch(clearTiledCard(browserId)); - } - } + const popped = tiling.untileForResize(); + if (popped) setLocalResize(popped); resizeRef.current = { dir, startX: e.clientX, startY: e.clientY, - origX, origY, origW, origH, + origX: popped?.x ?? cardX, origY: popped?.y ?? cardY, origW: popped?.w ?? cardWidth, origH: popped?.h ?? cardHeight, }; setIsResizing(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); }, - [cardX, cardY, cardWidth, cardHeight, getCanvasState, dispatch], + [cardX, cardY, cardWidth, cardHeight, tiling], ); const computeResize = useCallback( @@ -1177,7 +1170,7 @@ const BrowserCard: React.FC = ({ onMinimize={handleMinimize} onTile={onTile} tiled={!!tileZone} - noTileMenu={tileZone === 'fullscreen'} + /> void; } @@ -31,7 +34,6 @@ interface CanvasWindowCardProps { selectType: string; selectName: string; cardX: number; cardY: number; cardWidth: number; cardHeight: number; cardZOrder?: number; - fullscreen?: boolean; /** Parked in the minimized rail: stays mounted (and keeps its state) off-canvas instead of unmounting. */ minimized?: boolean; minWidth: number; minHeight: number; @@ -55,7 +57,7 @@ interface CanvasWindowCardProps { const CanvasWindowCard: React.FC = ({ cardId, cardType, selectType, selectName, cardX, cardY, cardWidth, cardHeight, cardZOrder = 0, - fullscreen = false, minimized = false, minWidth, minHeight, background, highlightColor, + minimized = false, minWidth, minHeight, background, highlightColor, getCanvasState, isSelected = false, isHighlighted = false, multiDragDelta = null, onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront, @@ -63,17 +65,18 @@ const CanvasWindowCard: React.FC = ({ children, }) => { const c = useClaudeTokens(); - // Fullscreen 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 fullscreen. + 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 (!fullscreen) return undefined; + 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); - }, [fullscreen]); + }, [tiling.isTiled]); const cam = getCanvasState(); - const fsStyle = useTiledStyle(fullscreen ? 'fullscreen' : undefined, cam.panX, cam.panY, cam.zoom, getCanvasState, cardId); + 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); @@ -85,18 +88,23 @@ const CanvasWindowCard: React.FC = ({ const onHeaderPointerDown = useCallback((e: React.PointerEvent) => { if (e.button !== 0) return; - if (fullscreen) return; // pinned to the viewport, no drag until restored const target = e.target as HTMLElement; if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return; e.preventDefault(); e.stopPropagation(); const cs = getCanvasState(); - dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY }; + const popped = tiling.untileForDrag(e.clientX, e.clientY, cardWidth); + dragState.current = { + startX: e.clientX, startY: e.clientY, + origX: popped?.x ?? cardX, origY: popped?.y ?? cardY, + startPanX: cs.panX, startPanY: cs.panY, + }; + if (popped) setLocalDragPos(popped); didDrag.current = false; setIsDragging(true); onDragStart?.(cardId, cardType); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - }, [cardId, cardType, cardX, cardY, fullscreen, onDragStart, getCanvasState]); + }, [cardId, cardType, cardX, cardY, cardWidth, tiling, onDragStart, getCanvasState]); const onHeaderPointerMove = useCallback((e: React.PointerEvent) => { if (!dragState.current) return; @@ -152,18 +160,9 @@ const CanvasWindowCard: React.FC = ({ const { isResizing, live: localResize, handles } = useCanvasWindowResize({ cardX, cardY, cardWidth, cardHeight, minWidth, minHeight, - getCanvasState, onCommitPosition, onCommitSize, + getCanvasState, onCommitPosition, onCommitSize, untileForResize: tiling.untileForResize, }); - const onTileZone = useCallback((zone: string) => { - const z = TILE_ZONES[zone]; - const vp = document.querySelector('[data-canvas-viewport]')?.getBoundingClientRect(); - if (!z || !vp) return; - const camera = getCanvasState(); - onCommitPosition((z.x * vp.width + TILE_GAP - camera.panX) / camera.zoom, (z.y * vp.height + TILE_GAP - camera.panY) / camera.zoom); - onCommitSize((z.w * vp.width - TILE_GAP * 2) / camera.zoom, (z.h * vp.height - TILE_GAP * 2) / camera.zoom); - }, [getCanvasState, onCommitPosition, onCommitSize]); - const mdDx = (!isDragging && !isResizing && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; const mdDy = (!isDragging && !isResizing && isSelected && multiDragDelta) ? multiDragDelta.dy : 0; const dx = (localResize?.x ?? localDragPos?.x ?? cardX) + mdDx; @@ -199,20 +198,20 @@ const CanvasWindowCard: React.FC = ({ 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 : fsStyle ? fsStyle.left : dx, - top: minimized ? -100000 : fsStyle ? fsStyle.top : dy, - width: fsStyle ? fsStyle.width : dw, - height: fsStyle ? fsStyle.height : dh, - transform: minimized ? undefined : fsStyle ? fsStyle.transform : undefined, - transformOrigin: fsStyle ? fsStyle.transformOrigin : 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, background, - border: fsStyle ? 'none' : border, + border: tiling.isFullscreen ? 'none' : border, borderRadius: c.radius.lg, boxShadow: (isDragging || isResizing) ? c.shadow.lg : c.shadow.md, overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: fsStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, + zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease', }} > @@ -225,10 +224,11 @@ const CanvasWindowCard: React.FC = ({ onLostPointerCapture: abortDrag, dragging: isDragging, }, - onTileZone, + tileZone: tiling.zone, + onTileZone: tiling.applyZone, })} - {!fullscreen && !minimized && handles.map((h) => ( + {!minimized && handles.map((h) => (
= ({ const appGlow = useAppSelector((s) => s.dashboardLayout.glowingBrowserCards[`app:${cardKeyProp ?? output.id}`]); const showAgentGlow = !!appGlow && !appGlow.fading; const interactive = activeViewCardId === cardKey; - const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[cardKey]); + const commitCardPosition = useCallback((x: number, y: number) => { + dispatch(setViewCardPosition({ outputId: cardKey, x, y })); + }, [dispatch, cardKey]); + const tiling = useCardTiling({ cardId: cardKey, getCanvasState, commitPosition: commitCardPosition }); + const tileZone = tiling.zone; const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[cardKey]); // Reveal-born apps stay a light "click to open" card until the first click, so the onboarding curtain // lifts instantly instead of behind an in-frame live Vite boot. The click (selecting it) clears the flag. @@ -390,17 +394,22 @@ const DashboardViewCard: React.FC = ({ const handleDragPointerDown = useCallback((e: React.PointerEvent) => { if (e.button !== 0) return; - if (tileZone) return; e.preventDefault(); e.stopPropagation(); const cs = getCanvasState(); - dragState.current = { startX: e.clientX, startY: e.clientY, origX: dockRect?.x ?? cardX, origY: dockRect?.y ?? cardY, startPanX: cs.panX, startPanY: cs.panY }; + const popped = tiling.untileForDrag(e.clientX, e.clientY, cardWidth); + dragState.current = { + startX: e.clientX, startY: e.clientY, + origX: popped?.x ?? dockRect?.x ?? cardX, origY: popped?.y ?? dockRect?.y ?? cardY, + startPanX: cs.panX, startPanY: cs.panY, + }; + if (popped) setLocalDragPos(popped); lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; didDrag.current = false; setIsDragging(true); try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone */ } onDragStart?.(cardKey, 'view'); - }, [cardX, cardY, onDragStart, cardKey, getCanvasState, tileZone]); + }, [cardX, cardY, cardWidth, onDragStart, cardKey, getCanvasState, tiling, dockRect]); const recomputeDragPos = useCallback(() => { const ds = dragState.current; @@ -505,27 +514,16 @@ const DashboardViewCard: React.FC = ({ if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); - // Grabbing an edge of a TILED card exits the tile and resizes from exactly where it sat, - // macOS-style; without this the handles resized the stale free-position geometry. - let origX = cardX, origY = cardY, origW = cardWidth, origH = cardHeight; - const zone = store.getState().dashboardLayout.tiledCards[cardKey]; - if (zone) { - const cam = getCanvasState(); - const ts = computeTiledStyle(zone, cam.panX, cam.panY, cam.zoom); - if (ts) { - origX = ts.left; origY = ts.top; origW = ts.width / cam.zoom; origH = ts.height / cam.zoom; - setLocalResize({ x: origX, y: origY, w: origW, h: origH }); - dispatch(clearTiledCard(cardKey)); - } - } + const popped = tiling.untileForResize(); + if (popped) setLocalResize(popped); resizeRef.current = { dir, startX: e.clientX, startY: e.clientY, - origX, origY, origW, origH, + origX: popped?.x ?? cardX, origY: popped?.y ?? cardY, origW: popped?.w ?? cardWidth, origH: popped?.h ?? cardHeight, }; setIsResizing(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); }, - [cardX, cardY, cardWidth, cardHeight, getCanvasState, dispatch], + [cardX, cardY, cardWidth, cardHeight, tiling], ); const computeResize = useCallback( @@ -587,10 +585,7 @@ const DashboardViewCard: React.FC = ({ park(); })(); }, [dispatch, cardKey]); - const onTile = (zone: string) => { - if (zone === 'restore') dispatch(clearTiledCard(cardKey)); - else dispatch(setTiledCard({ cardId: cardKey, zone })); - }; + const onTile = tiling.applyZone; // Spawn ANOTHER independent instance of this app (own runtime + ports); the reducer picks the next #N and the lifecycle hook fits + highlights it. const handleOpenAnother = (e: React.MouseEvent) => { @@ -777,7 +772,7 @@ const DashboardViewCard: React.FC = ({ }} > e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, mr: 0.25 }}> - handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} /> + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> void; onMinimize: () => void; - onTile: (zone: string) => void; // a TILE_ZONES key, or 'restore' + onTile: (zone: string) => void; // a TILE_ZONES key, 'fullscreen', or 'restore' tiled?: boolean; - // Green = direct fullscreen toggle, no Fill/Halves/Quarters submenu (for surfaces that only - // support fullscreen, like the Workflows window, where half-tiling has nowhere to land). - noTileMenu?: boolean; } // macOS-style traffic lights on every card = the "AI OS" window feel. Grey at rest so a canvas @@ -20,11 +17,6 @@ const RED = '#ff5f57'; const YELLOW = '#febc2e'; const GREEN = '#28c840'; -const GROUPS: { label: string; zones: string[] }[] = [ - { label: 'Fill & Halves', zones: ['fill', 'left', 'right', 'top', 'bottom'] }, - { label: 'Quarters', zones: ['tl', 'tr', 'bl', 'br'] }, -]; - const dotSx = (color: string): Record => ({ width: 12, height: 12, p: 0, m: 0, borderRadius: '50%', border: '0.5px solid rgba(0,0,0,0.06)', background: '#cccac4', cursor: 'pointer', position: 'relative', display: 'flex', alignItems: 'center', @@ -54,7 +46,7 @@ export const ARC_CHIP_SX: Record = { '.osw-pill-host:hover & .osw-window-lights > [data-light="zoom"]': { transform: 'translate(calc(-50% + 11px), calc(-50% + 5px)) scale(1)', opacity: 1, transitionDelay: '80ms' }, }; -function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: WindowControlsProps): React.ReactElement { +function WindowControls({ onClose, onMinimize, onTile, tiled }: WindowControlsProps): React.ReactElement { const [menuOpen, setMenuOpen] = useState(false); // Menu DOM (12 tiles + labels, ~30 nodes) mounts on first green-dot hover, not per card at boot. const [menuHot, setMenuHot] = useState(false); @@ -63,7 +55,6 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind const greenRef = useRef(null); const closeTimer = useRef(null); const openMenu = (): void => { - if (noTileMenu) return; if (closeTimer.current) window.clearTimeout(closeTimer.current); const rect = greenRef.current?.getBoundingClientRect(); if (rect) setAlignRight(rect.left + 224 > window.innerWidth); @@ -110,14 +101,14 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind opacity: menuOpen ? 1 : 0, transform: menuOpen ? 'none' : 'translateY(-6px) scale(0.96)', pointerEvents: menuOpen ? 'auto' : 'none', transition: 'opacity .16s, transform .18s cubic-bezier(.3,.9,.3,1)', }}> - {GROUPS.map((g) => ( + {TILE_GROUPS.map((g) => ( {g.label} {g.zones.map((zone) => { const z = TILE_ZONES[zone]; return ( - { e.stopPropagation(); setMenuOpen(false); onTile(zone); }} sx={{ position: 'relative', flex: 1, height: 32, border: '1px solid rgba(0,0,0,0.08)', borderRadius: '6px', background: '#F5F4ED', cursor: 'pointer', overflow: 'hidden', transition: 'border-color .12s, background .12s', '&:hover': { borderColor: '#ae5630', background: '#ae56300d' } }}> diff --git a/frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts index cd0a9c5b..d981538e 100644 --- a/frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts +++ b/frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts @@ -1,30 +1,11 @@ import type { CardMenuRow } from '../desktop/openCardContextMenu'; +import { TILE_GROUPS, ZONE_LABELS } from './tileZones'; -// The green-dot tiling grid, spelled out as words for the keyboard/right-click path. -const ZONE_LABELS: Record = { - fill: 'Fill', - left: 'Left half', - right: 'Right half', - top: 'Top half', - bottom: 'Bottom half', - tl: 'Top left', - tr: 'Top right', - bl: 'Bottom left', - br: 'Bottom right', - t3l: 'Left third', - t3c: 'Center third', - t3r: 'Right third', -}; - -const GROUPS: { label: string; zones: string[] }[] = [ - { label: 'Fill and halves', zones: ['fill', 'left', 'right', 'top', 'bottom'] }, - { label: 'Quarters', zones: ['tl', 'tr', 'bl', 'br'] }, - { label: 'Thirds', zones: ['t3l', 't3c', 't3r'] }, -]; - +// The green-dot tiling grid, spelled out as words for the right-click path. Same catalog, so the two +// surfaces always offer the same zones. export function tileMenuRows(onTile: (zone: string) => void, currentZone?: string): CardMenuRow[] { const rows: CardMenuRow[] = []; - for (const group of GROUPS) { + for (const group of TILE_GROUPS) { rows.push({ kind: 'header', label: group.label }); for (const zone of group.zones) { rows.push({ label: ZONE_LABELS[zone], checked: currentZone === zone, onClick: () => onTile(zone) }); diff --git a/frontend/src/app/pages/Dashboard/cards/tileZones.ts b/frontend/src/app/pages/Dashboard/cards/tileZones.ts index d01d53b4..15eb3f21 100644 --- a/frontend/src/app/pages/Dashboard/cards/tileZones.ts +++ b/frontend/src/app/pages/Dashboard/cards/tileZones.ts @@ -21,6 +21,20 @@ export const TILE_ZONES: Record = { + fill: 'Fill', left: 'Left half', right: 'Right half', top: 'Top half', bottom: 'Bottom half', + tl: 'Top left', tr: 'Top right', bl: 'Bottom left', br: 'Bottom right', + t3l: 'Left third', t3c: 'Center third', t3r: 'Right third', +}; + +// The one grid every tiling surface renders: the green-dot hover menu and the right-click "Tile to +// zone" submenu both map this, so they can never drift apart on which zones exist. +export const TILE_GROUPS: { label: string; zones: string[] }[] = [ + { label: 'Fill and halves', zones: ['fill', 'left', 'right', 'top', 'bottom'] }, + { 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; diff --git a/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts b/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts index 9f1ef0d3..b4d5dc67 100644 --- a/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts +++ b/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts @@ -41,12 +41,14 @@ interface CanvasWindowResizeArgs { getCanvasState: () => { panX: number; panY: number; zoom: number }; onCommitPosition: (x: number, y: number) => void; onCommitSize: (width: number, height: number) => void; + /** Tiling rule 5: grabbing a grip breaks the tile and resizes from the rect the card was filling. */ + untileForResize?: () => { x: number; y: number; w: number; h: number } | null; } /** The 8 edge/corner grips of a canvas window: preview the new rect locally, commit it on release. */ export function useCanvasWindowResize({ cardX, cardY, cardWidth, cardHeight, minWidth, minHeight, - getCanvasState, onCommitPosition, onCommitSize, + getCanvasState, onCommitPosition, onCommitSize, untileForResize, }: CanvasWindowResizeArgs): CanvasWindowResizeState { const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null); const [isResizing, setIsResizing] = useState(false); @@ -56,10 +58,15 @@ export function useCanvasWindowResize({ if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); - resizeRef.current = { dir, sx0: e.clientX, sy0: e.clientY, ox: cardX, oy: cardY, ow: cardWidth, oh: cardHeight }; + const popped = untileForResize?.() ?? null; + if (popped) setLive(popped); + resizeRef.current = { + dir, sx0: e.clientX, sy0: e.clientY, + ox: popped?.x ?? cardX, oy: popped?.y ?? cardY, ow: popped?.w ?? cardWidth, oh: popped?.h ?? cardHeight, + }; setIsResizing(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, [cardX, cardY, cardWidth, cardHeight]); + }, [cardX, cardY, cardWidth, cardHeight, untileForResize]); const compute = useCallback((e: React.PointerEvent) => { if (!resizeRef.current) return null; diff --git a/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts b/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts new file mode 100644 index 00000000..944ec877 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts @@ -0,0 +1,87 @@ +import { useCallback } from 'react'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { setTiledCard, clearTiledCard } from '@/shared/state/dashboardLayoutSlice'; +import { computeTiledStyle } from './tileZones'; + +// 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 +// everywhere. Modelled on macOS Sequoia window tiling / Rectangle / Magnet: +// 1. `tiledCards[cardId]` is the ONLY tiling state: one zone per card, 'fullscreen' included. +// 2. Tiling never writes card geometry, so leaving a tile always lands back on the pre-tile frame. +// 3. A tiled card re-tiles straight to another zone; there is no restore step in between. +// 4. Dragging a tiled card untiles it at its pre-tile SIZE and keeps the pointer's grip on it. +// 5. Resizing a tiled card untiles it and continues from the tiled rect, so the new size sticks. +// 6. Minimizing keeps the zone (the rail puts you back in it); only 'fullscreen' is dropped, since +// a parked card must not keep hiding the whole shell. +// 7. Closing a card drops its zone (the reducers own that; an orphan entry poisons every reader). + +export interface CardFrame { + x: number; + y: number; + w: number; + h: number; +} + +interface CardTilingArgs { + cardId: string; + getCanvasState: () => { panX: number; panY: number; zoom: number }; + /** The card's own position reducer; a drag-untile writes the popped-out origin through it. */ + commitPosition: (x: number, y: number) => void; +} + +export interface CardTiling { + zone: string | undefined; + isTiled: boolean; + isFullscreen: boolean; + /** A TILE_ZONES key, 'fullscreen', or 'restore'. */ + applyZone: (zone: string) => void; + /** Title-bar press: pops a tiled card out under the cursor at its pre-tile size. Null if untiled. */ + untileForDrag: (clientX: number, clientY: number, preTileWidth: number) => { x: number; y: number } | null; + /** Resize-handle press: breaks the tile, handing back the rect the card was occupying. Null if untiled. */ + untileForResize: () => CardFrame | null; +} + +export function useCardTiling({ cardId, getCanvasState, commitPosition }: CardTilingArgs): CardTiling { + const dispatch = useAppDispatch(); + const zone = useAppSelector((s) => s.dashboardLayout.tiledCards[cardId]); + + const applyZone = useCallback((next: string): void => { + if (next === 'restore') dispatch(clearTiledCard(cardId)); + 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. + 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 }; + }, [zone, getCanvasState]); + + const untileForResize = useCallback((): CardFrame | null => { + const frame = tiledFrame(); + if (!frame) return null; + dispatch(clearTiledCard(cardId)); + return frame; + }, [tiledFrame, dispatch, cardId]); + + const untileForDrag = useCallback((clientX: number, clientY: number, preTileWidth: number): { x: number; y: number } | null => { + const frame = tiledFrame(); + if (!frame) return null; + const cam = getCanvasState(); + const vp = document.querySelector('[data-canvas-viewport]')?.getBoundingClientRect(); + const pointerX = (clientX - (vp?.left ?? 0) - cam.panX) / cam.zoom; + // macOS: the window snaps back to its pre-tile width but stays glued to where you grabbed it. + const grip = frame.w > 0 ? Math.min(Math.max((pointerX - frame.x) / frame.w, 0), 1) : 0; + const x = pointerX - grip * preTileWidth; + dispatch(clearTiledCard(cardId)); + // Commit before the drag starts, so a press-and-release with no movement leaves the card popped + // out where it appeared instead of teleporting back to its stored spot. + commitPosition(x, frame.y); + return { x, y: frame.y }; + }, [tiledFrame, getCanvasState, dispatch, cardId, commitPosition]); + + return { zone, isTiled: !!zone, isFullscreen: zone === 'fullscreen', applyZone, untileForDrag, untileForResize }; +} diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx index 60bccb2f..7d4c7fa4 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -2,8 +2,7 @@ import React from 'react'; import Box from '@mui/material/Box'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { - toggleMinimizeCard, setTiledCard, recordClosedCard, - closeSettingsCard, closeWorkflowsApp, toggleSettingsCardFullscreen, toggleWorkflowsHubFullscreen, + toggleMinimizeCard, setTiledCard, recordClosedCard, closeSettingsCard, closeWorkflowsApp, } from '@/shared/state/dashboardLayoutSlice'; import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; @@ -30,6 +29,7 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto const dispatch = useAppDispatch(); const c = useClaudeTokens(); const minimizedCards = useAppSelector((s) => s.dashboardLayout.minimizedCards); + const tiledCards = useAppSelector((s) => s.dashboardLayout.tiledCards); const workflowsHub = useAppSelector((s) => s.dashboardLayout.workflowsHub); const settingsCard = useAppSelector((s) => s.dashboardLayout.settingsCard); const entries = buildMinimizedEntries({ browserCards, viewCards, outputs, workflowsHub, settingsCard, minimizedCards }); @@ -38,7 +38,9 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto const restore = (entry: MinimizedEntry): void => { dropMinimizedShot(entry.id); dispatch(toggleMinimizeCard({ cardId: entry.id })); - onRestore(entry.id, entry.rect); + // A card that kept its tile comes back pinned to the viewport, so flying the camera to its stored + // free-floating rect would just wander off to empty canvas. + if (!tiledCards[entry.id]) onRestore(entry.id, entry.rect); }; const close = (entry: MinimizedEntry): void => { dropMinimizedShot(entry.id); @@ -54,11 +56,8 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto dispatch(closeSettingsCard()); } }; - // Singletons own a fullscreen flag instead of a tiledCards entry, so a setTiledCard here would strand a ghost fullscreen owner nothing renders. const tile = (entry: MinimizedEntry, zone: string): void => { restore(entry); - if (entry.kind === 'workflows') { if (zone === 'fullscreen') dispatch(toggleWorkflowsHubFullscreen()); return; } - if (entry.kind === 'settings') { if (zone === 'fullscreen') dispatch(toggleSettingsCardFullscreen()); return; } if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }; diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx index ac8e29c6..2e39b34f 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx @@ -113,8 +113,7 @@ function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile, on opacity: 0, pointerEvents: 'none', transition: 'opacity 140ms ease', }} > - {/* The singleton windows only know fullscreen, so their green light must not offer half/quarter zones it can't honor. */} - + diff --git a/frontend/src/app/pages/Settings/SettingsAppCard.tsx b/frontend/src/app/pages/Settings/SettingsAppCard.tsx index 89c255fb..e488227c 100644 --- a/frontend/src/app/pages/Settings/SettingsAppCard.tsx +++ b/frontend/src/app/pages/Settings/SettingsAppCard.tsx @@ -6,7 +6,6 @@ import { setSettingsCardPosition, setSettingsCardSize, toggleMinimizeCard, - toggleSettingsCardFullscreen, SETTINGS_CARD_ID, } from '@/shared/state/dashboardLayoutSlice'; import type { CardType } from '@/shared/state/dashboardLayoutSlice'; @@ -44,7 +43,6 @@ const SettingsAppCard: React.FC = ({ }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const isFullscreen = useAppSelector((s) => !!s.dashboardLayout.settingsCard?.fullscreen); const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[SETTINGS_CARD_ID]); const commitPosition = useCallback((x: number, y: number) => { @@ -67,7 +65,6 @@ const SettingsAppCard: React.FC = ({ cardWidth={cardWidth} cardHeight={cardHeight} cardZOrder={cardZOrder} - fullscreen={isFullscreen} minimized={isMinimized} minWidth={MIN_W} minHeight={MIN_H} @@ -85,7 +82,7 @@ const SettingsAppCard: React.FC = ({ onCommitPosition={commitPosition} onCommitSize={commitSize} > - {({ header, onTileZone }) => ( + {({ header, tileZone, onTileZone }) => ( <>
= ({ onClick={(e) => e.stopPropagation()} style={{ display: 'flex', alignItems: 'center' }} > - { - if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleSettingsCardFullscreen()); return; } - if (isFullscreen) dispatch(toggleSettingsCardFullscreen()); - onTileZone(zone); - }} - tiled={isFullscreen} - noTileMenu={isFullscreen} - /> +
diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index 83b39f1e..1c37139c 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -34,7 +34,6 @@ const WorkflowsAppCard: React.FC = ({ }) => { const WC = useWC(); const dispatch = useAppDispatch(); - const isFullscreen = useAppSelector((s) => !!s.dashboardLayout.workflowsHub?.fullscreen); const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[WORKFLOWS_HUB_ID]); // Keep fonts/keyframes available while the card is mounted. @@ -58,7 +57,6 @@ const WorkflowsAppCard: React.FC = ({ cardWidth={cardWidth} cardHeight={cardHeight} cardZOrder={cardZOrder} - fullscreen={isFullscreen} minimized={isMinimized} minWidth={MIN_W} minHeight={MIN_H} @@ -76,8 +74,8 @@ const WorkflowsAppCard: React.FC = ({ onCommitPosition={commitPosition} onCommitSize={commitSize} > - {({ header, onTileZone }) => ( - + {({ header, tileZone, onTileZone }) => ( + )} ); diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx index 4f133436..84412c1d 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import EventRepeatIcon from '@mui/icons-material/EventRepeat'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { clearWorkflowsAppTarget, closeWorkflowsApp, toggleMinimizeCard, toggleWorkflowsHubFullscreen, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice'; +import { clearWorkflowsAppTarget, closeWorkflowsApp, toggleMinimizeCard, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice'; import WindowControls from '@/app/pages/Dashboard/cards/WindowControls'; import { fetchWorkflows, fetchAllRuns, fetchPausedState, fetchActiveRuns, fetchDeletedWorkflows, @@ -18,11 +18,10 @@ import ComposeView from './ComposeView'; import TrashView from './TrashView'; // The three-pane Workflows body plus its title bar. The card wraps this with drag/resize geometry and passes the drag handlers in; the title bar lives here because Share needs to know which workflow is open. -const WorkflowsAppContent: React.FC<{ header: CardHeader; onTileZone?: (zone: string) => void }> = ({ header, onTileZone }) => { +const WorkflowsAppContent: React.FC<{ header: CardHeader; tileZone: string | undefined; onTileZone: (zone: string) => void }> = ({ header, tileZone, onTileZone }) => { const WC = useWC(); const dispatch = useAppDispatch(); const target = useAppSelector((s) => s.dashboardLayout.workflowsAppTarget); - const isFullscreen = useAppSelector((s) => !!s.dashboardLayout.workflowsHub?.fullscreen); const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined; const [mode, setMode] = useState('home'); @@ -85,13 +84,8 @@ const WorkflowsAppContent: React.FC<{ header: CardHeader; onTileZone?: (zone: st dispatch(closeWorkflowsApp())} onMinimize={() => dispatch(toggleMinimizeCard({ cardId: WORKFLOWS_HUB_ID }))} - onTile={(zone) => { - if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleWorkflowsHubFullscreen()); return; } - if (isFullscreen) dispatch(toggleWorkflowsHubFullscreen()); - onTileZone?.(zone); - }} - tiled={isFullscreen} - noTileMenu={isFullscreen} + onTile={onTileZone} + tiled={!!tileZone} />
diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 65953e5c..0b11a7bb 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -112,8 +112,6 @@ export interface WorkflowsHubPosition { width: number; height: number; zOrder: number; - // Full size view: the card fills the whole dashboard (reuses the fullscreen tile geometry). - fullscreen?: boolean; } // One entry in the Ctrl/Cmd+Shift+T "reopen last closed" stack: a full snapshot for browser/view/workflow/tab, just the session id for an agent (its session is brought back via resumeSession). @@ -571,19 +569,17 @@ const dashboardLayoutSlice = createSlice({ name: 'dashboardLayout', initialState, reducers: { - // Window controls (traffic lights). Minimize toggles a per-card pill; tiling snaps a card to a - // macOS-style viewport zone (green = 'fill'). Minimizing an un-tiles and vice-versa, so a card - // is never both pill'd and tiled at once. + // Window controls (traffic lights). Minimize parks a card in the right-edge rail; tiling snaps it + // to a macOS-style viewport zone. Rule 6 of the tiling set (see cards/useCardTiling.ts): a parked + // card keeps its zone and restores back into it, but never keeps 'fullscreen', which would leave + // an off-canvas card hiding the whole shell. toggleMinimizeCard(state, action: PayloadAction<{ cardId: string }>) { const id = action.payload.cardId; if (state.minimizedCards[id]) { delete state.minimizedCards[id]; } else { state.minimizedCards[id] = true; - if (state.tiledCards[id]) delete state.tiledCards[id]; - // The singletons hold their own fullscreen flag instead of a tiledCards entry, so parking one has to drop that too. - if (id === SETTINGS_CARD_ID && state.settingsCard) state.settingsCard.fullscreen = false; - if (id === WORKFLOWS_HUB_ID && state.workflowsHub) state.workflowsHub.fullscreen = false; + if (state.tiledCards[id] === 'fullscreen') delete state.tiledCards[id]; } }, setTiledCard(state, action: PayloadAction<{ cardId: string; zone: string }>) { @@ -1098,6 +1094,9 @@ const dashboardLayoutSlice = createSlice({ removeWorkflowCard(state, action: PayloadAction) { delete state.workflowCards[action.payload]; + // Rule 7: a dead card must never keep owning a tile; a stale entry poisons every reader of it. + delete state.tiledCards[action.payload]; + delete state.minimizedCards[action.payload]; }, // Rekey draft- id to the server-assigned id without visually hopping the card. @@ -1143,6 +1142,7 @@ const dashboardLayoutSlice = createSlice({ closeWorkflowsHub(state) { state.workflowsHub = null; delete state.minimizedCards[WORKFLOWS_HUB_ID]; + delete state.tiledCards[WORKFLOWS_HUB_ID]; }, // The Workflows app is an on-canvas card (like chat/browser/view cards), backed by the singleton workflowsHub geometry. Opening it creates or raises that card and pans to it; an optional workflowId deep-links to that workflow's detail once the card mounts. @@ -1169,6 +1169,7 @@ const dashboardLayoutSlice = createSlice({ closeWorkflowsApp(state) { state.workflowsHub = null; + delete state.tiledCards[WORKFLOWS_HUB_ID]; delete state.minimizedCards[WORKFLOWS_HUB_ID]; state.workflowsAppTarget = null; state.workflowsMonitorId = null; @@ -1176,13 +1177,6 @@ const dashboardLayoutSlice = createSlice({ state.workflowsMonitorCard = null; }, - toggleWorkflowsHubFullscreen(state) { - if (state.workflowsHub) { - state.workflowsHub.fullscreen = !state.workflowsHub.fullscreen; - state.workflowsHub.zOrder = state.nextZOrder++; - } - }, - clearWorkflowsAppTarget(state) { state.workflowsAppTarget = null; }, @@ -1261,6 +1255,7 @@ const dashboardLayoutSlice = createSlice({ closeSettingsCard(state) { state.settingsCard = null; delete state.minimizedCards[SETTINGS_CARD_ID]; + delete state.tiledCards[SETTINGS_CARD_ID]; state.pendingFocusSettingsCard = false; }, @@ -1268,12 +1263,6 @@ const dashboardLayoutSlice = createSlice({ state.pendingFocusSettingsCard = false; }, - toggleSettingsCardFullscreen(state) { - if (!state.settingsCard) return; - state.settingsCard.fullscreen = !state.settingsCard.fullscreen; - state.settingsCard.zOrder = state.nextZOrder++; - }, - setSettingsCardPosition(state, action: PayloadAction<{ x: number; y: number }>) { if (!state.settingsCard) return; state.settingsCard.x = action.payload.x; @@ -1877,7 +1866,6 @@ export const { closeWorkflowsHub, openWorkflowsApp, closeWorkflowsApp, - toggleWorkflowsHubFullscreen, clearWorkflowsAppTarget, openWorkflowMonitor, closeWorkflowMonitor, @@ -1890,7 +1878,6 @@ export const { openSettingsCard, closeSettingsCard, clearPendingFocusSettingsCard, - toggleSettingsCardFullscreen, setSettingsCardPosition, setSettingsCardSize, recordClosedCard, @@ -1925,8 +1912,9 @@ export const selectFullscreenCardId = (state: { dashboardLayout: DashboardLayout if (!entry) return null; const id = entry[0]; // Belt over the reducer hygiene: an entry whose card is gone (any removal path) must not hold the app in fullscreen. - const exists = id in s.cards || id in s.viewCards || id in s.browserCards || id in s.workflowCards; - return exists ? id : null; + const exists = id in s.cards || id in s.viewCards || id in s.browserCards || id in s.workflowCards + || (id === WORKFLOWS_HUB_ID && !!s.workflowsHub) || (id === SETTINGS_CARD_ID && !!s.settingsCard); + return exists && !s.minimizedCards[id] ? id : null; }; export default dashboardLayoutSlice.reducer;