From 318957b39c84ba0d786438afe186c09e87bdfb2a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 16:16:50 -0700 Subject: [PATCH] [eric] dashboard: minimize Settings and the Workflows hub to the rail instead of closing them --- .../app/pages/Dashboard/DashboardToolbar.tsx | 5 +- .../Dashboard/cards/CanvasWindowCard.tsx | 98 ++++------------ .../app/pages/Dashboard/cards/tileZones.ts | 9 +- .../Dashboard/cards/useCanvasWindowResize.ts | 107 ++++++++++++++++++ .../Dashboard/desktop/MinimizedStack.tsx | 30 ++++- .../pages/Dashboard/desktop/MinimizedTile.tsx | 8 +- .../Dashboard/desktop/minimizedEntries.ts | 17 ++- .../app/pages/Settings/SettingsAppCard.tsx | 6 +- .../pages/Workflows/app/WorkflowsAppCard.tsx | 6 +- .../Workflows/app/WorkflowsAppContent.tsx | 4 +- .../src/shared/state/dashboardLayoutSlice.ts | 12 ++ 11 files changed, 207 insertions(+), 95 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index fbb7f528..857314f8 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -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( 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(() => { diff --git a/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx b/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx index 08ed73d1..2283859f 100644 --- a/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx @@ -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 = { - 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 = ({ 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 = ({ }, [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 = ({ 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 = ({ onTileZone, })} - {!fullscreen && HANDLE_DEFS.map(({ dir, css }) => ( + {!fullscreen && !minimized && handles.map((h) => (
))}
diff --git a/frontend/src/app/pages/Dashboard/cards/tileZones.ts b/frontend/src/app/pages/Dashboard/cards/tileZones.ts index 669d0ea6..d01d53b4 100644 --- a/frontend/src/app/pages/Dashboard/cards/tileZones.ts +++ b/frontend/src/app/pages/Dashboard/cards/tileZones.ts @@ -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. diff --git a/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts b/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts new file mode 100644 index 00000000..9f1ef0d3 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts @@ -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 = { + 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 }; +} diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx index aeac4cda..60bccb2f 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -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 ( 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) }, ], diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx index e0222bd9..ac8e29c6 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx @@ -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 ; + // The singleton windows never have a thumbnail, so their own app glyph is the whole identity of the tile. + if (entry.kind === 'workflows') return ; + if (entry.kind === 'settings') return ; // 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 ; @@ -108,7 +113,8 @@ function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile, on opacity: 0, pointerEvents: 'none', transition: 'opacity 140ms ease', }} > - + {/* The singleton windows only know fullscreen, so their green light must not offer half/quarter zones it can't honor. */} + diff --git a/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts b/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts index 0cc1e131..30711ab4 100644 --- a/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts +++ b/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts @@ -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; viewCards: Record; outputs: Record; + workflowsHub: WorkflowsHubPosition | null; + settingsCard: WorkflowsHubPosition | null; minimizedCards: Record; } -/** 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; } diff --git a/frontend/src/app/pages/Settings/SettingsAppCard.tsx b/frontend/src/app/pages/Settings/SettingsAppCard.tsx index 19563dc5..89c255fb 100644 --- a/frontend/src/app/pages/Settings/SettingsAppCard.tsx +++ b/frontend/src/app/pages/Settings/SettingsAppCard.tsx @@ -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 = ({ 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 = ({ dispatch(setSettingsCardSize({ width, height })); }, [dispatch]); const close = useCallback(() => { dispatch(closeSettingsCard()); }, [dispatch]); + const minimize = useCallback(() => { dispatch(toggleMinimizeCard({ cardId: SETTINGS_CARD_ID })); }, [dispatch]); return ( = ({ 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 = ({ > { if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleSettingsCardFullscreen()); return; } if (isFullscreen) dispatch(toggleSettingsCardFullscreen()); diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index 5c5e5443..83b39f1e 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -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 = ({ 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 = ({ return ( = ({ cardHeight={cardHeight} cardZOrder={cardZOrder} fullscreen={isFullscreen} + minimized={isMinimized} minWidth={MIN_W} minHeight={MIN_H} background={WC.page} diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx index 63f1d91c..4f133436 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import EventRepeatIcon from '@mui/icons-material/EventRepeat'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { clearWorkflowsAppTarget, closeWorkflowsApp, 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 > 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()); diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index c00233d9..c3cf681b 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -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; },