[eric] dashboard: minimize Settings and the Workflows hub to the rail instead of closing them

This commit is contained in:
ciregenz
2026-07-30 16:16:50 -07:00
parent 77b3595264
commit 318957b39c
11 changed files with 207 additions and 95 deletions
@@ -13,7 +13,7 @@ import ChatInput from '@/app/pages/AgentChat/ChatInput';
import type { ContextPath } from '@/app/components/editor/DirectoryBrowser';
import SchedulePopover from '@/app/pages/Workflows/SchedulePopover';
import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice';
import { addWorkflowCard, openWorkflowsApp, closeWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import { addWorkflowCard, openWorkflowsApp, closeWorkflowsApp, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice';
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -171,7 +171,8 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const allRuns = useAppSelector((s) => s.workflows.allRuns);
const allRunsLoading = useAppSelector((s) => s.workflows.allRunsLoading);
const workflowItems = useAppSelector((s) => s.workflows.items);
const workflowsHubOpen = useAppSelector((s) => Boolean(s.dashboardLayout.workflowsHub));
// Parked counts as not-showing, so the pill restores the window instead of throwing its state away.
const workflowsHubOpen = useAppSelector((s) => Boolean(s.dashboardLayout.workflowsHub) && !s.dashboardLayout.minimizedCards[WORKFLOWS_HUB_ID]);
const outputList = useMemo(() => Object.values(outputs), [outputs]);
const filteredOutputs = useMemo(() => {
@@ -1,32 +1,14 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { TILE_ZONES, useTiledStyle } from './tileZones';
import { useCanvasWindowResize } from './useCanvasWindowResize';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const EDGE = 6;
const CORNER = 14;
const DRAG_THRESHOLD = 3;
const SNAP_GRID = 24;
const TILE_GAP = 8;
const CURSOR_MAP: Record<ResizeDir, string> = {
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
};
const HANDLE_DEFS: { dir: ResizeDir; css: React.CSSProperties }[] = [
{ dir: 'n', css: { top: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } },
{ dir: 's', css: { bottom: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } },
{ dir: 'w', css: { left: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } },
{ dir: 'e', css: { right: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } },
{ dir: 'nw', css: { top: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } },
{ dir: 'ne', css: { top: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } },
{ dir: 'sw', css: { bottom: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } },
{ dir: 'se', css: { bottom: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } },
];
/** Drag handlers the window hands down to whatever renders its title bar. */
export interface CanvasWindowHeader {
onPointerDown: (e: React.PointerEvent) => void;
@@ -50,6 +32,8 @@ interface CanvasWindowCardProps {
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;
background: string; highlightColor: string;
getCanvasState: () => { panX: number; panY: number; zoom: number };
@@ -71,7 +55,7 @@ interface CanvasWindowCardProps {
const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
cardId, cardType, selectType, selectName,
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
fullscreen = false, minWidth, minHeight, background, highlightColor,
fullscreen = false, minimized = false, minWidth, minHeight, background, highlightColor,
getCanvasState,
isSelected = false, isHighlighted = false, multiDragDelta = null,
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
@@ -166,53 +150,10 @@ const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
}, [finalizeDrag]);
useDragEndBackstops(isDragging, finalizeDrag, abortDrag);
// ---- Resize ----
const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null);
const [isResizing, setIsResizing] = useState(false);
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => {
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 };
setIsResizing(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, cardWidth, cardHeight]);
const compute = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current;
const z2 = getCanvasState().zoom;
const dx = (e.clientX - sx0) / z2;
const dy = (e.clientY - sy0) / z2;
let nx = ox, ny = oy, nw = ow, nh = oh;
if (dir.includes('e')) nw = ow + dx;
if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; }
if (dir.includes('s')) nh = oh + dy;
if (dir.includes('n')) { nh = oh - dy; ny = oy + dy; }
if (nw < minWidth) { if (dir.includes('w')) nx = ox + ow - minWidth; nw = minWidth; }
if (nh < minHeight) { if (dir.includes('n')) ny = oy + oh - minHeight; nh = minHeight; }
return { x: nx, y: ny, w: nw, h: nh };
}, [getCanvasState, minWidth, minHeight]);
const onResizeMove = useCallback((e: React.PointerEvent) => {
const r = compute(e);
if (r) setLocalResize(r);
}, [compute]);
const onResizeUp = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const r = compute(e);
if (r) {
onCommitPosition(r.x, r.y);
onCommitSize(r.w, r.h);
}
resizeRef.current = null;
setLocalResize(null);
setIsResizing(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [compute, onCommitPosition, onCommitSize]);
const { isResizing, live: localResize, handles } = useCanvasWindowResize({
cardX, cardY, cardWidth, cardHeight, minWidth, minHeight,
getCanvasState, onCommitPosition, onCommitSize,
});
const onTileZone = useCallback((zone: string) => {
const z = TILE_ZONES[zone];
@@ -249,15 +190,20 @@ const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
if (target.closest('[data-no-drag]')) return;
onCardSelect?.(cardId, cardType, e.shiftKey);
}}
data-keepalive-hidden={minimized ? '1' : undefined}
style={{
position: 'absolute',
contain: 'layout style',
willChange: 'transform',
left: fsStyle ? fsStyle.left : dx,
top: fsStyle ? fsStyle.top : dy,
// Parked windows go off-canvas rather than unmounting, so Settings keeps its form and Workflows its view state.
pointerEvents: minimized ? 'none' : undefined,
// Belt and braces: leaving fullscreen tears down the tiled-style hook, whose cleanup strips the inline left/top React just wrote, and visibility is the one park signal it never touches.
visibility: minimized ? 'hidden' : undefined,
left: minimized ? -100000 : fsStyle ? fsStyle.left : dx,
top: minimized ? -100000 : fsStyle ? fsStyle.top : dy,
width: fsStyle ? fsStyle.width : dw,
height: fsStyle ? fsStyle.height : dh,
transform: fsStyle ? fsStyle.transform : undefined,
transform: minimized ? undefined : fsStyle ? fsStyle.transform : undefined,
transformOrigin: fsStyle ? fsStyle.transformOrigin : undefined,
background,
border: fsStyle ? 'none' : border,
@@ -282,14 +228,14 @@ const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
onTileZone,
})}
{!fullscreen && HANDLE_DEFS.map(({ dir, css }) => (
{!fullscreen && !minimized && handles.map((h) => (
<div
key={dir}
key={h.dir}
data-no-drag
onPointerDown={onResizeDown(dir)}
onPointerMove={onResizeMove}
onPointerUp={onResizeUp}
style={{ position: 'absolute', cursor: CURSOR_MAP[dir], zIndex: 25, ...css }}
onPointerDown={h.onPointerDown}
onPointerMove={h.onPointerMove}
onPointerUp={h.onPointerUp}
style={h.style}
/>
))}
</div>
@@ -124,9 +124,14 @@ export function useTiledStyle(
: (el.parentElement as HTMLElement | null) ?? el;
const posProps = ['left', 'top'];
const sizeProps = ['width', 'height', 'transform', 'transform-origin', 'transition'];
// Drop ONLY the overrides this hook still owns: React re-asserts these props without the important
// flag when a card leaves its zone, and deleting those left the window with no geometry at all.
const clearOwn = (target: HTMLElement, props: string[]): void => {
props.forEach((pr) => { if (target.style.getPropertyPriority(pr) === 'important') target.style.removeProperty(pr); });
};
const clearAll = (): void => {
posProps.forEach((pr) => posEl.style.removeProperty(pr));
sizeProps.forEach((pr) => el.style.removeProperty(pr));
clearOwn(posEl, posProps);
clearOwn(el, sizeProps);
};
const apply = (): void => {
// A parked card (kept alive off-screen / minimized) must keep its sx parking position.
@@ -0,0 +1,107 @@
import React, { useCallback, useRef, useState } from 'react';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const EDGE = 6;
const CORNER = 14;
const CURSOR_MAP: Record<ResizeDir, string> = {
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
};
const HANDLE_DEFS: { dir: ResizeDir; css: React.CSSProperties }[] = [
{ dir: 'n', css: { top: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } },
{ dir: 's', css: { bottom: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } },
{ dir: 'w', css: { left: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } },
{ dir: 'e', css: { right: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } },
{ dir: 'nw', css: { top: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } },
{ dir: 'ne', css: { top: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } },
{ dir: 'sw', css: { bottom: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } },
{ dir: 'se', css: { bottom: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } },
];
export interface CanvasWindowResizeHandle {
dir: string;
style: React.CSSProperties;
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: (e: React.PointerEvent) => void;
}
export interface CanvasWindowResizeState {
isResizing: boolean;
/** Live geometry while the pointer is down; null once committed to the slice. */
live: { x: number; y: number; w: number; h: number } | null;
handles: CanvasWindowResizeHandle[];
}
interface CanvasWindowResizeArgs {
cardX: number; cardY: number; cardWidth: number; cardHeight: number;
minWidth: number; minHeight: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
onCommitPosition: (x: number, y: number) => void;
onCommitSize: (width: number, height: number) => void;
}
/** 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,
}: 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);
const [live, setLive] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => {
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 };
setIsResizing(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, cardWidth, cardHeight]);
const compute = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current;
const zoom = getCanvasState().zoom;
const dx = (e.clientX - sx0) / zoom;
const dy = (e.clientY - sy0) / zoom;
let nx = ox, ny = oy, nw = ow, nh = oh;
if (dir.includes('e')) nw = ow + dx;
if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; }
if (dir.includes('s')) nh = oh + dy;
if (dir.includes('n')) { nh = oh - dy; ny = oy + dy; }
if (nw < minWidth) { if (dir.includes('w')) nx = ox + ow - minWidth; nw = minWidth; }
if (nh < minHeight) { if (dir.includes('n')) ny = oy + oh - minHeight; nh = minHeight; }
return { x: nx, y: ny, w: nw, h: nh };
}, [getCanvasState, minWidth, minHeight]);
const onResizeMove = useCallback((e: React.PointerEvent) => {
const r = compute(e);
if (r) setLive(r);
}, [compute]);
const onResizeUp = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const r = compute(e);
if (r) {
onCommitPosition(r.x, r.y);
onCommitSize(r.w, r.h);
}
resizeRef.current = null;
setLive(null);
setIsResizing(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [compute, onCommitPosition, onCommitSize]);
const handles = HANDLE_DEFS.map(({ dir, css }) => ({
dir,
style: { position: 'absolute' as const, cursor: CURSOR_MAP[dir], zIndex: 25, ...css },
onPointerDown: onResizeDown(dir),
onPointerMove: onResizeMove,
onPointerUp: onResizeUp,
}));
return { isResizing, live, handles };
}
@@ -1,7 +1,10 @@
import React from 'react';
import Box from '@mui/material/Box';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { toggleMinimizeCard, setTiledCard, recordClosedCard } from '@/shared/state/dashboardLayoutSlice';
import {
toggleMinimizeCard, setTiledCard, recordClosedCard,
closeSettingsCard, closeWorkflowsApp, toggleSettingsCardFullscreen, toggleWorkflowsHubFullscreen,
} from '@/shared/state/dashboardLayoutSlice';
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -27,7 +30,9 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto
const dispatch = useAppDispatch();
const c = useClaudeTokens();
const minimizedCards = useAppSelector((s) => s.dashboardLayout.minimizedCards);
const entries = buildMinimizedEntries({ browserCards, viewCards, outputs, minimizedCards });
const workflowsHub = useAppSelector((s) => s.dashboardLayout.workflowsHub);
const settingsCard = useAppSelector((s) => s.dashboardLayout.settingsCard);
const entries = buildMinimizedEntries({ browserCards, viewCards, outputs, workflowsHub, settingsCard, minimizedCards });
if (entries.length === 0) return null;
const restore = (entry: MinimizedEntry): void => {
@@ -40,11 +45,22 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto
if (entry.kind === 'browser') {
dispatch(recordClosedCard({ kind: 'browser', id: entry.id }));
void removeBrowserCardCleanly(entry.id, dispatch);
} else {
} else if (entry.kind === 'view') {
dispatch(recordClosedCard({ kind: 'view', id: entry.id }));
void removeViewCardCleanly(entry.id, dispatch);
} else if (entry.kind === 'workflows') {
dispatch(closeWorkflowsApp());
} else {
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 }));
};
return (
<Box
@@ -89,12 +105,14 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto
selected={selectedIds.includes(entry.id)}
onRestore={() => restore(entry)}
onClose={() => close(entry)}
onTile={(zone: string) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }}
onTile={(zone: string) => tile(entry, zone)}
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
items: [
{ label: 'Restore', onClick: () => restore(entry) },
{ label: 'Restore full screen', onClick: () => { restore(entry); dispatch(setTiledCard({ cardId: entry.id, zone: 'fullscreen' })); } },
{ label: 'Tile to zone', submenu: tileMenuRows((zone) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }) },
{ label: 'Restore full screen', onClick: () => tile(entry, 'fullscreen') },
...(entry.kind === 'browser' || entry.kind === 'view'
? [{ label: 'Tile to zone', submenu: tileMenuRows((zone) => tile(entry, zone)) }]
: []),
{ kind: 'separator' },
{ label: 'Close', danger: true, onClick: () => close(entry) },
],
@@ -3,6 +3,8 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import LanguageIcon from '@mui/icons-material/Language';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import SettingsIcon from '@mui/icons-material/Settings';
import { pickIcon } from '../canvas/DashboardGlyph';
import WindowControls, { ARC_CHIP_SX } from '../cards/WindowControls';
import { getMinimizedShot } from './minimizedShots';
@@ -46,6 +48,9 @@ function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile, on
);
}
if (entry.kind === 'browser') return <LanguageIcon sx={{ fontSize: size, color: 'rgba(255,255,255,0.55)', display: 'block' }} />;
// The singleton windows never have a thumbnail, so their own app glyph is the whole identity of the tile.
if (entry.kind === 'workflows') return <EventRepeatIcon sx={{ fontSize: size, color: 'rgba(255,255,255,0.55)', display: 'block' }} />;
if (entry.kind === 'settings') return <SettingsIcon sx={{ fontSize: size, color: 'rgba(255,255,255,0.55)', display: 'block' }} />;
// Never a letter here: a glyph initial reads as a bug, so an unmatched name falls back to a real symbol.
const AppIcon = pickIcon(entry.label);
if (AppIcon) return <AppIcon size={size} strokeWidth={1.75} color="rgba(255,255,255,0.6)" />;
@@ -108,7 +113,8 @@ function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile, on
opacity: 0, pointerEvents: 'none', transition: 'opacity 140ms ease',
}}
>
<WindowControls onClose={onClose} onMinimize={onRestore} onTile={onTile} tiled={false} />
{/* 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'} />
</Box>
</Box>
@@ -1,4 +1,5 @@
import type { BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice';
import { SETTINGS_CARD_ID, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice';
import type { BrowserCardPosition, ViewCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
export interface MinimizedRect {
@@ -8,7 +9,7 @@ export interface MinimizedRect {
height: number;
}
export type MinimizedKind = 'browser' | 'view';
export type MinimizedKind = 'browser' | 'view' | 'workflows' | 'settings';
export interface MinimizedEntry {
id: string;
@@ -25,14 +26,18 @@ export interface MinimizedSlices {
browserCards: Record<string, BrowserCardPosition>;
viewCards: Record<string, ViewCardPosition>;
outputs: Record<string, Output>;
workflowsHub: WorkflowsHubPosition | null;
settingsCard: WorkflowsHubPosition | null;
minimizedCards: Record<string, boolean>;
}
/** Every window parked in the minimized rail, browsers and apps in one list so both share a small state. */
/** Every window parked in the minimized rail, browsers, apps and the singleton windows in one list so they all share a small state. */
export function buildMinimizedEntries({
browserCards,
viewCards,
outputs,
workflowsHub,
settingsCard,
minimizedCards,
}: MinimizedSlices): MinimizedEntry[] {
const list: MinimizedEntry[] = [];
@@ -59,5 +64,11 @@ export function buildMinimizedEntries({
thumbnail: outputs[vc.output_id]?.thumbnail,
});
}
if (workflowsHub && minimizedCards[WORKFLOWS_HUB_ID]) {
list.push({ id: WORKFLOWS_HUB_ID, kind: 'workflows', label: 'Workflows', rect: workflowsHub });
}
if (settingsCard && minimizedCards[SETTINGS_CARD_ID]) {
list.push({ id: SETTINGS_CARD_ID, kind: 'settings', label: 'Settings', rect: settingsCard });
}
return list;
}
@@ -5,6 +5,7 @@ import {
closeSettingsCard,
setSettingsCardPosition,
setSettingsCardSize,
toggleMinimizeCard,
toggleSettingsCardFullscreen,
SETTINGS_CARD_ID,
} from '@/shared/state/dashboardLayoutSlice';
@@ -44,6 +45,7 @@ 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) => {
dispatch(setSettingsCardPosition({ x, y }));
@@ -52,6 +54,7 @@ const SettingsAppCard: React.FC<Props> = ({
dispatch(setSettingsCardSize({ width, height }));
}, [dispatch]);
const close = useCallback(() => { dispatch(closeSettingsCard()); }, [dispatch]);
const minimize = useCallback(() => { dispatch(toggleMinimizeCard({ cardId: SETTINGS_CARD_ID })); }, [dispatch]);
return (
<CanvasWindowCard
@@ -65,6 +68,7 @@ const SettingsAppCard: React.FC<Props> = ({
cardHeight={cardHeight}
cardZOrder={cardZOrder}
fullscreen={isFullscreen}
minimized={isMinimized}
minWidth={MIN_W}
minHeight={MIN_H}
background={c.bg.page}
@@ -104,7 +108,7 @@ const SettingsAppCard: React.FC<Props> = ({
>
<WindowControls
onClose={close}
onMinimize={close}
onMinimize={minimize}
onTile={(zone) => {
if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleSettingsCardFullscreen()); return; }
if (isFullscreen) dispatch(toggleSettingsCardFullscreen());
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice';
import { setWorkflowsHubPosition, setWorkflowsHubSize, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice';
import CanvasWindowCard from '@/app/pages/Dashboard/cards/CanvasWindowCard';
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
import { useWC } from './uiKit';
@@ -35,6 +35,7 @@ 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.
useEffect(() => { ensureAssets(); }, []);
@@ -48,7 +49,7 @@ const WorkflowsAppCard: React.FC<Props> = ({
return (
<CanvasWindowCard
cardId="workflows-hub"
cardId={WORKFLOWS_HUB_ID}
cardType="workflows-hub"
selectType="workflows-hub-card"
selectName="Workflows"
@@ -58,6 +59,7 @@ const WorkflowsAppCard: React.FC<Props> = ({
cardHeight={cardHeight}
cardZOrder={cardZOrder}
fullscreen={isFullscreen}
minimized={isMinimized}
minWidth={MIN_W}
minHeight={MIN_H}
background={WC.page}
@@ -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, toggleWorkflowsHubFullscreen } from '@/shared/state/dashboardLayoutSlice';
import { clearWorkflowsAppTarget, closeWorkflowsApp, toggleMinimizeCard, toggleWorkflowsHubFullscreen, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice';
import WindowControls from '@/app/pages/Dashboard/cards/WindowControls';
import {
fetchWorkflows, fetchAllRuns, fetchPausedState, fetchActiveRuns, fetchDeletedWorkflows,
@@ -84,7 +84,7 @@ const WorkflowsAppContent: React.FC<{ header: CardHeader; onTileZone?: (zone: st
>
<WindowControls
onClose={() => dispatch(closeWorkflowsApp())}
onMinimize={() => dispatch(closeWorkflowsApp())}
onMinimize={() => dispatch(toggleMinimizeCard({ cardId: WORKFLOWS_HUB_ID }))}
onTile={(zone) => {
if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleWorkflowsHubFullscreen()); return; }
if (isFullscreen) dispatch(toggleWorkflowsHubFullscreen());
@@ -26,7 +26,9 @@ export const DEFAULT_WORKFLOWS_HUB_W = DEFAULT_BROWSER_CARD_W;
export const DEFAULT_WORKFLOWS_HUB_H = DEFAULT_BROWSER_CARD_H;
export const DEFAULT_SETTINGS_CARD_W = 900;
export const DEFAULT_SETTINGS_CARD_H = 640;
// The two singleton windows have no card map to key off, so they own these fixed ids everywhere (selection, minimize, z-order).
export const SETTINGS_CARD_ID = 'settings';
export const WORKFLOWS_HUB_ID = 'workflows-hub';
export const EXPANDED_CARD_MIN_H = 620;
export const GRID_GAP = 24;
// Gap between the Workflows window and the cards it spawns (run monitor, that monitor's browser). Keeps the hub -> monitor -> browser row evenly spaced.
@@ -579,6 +581,9 @@ const dashboardLayoutSlice = createSlice({
} 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;
}
},
setTiledCard(state, action: PayloadAction<{ cardId: string; zone: string }>) {
@@ -1115,6 +1120,7 @@ const dashboardLayoutSlice = createSlice({
openWorkflowsHub(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) {
if (state.workflowsHub) {
state.workflowsHub.zOrder = state.nextZOrder++;
delete state.minimizedCards[WORKFLOWS_HUB_ID];
state.pendingFocusWorkflowsHub = true;
return;
}
@@ -1136,6 +1142,7 @@ const dashboardLayoutSlice = createSlice({
closeWorkflowsHub(state) {
state.workflowsHub = null;
delete state.minimizedCards[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.
@@ -1143,6 +1150,8 @@ const dashboardLayoutSlice = createSlice({
state.workflowsAppTarget = action.payload?.workflowId ?? null;
if (state.workflowsHub) {
state.workflowsHub.zOrder = state.nextZOrder++;
// Opening means visible: a parked window must come back to the canvas, or the focus pan flies to empty space.
delete state.minimizedCards[WORKFLOWS_HUB_ID];
state.pendingFocusWorkflowsHub = true;
return;
}
@@ -1160,6 +1169,7 @@ const dashboardLayoutSlice = createSlice({
closeWorkflowsApp(state) {
state.workflowsHub = null;
delete state.minimizedCards[WORKFLOWS_HUB_ID];
state.workflowsAppTarget = null;
state.workflowsMonitorId = null;
state.workflowsMonitorRunId = null;
@@ -1232,6 +1242,7 @@ const dashboardLayoutSlice = createSlice({
openSettingsCard(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) {
if (state.settingsCard) {
state.settingsCard.zOrder = state.nextZOrder++;
delete state.minimizedCards[SETTINGS_CARD_ID];
state.pendingFocusSettingsCard = true;
return;
}
@@ -1249,6 +1260,7 @@ const dashboardLayoutSlice = createSlice({
closeSettingsCard(state) {
state.settingsCard = null;
delete state.minimizedCards[SETTINGS_CARD_ID];
state.pendingFocusSettingsCard = false;
},