[eric] tiling: one macOS rule set for every card type, Settings and Workflows windows included

This commit is contained in:
ciregenz
2026-07-30 19:31:21 -07:00
parent 42ef6be571
commit d9c2b728d5
15 changed files with 247 additions and 214 deletions
@@ -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<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
}, [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<Props> = ({
onPointerDown={(e) => e.stopPropagation()}
sx={{ display: 'flex', alignItems: 'center', mr: 0.75, flexShrink: 0 }}
>
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} />
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} />
</Box>
<Box
sx={{
@@ -1,5 +1,4 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { store } from '@/shared/state/store';
import { createPortal } from 'react-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
@@ -39,13 +38,12 @@ import {
moveBrowserTab,
recordClosedCard,
toggleMinimizeCard,
setTiledCard,
clearTiledCard,
setBrowserDocked,
type BrowserTab,
} from '@/shared/state/dashboardLayoutSlice';
import WindowControls from './WindowControls';
import { useTiledStyle, computeTiledStyle } from './tileZones';
import { useTiledStyle } from './tileZones';
import { useCardTiling } from './useCardTiling';
import { getMinimizedShot, saveMinimizedShot } from '../desktop/minimizedShots';
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
import { createSelector } from '@reduxjs/toolkit';
@@ -231,7 +229,11 @@ const BrowserCard: React.FC<Props> = ({
);
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<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
onMinimize={handleMinimize}
onTile={onTile}
tiled={!!tileZone}
noTileMenu={tileZone === 'fullscreen'}
/>
</Box>
<Box
@@ -1,13 +1,13 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { TILE_ZONES, useTiledStyle } from './tileZones';
import { useTiledStyle } from './tileZones';
import { useCardTiling } from './useCardTiling';
import { useCanvasWindowResize } from './useCanvasWindowResize';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
const DRAG_THRESHOLD = 3;
const SNAP_GRID = 24;
const TILE_GAP = 8;
/** Drag handlers the window hands down to whatever renders its title bar. */
export interface CanvasWindowHeader {
@@ -21,6 +21,9 @@ export interface CanvasWindowHeader {
export interface CanvasWindowChrome {
header: CanvasWindowHeader;
/** The window's current tile zone, or undefined while it floats free. */
tileZone: string | undefined;
/** A TILE_ZONES key, 'fullscreen', or 'restore'. */
onTileZone: (zone: string) => 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<CanvasWindowCardProps> = ({
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<CanvasWindowCardProps> = ({
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<CanvasWindowCardProps> = ({
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<CanvasWindowCardProps> = ({
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<CanvasWindowCardProps> = ({
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<CanvasWindowCardProps> = ({
onLostPointerCapture: abortDrag,
dragging: isDragging,
},
onTileZone,
tileZone: tiling.zone,
onTileZone: tiling.applyZone,
})}
{!fullscreen && !minimized && handles.map((h) => (
{!minimized && handles.map((h) => (
<div
key={h.dir}
data-no-drag
@@ -1,5 +1,4 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { store } from '@/shared/state/store';
import { createPortal } from 'react-dom';
import Box from '@mui/material/Box';
import Fade from '@mui/material/Fade';
@@ -16,7 +15,7 @@ import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
import AddIcon from '@mui/icons-material/Add';
import KeyboardArrowUpRounded from '@mui/icons-material/KeyboardArrowUpRounded';
import { Output, SERVE_BASE, updateOutput } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewDocked, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice';
import { setViewCardPosition, setViewDocked, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { saveMinimizedShot } from '../desktop/minimizedShots';
import { requestAppSlot, releaseAppSlot, subscribeAppBudget } from '@/shared/appWebviewBudget';
@@ -25,7 +24,8 @@ import WindowControls from './WindowControls';
import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu';
import { viewCardMenuRows } from './viewCardMenuRows';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import { useTiledStyle, computeTiledStyle } from './tileZones';
import { useTiledStyle } from './tileZones';
import { useCardTiling } from './useCardTiling';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { API_BASE, getAuthToken } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -155,7 +155,11 @@ const DashboardViewCard: React.FC<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
}}
>
<Box onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, mr: 0.25 }}>
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} />
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} />
</Box>
<GridViewRoundedIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
<Typography
@@ -1,15 +1,12 @@
import React, { useRef, useState } from 'react';
import Box from '@mui/material/Box';
import { TILE_ZONES } from './tileZones';
import { TILE_GROUPS, TILE_ZONES, ZONE_LABELS } from './tileZones';
interface WindowControlsProps {
onClose: () => 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<string, unknown> => ({
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<string, unknown> = {
'.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<HTMLDivElement | null>(null);
const closeTimer = useRef<number | null>(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) => (
<Box key={g.label} sx={{ mb: 0.75, '&:last-of-type': { mb: 0 } }}>
<Box sx={{ fontSize: '0.625rem', fontWeight: 700, letterSpacing: '0.08em', color: 'rgba(115,114,108,0.65)', textTransform: 'uppercase', mb: 0.5 }}>{g.label}</Box>
<Box sx={{ display: 'flex', gap: '7px' }}>
{g.zones.map((zone) => {
const z = TILE_ZONES[zone];
return (
<Box key={zone} role="button" aria-label={zone}
<Box key={zone} role="button" aria-label={ZONE_LABELS[zone]}
onClick={(e: React.MouseEvent) => { 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' } }}>
<Box sx={{ position: 'absolute', left: `${z.x * 100 + 8}%`, top: `${z.y * 100 + 14}%`, width: `${z.w * 100 - 16}%`, height: `${z.h * 100 - 28}%`, background: '#ae5630', opacity: 0.8, borderRadius: '2px' }} />
@@ -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<string, string> = {
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) });
@@ -21,6 +21,20 @@ export const TILE_ZONES: Record<string, { x: number; y: number; w: number; h: nu
t3r: { x: 2 / 3, y: 0, w: 1 / 3, h: 1 },
};
export const ZONE_LABELS: Record<string, string> = {
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;
@@ -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;
@@ -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 };
}
@@ -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 }));
};
@@ -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. */}
<WindowControls onClose={onClose} onMinimize={onRestore} onTile={onTile} tiled={false} noTileMenu={entry.kind === 'workflows' || entry.kind === 'settings'} />
<WindowControls onClose={onClose} onMinimize={onRestore} onTile={onTile} tiled={false} />
</Box>
</Box>
@@ -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<Props> = ({
}) => {
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<Props> = ({
cardWidth={cardWidth}
cardHeight={cardHeight}
cardZOrder={cardZOrder}
fullscreen={isFullscreen}
minimized={isMinimized}
minWidth={MIN_W}
minHeight={MIN_H}
@@ -85,7 +82,7 @@ const SettingsAppCard: React.FC<Props> = ({
onCommitPosition={commitPosition}
onCommitSize={commitSize}
>
{({ header, onTileZone }) => (
{({ header, tileZone, onTileZone }) => (
<>
<div
onPointerDown={header.onPointerDown}
@@ -106,17 +103,7 @@ const SettingsAppCard: React.FC<Props> = ({
onClick={(e) => e.stopPropagation()}
style={{ display: 'flex', alignItems: 'center' }}
>
<WindowControls
onClose={close}
onMinimize={minimize}
onTile={(zone) => {
if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleSettingsCardFullscreen()); return; }
if (isFullscreen) dispatch(toggleSettingsCardFullscreen());
onTileZone(zone);
}}
tiled={isFullscreen}
noTileMenu={isFullscreen}
/>
<WindowControls onClose={close} onMinimize={minimize} onTile={onTileZone} tiled={!!tileZone} />
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SettingsIcon sx={{ fontSize: 18, color: c.text.secondary, display: 'block' }} />
@@ -34,7 +34,6 @@ const WorkflowsAppCard: React.FC<Props> = ({
}) => {
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<Props> = ({
cardWidth={cardWidth}
cardHeight={cardHeight}
cardZOrder={cardZOrder}
fullscreen={isFullscreen}
minimized={isMinimized}
minWidth={MIN_W}
minHeight={MIN_H}
@@ -76,8 +74,8 @@ const WorkflowsAppCard: React.FC<Props> = ({
onCommitPosition={commitPosition}
onCommitSize={commitSize}
>
{({ header, onTileZone }) => (
<WorkflowsAppContent header={header} onTileZone={onTileZone} />
{({ header, tileZone, onTileZone }) => (
<WorkflowsAppContent header={header} tileZone={tileZone} onTileZone={onTileZone} />
)}
</CanvasWindowCard>
);
@@ -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<AppMode>('home');
@@ -85,13 +84,8 @@ const WorkflowsAppContent: React.FC<{ header: CardHeader; onTileZone?: (zone: st
<WindowControls
onClose={() => 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}
/>
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -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<string>) {
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;