mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-28 18:59:51 +02:00
[eric] tiling: one macOS rule set for every card type, Settings and Workflows windows included
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user