diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index a6530708..f06892be 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -170,8 +170,9 @@ const DashboardCanvas: React.FC = ({ // macOS full screen: one card owns the whole window, every piece of chrome steps aside; Esc exits. const dispatch = useAppDispatch(); const fullscreenCardId = useAppSelector(selectFullscreenCardId); - // The Workflows window has its own fullscreen flag (not a tiledCard); its fill also hides the dock. - const anyFullscreen = !!fullscreenCardId || !!workflowsHub?.fullscreen; + // The singleton app windows (Workflows, Settings) carry their own fullscreen flag, not a tiledCard; their fill also hides the dock. + const settingsFullscreen = useAppSelector((s) => !!s.dashboardLayout.settingsCard?.fullscreen); + const anyFullscreen = !!fullscreenCardId || !!workflowsHub?.fullscreen || settingsFullscreen; const [headerRevealed, setHeaderRevealed] = React.useState(false); const [appsWindowOpen, setAppsWindowOpen] = React.useState(false); useEffect(() => { diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index d0bcc1fa..b6bf901b 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -3,8 +3,7 @@ import { AnimatePresence } from 'framer-motion'; import AgentCard from '../cards/AgentCard'; import DashboardViewCard from '../cards/DashboardViewCard'; import BrowserCard from '../cards/BrowserCard'; -import WorkflowsAppCard from '@/app/pages/Workflows/app/WorkflowsAppCard'; -import RunMonitor from '@/app/pages/Workflows/app/RunMonitor'; +import DashboardWindowCards from './DashboardWindowCards'; import { EXPANDED_CARD_MIN_H, DEFAULT_CARD_W, @@ -15,8 +14,6 @@ import { type WorkflowCardPosition, type WorkflowsHubPosition, } from '@/shared/state/dashboardLayoutSlice'; -import { useAppSelector, useAppDispatch } from '@/shared/hooks'; -import { closeWorkflowMonitor } from '@/shared/state/dashboardLayoutSlice'; import type { Output } from '@/shared/state/outputsSlice'; import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection'; @@ -88,14 +85,6 @@ const DashboardCardLayer: React.FC = ({ onBranch, onMeasuredHeight, }) => { - const dispatch = useAppDispatch(); - const monitorCard = useAppSelector((s) => s.dashboardLayout.workflowsMonitorCard); - const monitorWorkflowId = useAppSelector((s) => s.dashboardLayout.workflowsMonitorId); - const monitorWorkflow = useAppSelector((s) => (monitorWorkflowId ? s.workflows.items[monitorWorkflowId] : undefined)); - // The monitor's workflow vanished (trashed/deleted) while open: tear the card + its tether down instead of leaving an orange line pointing at nothing. - React.useEffect(() => { - if (monitorCard && !monitorWorkflow) dispatch(closeWorkflowMonitor()); - }, [monitorCard, monitorWorkflow, dispatch]); return ( <> @@ -233,38 +222,18 @@ const DashboardCardLayer: React.FC = ({ onBringToFront={onBringToFront} /> ))} - {workflowsHub && ( - - )} - {monitorCard && monitorWorkflow && ( - - )} + {/* Marquee selection rectangle */} {selection.marquee && (
; + highlightedCardId: string | null; + multiDragDelta: { dx: number; dy: number } | null; + getCanvasState: () => { panX: number; panY: number; zoom: number }; + onCardSelect: (id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => void; + onDragStart: (id: string, type: CardType) => void; + onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; + onDragEnd: (dx: number, dy: number, didDrag: boolean) => void; + onBringToFront: (id: string, type: CardType) => void; +} + +// The singleton app windows on the canvas (Workflows, its run monitor, Settings). One per dashboard, so they read their geometry straight from the slice instead of a card map. +const DashboardWindowCards: React.FC = ({ + workflowsHub, + selection, + highlightedCardId, + multiDragDelta, + getCanvasState, + onCardSelect, + onDragStart, + onDragMove, + onDragEnd, + onBringToFront, +}) => { + const dispatch = useAppDispatch(); + const settingsCard = useAppSelector((s) => s.dashboardLayout.settingsCard); + const monitorCard = useAppSelector((s) => s.dashboardLayout.workflowsMonitorCard); + const monitorWorkflowId = useAppSelector((s) => s.dashboardLayout.workflowsMonitorId); + const monitorWorkflow = useAppSelector((s) => (monitorWorkflowId ? s.workflows.items[monitorWorkflowId] : undefined)); + // The monitor's workflow vanished (trashed/deleted) while open: tear the card + its tether down instead of leaving an orange line pointing at nothing. + React.useEffect(() => { + if (monitorCard && !monitorWorkflow) dispatch(closeWorkflowMonitor()); + }, [monitorCard, monitorWorkflow, dispatch]); + + return ( + <> + {workflowsHub && ( + + )} + {settingsCard && ( + + )} + {monitorCard && monitorWorkflow && ( + + )} + + ); +}; + +export default DashboardWindowCards; diff --git a/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx b/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx new file mode 100644 index 00000000..08ed73d1 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx @@ -0,0 +1,299 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { TILE_ZONES, useTiledStyle } from './tileZones'; +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; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onPointerCancel: () => void; + onLostPointerCapture: () => void; + dragging: boolean; +} + +export interface CanvasWindowChrome { + header: CanvasWindowHeader; + onTileZone: (zone: string) => void; +} + +interface CanvasWindowCardProps { + cardId: string; + cardType: CardType; + /** data-select-type / data-select-meta values: the DOM contract paste + onboarding selectors read. */ + selectType: string; + selectName: string; + cardX: number; cardY: number; cardWidth: number; cardHeight: number; cardZOrder?: number; + fullscreen?: boolean; + minWidth: number; minHeight: number; + background: string; highlightColor: string; + getCanvasState: () => { panX: number; panY: number; zoom: number }; + isSelected?: boolean; isHighlighted?: boolean; + multiDragDelta?: { dx: number; dy: number } | null; + onCardSelect?: (id: string, type: CardType, shiftKey: boolean) => void; + onDragStart?: (id: string, type: CardType) => void; + onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; + onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void; + onBringToFront?: (id: string, type: CardType) => void; + onCommitPosition: (x: number, y: number) => void; + onCommitSize: (width: number, height: number) => void; + children: (chrome: CanvasWindowChrome) => React.ReactNode; +} + +// Window chrome for the singleton app cards (Workflows, Settings): drag by the title bar, 8 resize +// handles, tile zones, fullscreen. Geometry lives in the slice; the host passes commit callbacks so +// this stays reducer-agnostic, and renders its body through the children render prop. +const CanvasWindowCard: React.FC = ({ + cardId, cardType, selectType, selectName, + cardX, cardY, cardWidth, cardHeight, cardZOrder = 0, + fullscreen = false, minWidth, minHeight, background, highlightColor, + getCanvasState, + isSelected = false, isHighlighted = false, multiDragDelta = null, + onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront, + onCommitPosition, onCommitSize, + 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 [, forceTick] = useState(0); + useEffect(() => { + if (!fullscreen) 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]); + const cam = getCanvasState(); + const fsStyle = useTiledStyle(fullscreen ? 'fullscreen' : undefined, 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); + const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null); + const didDrag = useRef(false); + const justDraggedRef = useRef(false); + + 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 }; + didDrag.current = false; + setIsDragging(true); + onDragStart?.(cardId, cardType); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }, [cardId, cardType, cardX, cardY, fullscreen, onDragStart, getCanvasState]); + + const onHeaderPointerMove = useCallback((e: React.PointerEvent) => { + if (!dragState.current) return; + const rawDx = e.clientX - dragState.current.startX; + const rawDy = e.clientY - dragState.current.startY; + if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return; + didDrag.current = true; + lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; + const cs = getCanvasState(); + const z = cs.zoom; + const panDx = (cs.panX - dragState.current.startPanX) / z; + const panDy = (cs.panY - dragState.current.startPanY) / z; + const dx = rawDx / z - panDx; + const dy = rawDy / z - panDy; + setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy }); + onDragMove?.(dx, dy, e.clientX, e.clientY); + }, [onDragMove, getCanvasState]); + + const finalizeDrag = useCallback((clientX: number, clientY: number, shiftKey: boolean) => { + if (!dragState.current) return; + const cs = getCanvasState(); + const z = cs.zoom; + const panDx = (cs.panX - dragState.current.startPanX) / z; + const panDy = (cs.panY - dragState.current.startPanY) / z; + const dx = (clientX - dragState.current.startX) / z - panDx; + const dy = (clientY - dragState.current.startY) / z - panDy; + if (didDrag.current) { + justDraggedRef.current = true; + setTimeout(() => { justDraggedRef.current = false; }, 0); + let finalX = dragState.current.origX + dx; + let finalY = dragState.current.origY + dy; + if (!shiftKey) { finalX = Math.round(finalX / SNAP_GRID) * SNAP_GRID; finalY = Math.round(finalY / SNAP_GRID) * SNAP_GRID; } + onCommitPosition(finalX, finalY); + } + onDragEnd?.(dx, dy, didDrag.current); + dragState.current = null; + didDrag.current = false; + setLocalDragPos(null); + setIsDragging(false); + }, [onCommitPosition, onDragEnd, getCanvasState]); + + const onHeaderPointerUp = useCallback((e: React.PointerEvent) => { + if (!dragState.current) return; + finalizeDrag(e.clientX, e.clientY, e.shiftKey); + try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* capture already gone */ } + }, [finalizeDrag]); + + const abortDrag = useCallback(() => { + if (!dragState.current) return; + finalizeDrag(lastPointerRef.current.clientX, lastPointerRef.current.clientY, true); + }, [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 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; + const dy = (localResize?.y ?? localDragPos?.y ?? cardY) + mdDy; + const dw = localResize?.w ?? cardWidth; + const dh = localResize?.h ?? cardHeight; + + const border = isHighlighted ? `2px solid ${highlightColor}` : isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.subtle}`; + const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); + + return ( +
{ + const target = e.target as HTMLElement; + if (target.closest('[data-no-drag]')) return; + onBringToFront?.(cardId, cardType); + }} + onClick={(e: React.MouseEvent) => { + if (justDraggedRef.current) return; + const target = e.target as HTMLElement; + if (target.closest('[data-no-drag]')) return; + onCardSelect?.(cardId, cardType, e.shiftKey); + }} + style={{ + position: 'absolute', + contain: 'layout style', + willChange: 'transform', + left: fsStyle ? fsStyle.left : dx, + top: fsStyle ? fsStyle.top : dy, + width: fsStyle ? fsStyle.width : dw, + height: fsStyle ? fsStyle.height : dh, + transform: fsStyle ? fsStyle.transform : undefined, + transformOrigin: fsStyle ? fsStyle.transformOrigin : undefined, + background, + border: fsStyle ? '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, + transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease', + }} + > + {children({ + header: { + onPointerDown: onHeaderPointerDown, + onPointerMove: onHeaderPointerMove, + onPointerUp: onHeaderPointerUp, + onPointerCancel: abortDrag, + onLostPointerCapture: abortDrag, + dragging: isDragging, + }, + onTileZone, + })} + + {!fullscreen && HANDLE_DEFS.map(({ dir, css }) => ( +
+ ))} +
+ ); +}; + +export default CanvasWindowCard; diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index 15b5bf20..95787492 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -4,11 +4,10 @@ import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import LanguageIcon from '@mui/icons-material/Language'; import EventRepeatIcon from '@mui/icons-material/EventRepeat'; -import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; +import { openSettingsCard, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; import SettingsIcon from '@mui/icons-material/Settings'; import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; import { useAppDispatch } from '@/shared/hooks'; -import { openSettingsModal } from '@/shared/state/settingsSlice'; import { getWebview } from '@/shared/browserRegistry'; import { buildDockEntries, CardRect, DockEntry } from './dockEntries'; import type { AgentSession } from '@/shared/state/agentsSlice'; @@ -199,7 +198,7 @@ function DesktopDock({ {([ { label: 'New browser', icon: , act: onAddBrowser }, { label: 'Workflows', icon: , act: () => dispatch(openWorkflowsApp()) }, - { label: 'Settings', icon: , act: () => dispatch(openSettingsModal(undefined)), divider: true }, + { label: 'Settings', icon: , act: () => dispatch(openSettingsCard()), divider: true }, { label: 'Applications', icon: , act: onApplications, bg: 'linear-gradient(135deg, #3d3d46, #232329)' }, ] as { label: string; icon: React.ReactNode; act: () => void; divider?: boolean; bg?: string }[]).map((a) => ( diff --git a/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts b/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts index 9ec71f81..7ea309ee 100644 --- a/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts +++ b/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts @@ -25,6 +25,10 @@ export function getCardRect(id: string, type: CardType): const hub = layoutState.workflowsHub; if (!hub) return undefined; return { x: hub.x, y: hub.y, width: hub.width, height: hub.height }; + } else if (type === 'settings') { + const sc = layoutState.settingsCard; + if (!sc) return undefined; + return { x: sc.x, y: sc.y, width: sc.width, height: sc.height }; } return undefined; } diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts index defc365a..a953a261 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts @@ -1,5 +1,5 @@ import { closeSession } from '@/shared/state/agentsSlice'; -import { removeWorkflowCard, closeWorkflowsHub, recordClosedCard } from '@/shared/state/dashboardLayoutSlice'; +import { removeWorkflowCard, closeWorkflowsHub, closeSettingsCard, recordClosedCard } from '@/shared/state/dashboardLayoutSlice'; import { closeWorkflowCard } from '@/shared/state/workflowsSlice'; import { removeBrowserCardsCleanly } from '@/shared/browserTeardown'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; @@ -26,6 +26,8 @@ export function deleteSelectedCards(selectedIds: Map, dispatch dispatch(closeWorkflowCard(id)); } else if (type === 'workflows-hub') { dispatch(closeWorkflowsHub()); + } else if (type === 'settings') { + dispatch(closeSettingsCard()); } } // Tear webview-backed cards down ONE AT A TIME (each quiesces / CDP-detaches its GPU surface diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index fa4520b0..879d09b0 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -18,6 +18,7 @@ import { clearPendingFocusViewCardId, clearPendingFocusWorkflowId, clearPendingFocusWorkflowsHub, + clearPendingFocusSettingsCard, type ViewCardPosition, } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs, type Output } from '@/shared/state/outputsSlice'; @@ -77,6 +78,7 @@ export function useDashboardLifecycle({ const pendingFocusViewCardId = useAppSelector((state) => state.dashboardLayout.pendingFocusViewCardId); const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId); const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub); + const pendingFocusSettingsCard = useAppSelector((state) => state.dashboardLayout.pendingFocusSettingsCard); // Once per app launch: if scheduled fires elapsed while we were closed, fetch them. The slice flips its toast flag on fulfilled, so a bottom-left nudge shows instead of a card popping unrequested; the user opens the card from it. useEffect(() => { @@ -270,6 +272,25 @@ export function useDashboardLifecycle({ return () => clearTimeout(fallback); }, [isActive, pendingFocusWorkflowsHub, layoutInitialized, dispatch, canvasActions]); + // Same for the Settings window: the dock can open it off-screen, so glide to it or the click looks dead. + useEffect(() => { + if (!isActive) return; + if (!pendingFocusSettingsCard || !layoutInitialized) return; + dispatch(clearPendingFocusSettingsCard()); + const fit = () => { + const card = store.getState().dashboardLayout.settingsCard; + if (!card) return; + canvasActions.fitToCards( + [{ x: card.x, y: card.y, width: card.width, height: card.height }], + 1.1, + true, + ); + }; + requestAnimationFrame(() => requestAnimationFrame(fit)); + const fallback = setTimeout(fit, 300); + return () => clearTimeout(fallback); + }, [isActive, pendingFocusSettingsCard, layoutInitialized, dispatch, canvasActions]); + useEffect(() => { if (!layoutInitialized || restoredExpandedRef.current) return; restoredExpandedRef.current = true; diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index cd6f3951..4e53132b 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -1,251 +1,27 @@ -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import Box from '@mui/material/Box'; -import Snackbar from '@mui/material/Snackbar'; -import Alert from '@mui/material/Alert'; +import React, { useCallback } from 'react'; import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import CircularProgress from '@mui/material/CircularProgress'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { updateSettingsPatch, closeSettingsModal, AppSettings } from '@/shared/state/settingsSlice'; +import { closeSettingsModal } from '@/shared/state/settingsSlice'; import { onboardingBus } from '@/app/components/Onboarding/eventBus'; -import { fetchModels } from '@/shared/state/modelsSlice'; -import { fetchModes } from '@/shared/state/modesSlice'; -import { useThemeMode, useThemeAccent, useClaudeTokens } from '@/shared/styles/ThemeContext'; -import IconButton from '@mui/material/IconButton'; -import Typography from '@mui/material/Typography'; -import { X } from 'lucide-react'; -import DirectoryBrowser from '@/app/components/editor/DirectoryBrowser'; -import { CommandsContent } from '@/app/pages/Commands/Commands'; -import AccountCard from './sections/subscription/AccountCard'; -import GeneralAgentDefaults from './sections/general/GeneralAgentDefaults'; -import GeneralInterface from './sections/general/GeneralInterface'; -import GeneralAdvanced from './sections/general/GeneralAdvanced'; -import DataPrivacySection from './sections/general/DataPrivacySection'; -import ModelsTab from './sections/models/ModelsTab'; -import UsageStats from './sections/usage/UsageStats'; -import SettingsRail, { railLabelFor } from './sections/SettingsRail'; -import { makeSettingsStyles } from './sections/settingsStyles'; - -// Skills/Tools moved here from the old sidebar Customization section; lazy since both pull heavy deps and Settings opens nearly every session. -const SkillsTab = React.lazy(() => import('@/app/pages/Skills/Skills')); -const ToolsTab = React.lazy(() => import('@/app/pages/Tools/Tools')); - -// Brand colors for provider group headers; mirrors ChatInput picker. -const PROVIDER_COLORS: Record = { - anthropic: '#E8927A', - openai: '#74AA9C', - google: '#4285F4', - gemini: '#4285F4', - xai: '#8B949E', - meta: '#0866FF', - deepseek: '#4D6BFE', - mistral: '#FF7000', - qwen: '#A974FF', - cohere: '#FF7759', -}; -const OPENSWARM_GRADIENT = - 'linear-gradient(135deg, #8FB3FF 0%, #E56BC4 45%, #FFA85C 100%)'; - -// Module-scope: remember the last open tab across modal closes (System Settings style). -let lastOpenTab: string | null = null; - -// Shown only in the brief window before the live model list loads from the backend. Keep the flagship current so the default-model dropdown isn't stale. -const DEFAULT_MODEL_FALLBACK = [ - { value: 'opus-5', label: 'Claude Opus 5' }, - { value: 'opus-4-8', label: 'Claude Opus 4.8' }, - { value: 'sonnet', label: 'Claude Sonnet 4.6' }, - { value: 'opus', label: 'Claude Opus 4.6' }, - { value: 'haiku', label: 'Claude Haiku 4.5' }, -]; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import SettingsBody from './SettingsBody'; +// Modal host for the settings UI, kept for every programmatic caller (provider-health toast, search palette, "Configure models" links). The dock opens the same body as an on-canvas window instead. const Settings: React.FC = () => { const open = useAppSelector((s) => s.settings.modalOpen); + const initialTab = useAppSelector((s) => s.settings.initialTab); const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const settings = useAppSelector((s) => s.settings.data); - const loaded = useAppSelector((s) => s.settings.loaded); - const modes = useAppSelector((s) => s.modes.items); - const { setMode: setThemeMode } = useThemeMode(); - const { setAccent, setGradient } = useThemeAccent(); - const modesList = useMemo(() => Object.values(modes), [modes]); - - // Model picker source matches the in-session ChatInput picker, so Settings reflects connected providers. - const modelsByProvider = useAppSelector((s) => s.models.byProvider); - const modelsLoaded = useAppSelector((s) => s.models.loaded); - - const modelOptions = useMemo(() => { - if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) { - const key = settings.connection_mode === 'openswarm-pro' ? 'OpenSwarm Pro' : 'Anthropic'; - return { - grouped: { [key]: DEFAULT_MODEL_FALLBACK }, - flat: DEFAULT_MODEL_FALLBACK.map((m) => ({ ...m, provider: key })), - }; - } - const grouped: Record> = {}; - const flat: Array<{ value: string; label: string; provider: string }> = []; - for (const [prov, models] of Object.entries(modelsByProvider)) { - grouped[prov] = models.map((m) => ({ value: m.value, label: m.label })); - for (const m of models) flat.push({ value: m.value, label: m.label, provider: prov }); - } - // Guarantee the currently-selected default is always a valid option, even if the live list doesn't carry it (custom/OpenRouter value, or a stored model not in the current registry). Without this the dropdown gets an MUI "out-of-range value" warning and renders blank. - const sel = settings.default_model; - if (sel && !flat.some((m) => m.value === sel)) { - const other = 'Other'; - (grouped[other] ||= []).push({ value: sel, label: sel }); - flat.push({ value: sel, label: sel, provider: other }); - } - return { grouped, flat }; - }, [modelsByProvider, modelsLoaded, settings.connection_mode, settings.default_model]); - - const initialTab = useAppSelector((s) => s.settings.initialTab); - const TAB_VALUES = ['account', 'general', 'appearance', 'privacy', 'advanced', 'models', 'skills', 'tools', 'commands', 'usage'] as const; - type SettingsTab = typeof TAB_VALUES[number]; - const isValidTab = (t: string | null | undefined): t is SettingsTab => - !!t && (TAB_VALUES as readonly string[]).includes(t); - const [activeTab, setActiveTab] = useState( - isValidTab(lastOpenTab) ? lastOpenTab : 'general', - ); - const [form, setForm] = useState({ ...settings }); - - // Re-seed form on user change; otherwise the dirty detector falsely lights up Save/Discard. - useEffect(() => { - setForm({ ...settings }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings.user_id, settings.user_email]); - - // Switch to requested tab when modal opens (e.g. from the "Configure models" banner link). - useEffect(() => { - if (initialTab && (TAB_VALUES as readonly string[]).includes(initialTab)) { - setActiveTab(initialTab as SettingsTab); - } - }, [initialTab]); - const [showApiKey, setShowApiKey] = useState(false); - const [browseOpen, setBrowseOpen] = useState(false); - const [saveError, setSaveError] = useState(false); - - useEffect(() => { - dispatch(fetchModes()); - }, [dispatch]); - - useEffect(() => { - if (open) dispatch(fetchModels()); - }, [open, dispatch]); - - useEffect(() => { - // On open, restore the last open tab; explicit initialTab is handled by the effect above. - if (open && !initialTab) { - setActiveTab(isValidTab(lastOpenTab) ? lastOpenTab : 'general'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, initialTab]); - - useEffect(() => { - lastOpenTab = activeTab; - }, [activeTab]); - - // Sync form on modal open + first load only; including `settings` in deps wipes in-flight edits on background fetches (issue #25). baseline = the snapshot the user started editing from, so we can tell user edits apart from fields the backend changed underneath us (OAuth connects, free-trial mints). - const baselineRef = useRef(settings); - useEffect(() => { - if (open && loaded) { - setForm({ ...settings }); - baselineRef.current = settings; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, loaded]); - - // Apply-on-change (System Settings style): edits save themselves after a short debounce, so text fields settle between keystrokes and toggles feel instant. - const saveTimer = useRef | null>(null); - const inFlight = useRef(false); - - // Only the fields the user touched ride on top of the LATEST settings; submitting the whole stale form would clobber background updates and ping-pong with server-owned fields. - const buildSubmit = useCallback((): { touched: string[]; patch: Partial } | null => { - const base = baselineRef.current as unknown as Record; - const f = form as unknown as Record; - const touched = Array.from(new Set([...Object.keys(base), ...Object.keys(f)])) - .filter((k) => JSON.stringify(f[k]) !== JSON.stringify(base[k])); - if (touched.length === 0) return null; - // Send ONLY what the user changed; the server merges it onto fresh state, so we never re-send (and clobber) a field something else updated underneath us. - const patch: Record = {}; - for (const k of touched) patch[k] = f[k]; - return { touched, patch: patch as Partial }; - }, [form]); - - // Theme is local UI state; apply it the moment the toggle flips, the debounced save persists it. - useEffect(() => { - if (open && loaded) setThemeMode(form.theme); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.theme]); - - // Accent + gradient apply live too, same contract as theme: instant paint, debounced persist. - useEffect(() => { - if (open && loaded) { - setAccent(form.accent_color ?? null); - setGradient(form.accent_gradient ?? null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.accent_color, form.accent_gradient]); - - // Text size applies live too; AppShell re-applies the persisted value on every boot. - useEffect(() => { - if (open && loaded) { - const scale = Math.min(1.4, Math.max(0.8, form.ui_font_scale ?? 1)); - document.documentElement.style.fontSize = `${scale * 100}%`; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.ui_font_scale]); - - useEffect(() => { - if (!open || !loaded) return; - if (!buildSubmit()) return; - if (saveTimer.current) clearTimeout(saveTimer.current); - saveTimer.current = setTimeout(async () => { - // A save already in flight will update `settings` when it lands, re-running this effect to pick up whatever is still unsaved. - if (inFlight.current) return; - const payload = buildSubmit(); - if (!payload) return; - inFlight.current = true; - try { - await dispatch(updateSettingsPatch(payload.patch)).unwrap(); - // Absorb the saved edits so they stop counting as touched (prevents re-save loops). - const nextBase = { ...baselineRef.current } as Record; - for (const k of payload.touched) nextBase[k] = (form as unknown as Record)[k]; - baselineRef.current = nextBase as unknown as AppSettings; - dispatch(fetchModels()); - } catch { - setSaveError(true); - } finally { - inFlight.current = false; - } - }, 900); - return () => { - if (saveTimer.current) clearTimeout(saveTimer.current); - }; - }, [form, open, loaded, settings, dispatch, buildSubmit]); - - // Closing flushes any edit still inside the debounce window; nothing is ever lost or asked about. - const handleRequestClose = useCallback(() => { - if (saveTimer.current) clearTimeout(saveTimer.current); - const payload = loaded ? buildSubmit() : null; - if (payload) { - // Refetch only AFTER the patch lands, or it races the save and reads the pre-change list (stale Haiku until you reopen Settings). Not awaited, so the modal still closes instantly. - dispatch(updateSettingsPatch(payload.patch)) - .unwrap() - .then(() => dispatch(fetchModels())) - .catch(() => {}); - baselineRef.current = form; - } + const handleClose = useCallback(() => { dispatch(closeSettingsModal()); onboardingBus.emit('settings:closed'); - }, [dispatch, form, loaded, buildSubmit]); - - const styles = makeSettingsStyles(c); + }, [dispatch]); return ( - <> { }, }} > - setActiveTab(v as SettingsTab)} /> - - - - - {railLabelFor(activeTab)} - - - - - - - - {activeTab === 'account' ? ( - - - - ) : activeTab === 'general' ? ( - - - - ) : activeTab === 'appearance' ? ( - - - - ) : activeTab === 'privacy' ? ( - - - - ) : activeTab === 'advanced' ? ( - - - - ) : activeTab === 'models' ? ( - - ) : activeTab === 'usage' ? ( - - - - ) : activeTab === 'skills' ? ( - - }> - - - - ) : activeTab === 'tools' ? ( - - }> - - - - ) : ( - - - - )} - - - - setBrowseOpen(false)} - onSelect={(item) => setForm({ ...form, default_folder: item.path })} - initialPath={form.default_folder ?? ''} - /> - - setSaveError(false)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - setSaveError(false)} severity="error" sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.status.error}` }}> - Couldn't save that change. Try again in a moment. - - + - ); }; diff --git a/frontend/src/app/pages/Settings/SettingsAppCard.tsx b/frontend/src/app/pages/Settings/SettingsAppCard.tsx new file mode 100644 index 00000000..19563dc5 --- /dev/null +++ b/frontend/src/app/pages/Settings/SettingsAppCard.tsx @@ -0,0 +1,129 @@ +import React, { useCallback } from 'react'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { + closeSettingsCard, + setSettingsCardPosition, + setSettingsCardSize, + toggleSettingsCardFullscreen, + SETTINGS_CARD_ID, +} from '@/shared/state/dashboardLayoutSlice'; +import type { CardType } from '@/shared/state/dashboardLayoutSlice'; +import CanvasWindowCard from '@/app/pages/Dashboard/cards/CanvasWindowCard'; +import WindowControls from '@/app/pages/Dashboard/cards/WindowControls'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import SettingsBody from './SettingsBody'; + +const MIN_W = 640; +const MIN_H = 460; + +interface Props { + cardX: number; + cardY: number; + cardWidth: number; + cardHeight: number; + cardZOrder?: number; + getCanvasState: () => { panX: number; panY: number; zoom: number }; + isSelected?: boolean; + isHighlighted?: boolean; + multiDragDelta?: { dx: number; dy: number } | null; + onCardSelect?: (id: string, type: CardType, shiftKey: boolean) => void; + onDragStart?: (id: string, type: CardType) => void; + onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; + onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void; + onBringToFront?: (id: string, type: CardType) => void; +} + +// Settings as a real dashboard window: same chrome as the Workflows app, same body as the modal. +const SettingsAppCard: React.FC = ({ + cardX, cardY, cardWidth, cardHeight, cardZOrder = 0, + getCanvasState, + isSelected = false, isHighlighted = false, multiDragDelta = null, + onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront, +}) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const isFullscreen = useAppSelector((s) => !!s.dashboardLayout.settingsCard?.fullscreen); + + const commitPosition = useCallback((x: number, y: number) => { + dispatch(setSettingsCardPosition({ x, y })); + }, [dispatch]); + const commitSize = useCallback((width: number, height: number) => { + dispatch(setSettingsCardSize({ width, height })); + }, [dispatch]); + const close = useCallback(() => { dispatch(closeSettingsCard()); }, [dispatch]); + + return ( + + {({ header, onTileZone }) => ( + <> +
+ e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + style={{ display: 'flex', alignItems: 'center' }} + > + { + if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleSettingsCardFullscreen()); return; } + if (isFullscreen) dispatch(toggleSettingsCardFullscreen()); + onTileZone(zone); + }} + tiled={isFullscreen} + noTileMenu={isFullscreen} + /> + +
+ + Settings +
+
+ + + )} +
+ ); +}; + +export default SettingsAppCard; diff --git a/frontend/src/app/pages/Settings/SettingsBody.tsx b/frontend/src/app/pages/Settings/SettingsBody.tsx new file mode 100644 index 00000000..d0f2dd4e --- /dev/null +++ b/frontend/src/app/pages/Settings/SettingsBody.tsx @@ -0,0 +1,194 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import CircularProgress from '@mui/material/CircularProgress'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import { X } from 'lucide-react'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { fetchModels } from '@/shared/state/modelsSlice'; +import { fetchModes } from '@/shared/state/modesSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import DirectoryBrowser from '@/app/components/editor/DirectoryBrowser'; +import { CommandsContent } from '@/app/pages/Commands/Commands'; +import AccountCard from './sections/subscription/AccountCard'; +import GeneralAgentDefaults from './sections/general/GeneralAgentDefaults'; +import GeneralInterface from './sections/general/GeneralInterface'; +import GeneralAdvanced from './sections/general/GeneralAdvanced'; +import DataPrivacySection from './sections/general/DataPrivacySection'; +import ModelsTab from './sections/models/ModelsTab'; +import UsageStats from './sections/usage/UsageStats'; +import SettingsRail, { railLabelFor } from './sections/SettingsRail'; +import { makeSettingsStyles } from './sections/settingsStyles'; +import { useSettingsForm } from './useSettingsForm'; +import { PROVIDER_COLORS, OPENSWARM_GRADIENT, useModelOptions } from './settingsModelOptions'; + +// Skills/Tools moved here from the old sidebar Customization section; lazy since both pull heavy deps and Settings opens nearly every session. +const SkillsTab = React.lazy(() => import('@/app/pages/Skills/Skills')); +const ToolsTab = React.lazy(() => import('@/app/pages/Tools/Tools')); + +// Module-scope: remember the last open tab across closes (System Settings style). +let lastOpenTab: string | null = null; + +const TAB_VALUES = ['account', 'general', 'appearance', 'privacy', 'advanced', 'models', 'skills', 'tools', 'commands', 'usage'] as const; +type SettingsTab = typeof TAB_VALUES[number]; +const isValidTab = (t: string | null | undefined): t is SettingsTab => + !!t && (TAB_VALUES as readonly string[]).includes(t); + +interface SettingsBodyProps { + /** The host is showing this body; gates the fetches, the live theme apply and the debounced save. */ + active: boolean; + /** Tab a programmatic caller asked for (openSettingsModal('models'), search palette, dock). */ + requestedTab: string | null; + onRequestClose: () => void; +} + +// The settings UI itself: rail + section. Hosted by the modal (Settings.tsx) and by the on-canvas window (SettingsAppCard) with no forked copy between them. +const SettingsBody: React.FC = ({ active, requestedTab, onRequestClose }) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const modes = useAppSelector((s) => s.modes.items); + const modesList = useMemo(() => Object.values(modes), [modes]); + const modelOptions = useModelOptions(); + const { form, setForm, saveError, dismissSaveError, flushPendingSave } = useSettingsForm(active); + + const [activeTab, setActiveTab] = useState(isValidTab(lastOpenTab) ? lastOpenTab : 'general'); + const [showApiKey, setShowApiKey] = useState(false); + const [browseOpen, setBrowseOpen] = useState(false); + + useEffect(() => { + dispatch(fetchModes()); + }, [dispatch]); + + useEffect(() => { + if (active) dispatch(fetchModels()); + }, [active, dispatch]); + + // Switch to the requested tab (e.g. from the "Configure models" banner link); without one, restore the last open tab. + useEffect(() => { + if (isValidTab(requestedTab)) setActiveTab(requestedTab); + else if (active) setActiveTab(isValidTab(lastOpenTab) ? lastOpenTab : 'general'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, requestedTab]); + + useEffect(() => { + lastOpenTab = activeTab; + }, [activeTab]); + + const handleRequestClose = (): void => { + flushPendingSave(); + onRequestClose(); + }; + + const styles = makeSettingsStyles(c); + + return ( + + setActiveTab(v as SettingsTab)} /> + + + + + {railLabelFor(activeTab)} + + + + + + + + {activeTab === 'account' ? ( + + + + ) : activeTab === 'general' ? ( + + + + ) : activeTab === 'appearance' ? ( + + + + ) : activeTab === 'privacy' ? ( + + + + ) : activeTab === 'advanced' ? ( + + + + ) : activeTab === 'models' ? ( + + ) : activeTab === 'usage' ? ( + + + + ) : activeTab === 'skills' ? ( + + }> + + + + ) : activeTab === 'tools' ? ( + + }> + + + + ) : ( + + + + )} + + + + setBrowseOpen(false)} + onSelect={(item) => setForm({ ...form, default_folder: item.path })} + initialPath={form.default_folder ?? ''} + /> + + + + Couldn't save that change. Try again in a moment. + + + + ); +}; + +export default SettingsBody; diff --git a/frontend/src/app/pages/Settings/settingsModelOptions.ts b/frontend/src/app/pages/Settings/settingsModelOptions.ts new file mode 100644 index 00000000..e3fa518b --- /dev/null +++ b/frontend/src/app/pages/Settings/settingsModelOptions.ts @@ -0,0 +1,64 @@ +import { useMemo } from 'react'; +import { useAppSelector } from '@/shared/hooks'; + +// Brand colors for provider group headers; mirrors ChatInput picker. +export const PROVIDER_COLORS: Record = { + anthropic: '#E8927A', + openai: '#74AA9C', + google: '#4285F4', + gemini: '#4285F4', + xai: '#8B949E', + meta: '#0866FF', + deepseek: '#4D6BFE', + mistral: '#FF7000', + qwen: '#A974FF', + cohere: '#FF7759', +}; + +export const OPENSWARM_GRADIENT = + 'linear-gradient(135deg, #8FB3FF 0%, #E56BC4 45%, #FFA85C 100%)'; + +// Shown only in the brief window before the live model list loads from the backend. Keep the flagship current so the default-model dropdown isn't stale. +const DEFAULT_MODEL_FALLBACK = [ + { value: 'opus-5', label: 'Claude Opus 5' }, + { value: 'opus-4-8', label: 'Claude Opus 4.8' }, + { value: 'sonnet', label: 'Claude Sonnet 4.6' }, + { value: 'opus', label: 'Claude Opus 4.6' }, + { value: 'haiku', label: 'Claude Haiku 4.5' }, +]; + +export interface ModelOptions { + grouped: Record>; + flat: Array<{ value: string; label: string; provider: string }>; +} + +// Model picker source matches the in-session ChatInput picker, so Settings reflects connected providers. +export function useModelOptions(): ModelOptions { + const connectionMode = useAppSelector((s) => s.settings.data.connection_mode); + const defaultModel = useAppSelector((s) => s.settings.data.default_model); + const modelsByProvider = useAppSelector((s) => s.models.byProvider); + const modelsLoaded = useAppSelector((s) => s.models.loaded); + + return useMemo(() => { + if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) { + const key = connectionMode === 'openswarm-pro' ? 'OpenSwarm Pro' : 'Anthropic'; + return { + grouped: { [key]: DEFAULT_MODEL_FALLBACK }, + flat: DEFAULT_MODEL_FALLBACK.map((m) => ({ ...m, provider: key })), + }; + } + const grouped: Record> = {}; + const flat: Array<{ value: string; label: string; provider: string }> = []; + for (const [prov, models] of Object.entries(modelsByProvider)) { + grouped[prov] = models.map((m) => ({ value: m.value, label: m.label })); + for (const m of models) flat.push({ value: m.value, label: m.label, provider: prov }); + } + // Guarantee the currently-selected default is always a valid option, even if the live list doesn't carry it (custom/OpenRouter value, or a stored model not in the current registry). Without this the dropdown gets an MUI "out-of-range value" warning and renders blank. + if (defaultModel && !flat.some((m) => m.value === defaultModel)) { + const other = 'Other'; + (grouped[other] ||= []).push({ value: defaultModel, label: defaultModel }); + flat.push({ value: defaultModel, label: defaultModel, provider: other }); + } + return { grouped, flat }; + }, [modelsByProvider, modelsLoaded, connectionMode, defaultModel]); +} diff --git a/frontend/src/app/pages/Settings/useSettingsForm.ts b/frontend/src/app/pages/Settings/useSettingsForm.ts new file mode 100644 index 00000000..70c01816 --- /dev/null +++ b/frontend/src/app/pages/Settings/useSettingsForm.ts @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { updateSettingsPatch, AppSettings } from '@/shared/state/settingsSlice'; +import { fetchModels } from '@/shared/state/modelsSlice'; +import { useThemeMode, useThemeAccent } from '@/shared/styles/ThemeContext'; + +export interface SettingsForm { + form: AppSettings; + setForm: React.Dispatch>; + saveError: boolean; + dismissSaveError: () => void; + /** Push whatever is still inside the debounce window; the host calls this on close so nothing is lost. */ + flushPendingSave: () => void; +} + +// Apply-on-change settings editing (System Settings style), shared by both hosts of the settings UI: the modal and the on-canvas window. `active` gates every effect so an unmounted-but-alive host never saves. +export function useSettingsForm(active: boolean): SettingsForm { + const dispatch = useAppDispatch(); + const settings = useAppSelector((s) => s.settings.data); + const loaded = useAppSelector((s) => s.settings.loaded); + const { setMode: setThemeMode } = useThemeMode(); + const { setAccent, setGradient } = useThemeAccent(); + const [form, setForm] = useState({ ...settings }); + const [saveError, setSaveError] = useState(false); + + // Re-seed form on user change; otherwise the dirty detector falsely lights up Save/Discard. + useEffect(() => { + setForm({ ...settings }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings.user_id, settings.user_email]); + + // Sync form on open + first load only; including `settings` in deps wipes in-flight edits on background fetches (issue #25). baseline = the snapshot the user started editing from, so we can tell user edits apart from fields the backend changed underneath us (OAuth connects, free-trial mints). + const baselineRef = useRef(settings); + useEffect(() => { + if (active && loaded) { + setForm({ ...settings }); + baselineRef.current = settings; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, loaded]); + + const saveTimer = useRef | null>(null); + const inFlight = useRef(false); + + // Only the fields the user touched ride on top of the LATEST settings; submitting the whole stale form would clobber background updates and ping-pong with server-owned fields. + const buildSubmit = useCallback((): { touched: string[]; patch: Partial } | null => { + const base = baselineRef.current as unknown as Record; + const f = form as unknown as Record; + const touched = Array.from(new Set([...Object.keys(base), ...Object.keys(f)])) + .filter((k) => JSON.stringify(f[k]) !== JSON.stringify(base[k])); + if (touched.length === 0) return null; + // Send ONLY what the user changed; the server merges it onto fresh state, so we never re-send (and clobber) a field something else updated underneath us. + const patch: Record = {}; + for (const k of touched) patch[k] = f[k]; + return { touched, patch: patch as Partial }; + }, [form]); + + // Theme is local UI state; apply it the moment the toggle flips, the debounced save persists it. + useEffect(() => { + if (active && loaded) setThemeMode(form.theme); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.theme]); + + // Accent + gradient apply live too, same contract as theme: instant paint, debounced persist. + useEffect(() => { + if (active && loaded) { + setAccent(form.accent_color ?? null); + setGradient(form.accent_gradient ?? null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.accent_color, form.accent_gradient]); + + // Text size applies live too; AppShell re-applies the persisted value on every boot. + useEffect(() => { + if (active && loaded) { + const scale = Math.min(1.4, Math.max(0.8, form.ui_font_scale ?? 1)); + document.documentElement.style.fontSize = `${scale * 100}%`; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.ui_font_scale]); + + useEffect(() => { + if (!active || !loaded) return; + if (!buildSubmit()) return; + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(async () => { + // A save already in flight will update `settings` when it lands, re-running this effect to pick up whatever is still unsaved. + if (inFlight.current) return; + const payload = buildSubmit(); + if (!payload) return; + inFlight.current = true; + try { + await dispatch(updateSettingsPatch(payload.patch)).unwrap(); + // Absorb the saved edits so they stop counting as touched (prevents re-save loops). + const nextBase = { ...baselineRef.current } as Record; + for (const k of payload.touched) nextBase[k] = (form as unknown as Record)[k]; + baselineRef.current = nextBase as unknown as AppSettings; + dispatch(fetchModels()); + } catch { + setSaveError(true); + } finally { + inFlight.current = false; + } + }, 900); + return () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + }; + }, [form, active, loaded, settings, dispatch, buildSubmit]); + + const flushPendingSave = useCallback(() => { + if (saveTimer.current) clearTimeout(saveTimer.current); + const payload = loaded ? buildSubmit() : null; + if (!payload) return; + // Refetch only AFTER the patch lands, or it races the save and reads the pre-change list (stale Haiku until you reopen Settings). Not awaited, so the host still closes instantly. + dispatch(updateSettingsPatch(payload.patch)) + .unwrap() + .then(() => dispatch(fetchModels())) + .catch(() => {}); + baselineRef.current = form; + }, [dispatch, form, loaded, buildSubmit]); + + // Esc, backdrop click or a closing window unmounts the UI without touching the close button, so flush there too or an edit inside the 900ms debounce dies with the host. + const flushRef = useRef(flushPendingSave); + useEffect(() => { flushRef.current = flushPendingSave; }, [flushPendingSave]); + useEffect(() => () => flushRef.current(), []); + + const dismissSaveError = useCallback(() => setSaveError(false), []); + + return { form, setForm, saveError, dismissSaveError, flushPendingSave }; +} diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index a66b7eea..5c5e5443 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -1,34 +1,13 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice'; -import { TILE_ZONES, useTiledStyle } from '@/app/pages/Dashboard/cards/tileZones'; -import { useDragEndBackstops } from '@/app/pages/Dashboard/hooks/interaction/useDragEndBackstops'; +import CanvasWindowCard from '@/app/pages/Dashboard/cards/CanvasWindowCard'; +import type { CardType } from '@/shared/state/dashboardLayoutSlice'; import { useWC } from './uiKit'; import WorkflowsAppContent from './WorkflowsAppContent'; -type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; -type CardType = 'workflows-hub'; - -const EDGE = 6; -const CORNER = 14; const MIN_W = 900; const MIN_H = 520; -const DRAG_THRESHOLD = 3; - -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 } }, -]; interface Props { cardX: number; @@ -56,223 +35,49 @@ const WorkflowsAppCard: React.FC = ({ const WC = useWC(); const dispatch = useAppDispatch(); const isFullscreen = useAppSelector((s) => !!s.dashboardLayout.workflowsHub?.fullscreen); - // 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 [, forceTick] = useState(0); - useEffect(() => { - if (!isFullscreen) return undefined; - const onPan = (): void => forceTick((t) => t + 1); - window.addEventListener('openswarm:canvas-pan-changed', onPan); - return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan); - }, [isFullscreen]); - const cam = getCanvasState(); - const fsStyle = useTiledStyle(isFullscreen ? 'fullscreen' : undefined, cam.panX, cam.panY, cam.zoom, getCanvasState, 'workflows-hub'); - - - // ---- Drag (title bar is the handle) ---- - const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); - const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 }); - const [isDragging, setIsDragging] = useState(false); - const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null); - const didDrag = useRef(false); - const justDraggedRef = useRef(false); // Keep fonts/keyframes available while the card is mounted. useEffect(() => { ensureAssets(); }, []); - const onHeaderPointerDown = useCallback((e: React.PointerEvent) => { - if (e.button !== 0) return; - if (isFullscreen) 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 }; - didDrag.current = false; - setIsDragging(true); - onDragStart?.('workflows-hub', 'workflows-hub'); - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - }, [cardX, cardY, onDragStart, getCanvasState]); - - const onHeaderPointerMove = useCallback((e: React.PointerEvent) => { - if (!dragState.current) return; - const rawDx = e.clientX - dragState.current.startX; - const rawDy = e.clientY - dragState.current.startY; - if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return; - didDrag.current = true; - lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; - const cs = getCanvasState(); - const z = cs.zoom; - const panDx = (cs.panX - dragState.current.startPanX) / z; - const panDy = (cs.panY - dragState.current.startPanY) / z; - const dx = rawDx / z - panDx; - const dy = rawDy / z - panDy; - setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy }); - onDragMove?.(dx, dy, e.clientX, e.clientY); - }, [onDragMove, getCanvasState]); - - const finalizeDrag = useCallback((clientX: number, clientY: number, shiftKey: boolean) => { - if (!dragState.current) return; - const cs = getCanvasState(); - const z = cs.zoom; - const panDx = (cs.panX - dragState.current.startPanX) / z; - const panDy = (cs.panY - dragState.current.startPanY) / z; - const dx = (clientX - dragState.current.startX) / z - panDx; - const dy = (clientY - dragState.current.startY) / z - panDy; - if (didDrag.current) { - justDraggedRef.current = true; - setTimeout(() => { justDraggedRef.current = false; }, 0); - let finalX = dragState.current.origX + dx; - let finalY = dragState.current.origY + dy; - if (!shiftKey) { finalX = Math.round(finalX / 24) * 24; finalY = Math.round(finalY / 24) * 24; } - dispatch(setWorkflowsHubPosition({ x: finalX, y: finalY })); - } - onDragEnd?.(dx, dy, didDrag.current); - dragState.current = null; - didDrag.current = false; - setLocalDragPos(null); - setIsDragging(false); - }, [dispatch, onDragEnd, getCanvasState]); - - const onHeaderPointerUp = useCallback((e: React.PointerEvent) => { - if (!dragState.current) return; - finalizeDrag(e.clientX, e.clientY, e.shiftKey); - try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* capture already gone */ } - }, [finalizeDrag]); - - const abortDrag = useCallback(() => { - if (!dragState.current) return; - finalizeDrag(lastPointerRef.current.clientX, lastPointerRef.current.clientY, true); - }, [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 < MIN_W) { if (dir.includes('w')) nx = ox + ow - MIN_W; nw = MIN_W; } - if (nh < MIN_H) { if (dir.includes('n')) ny = oy + oh - MIN_H; nh = MIN_H; } - return { x: nx, y: ny, w: nw, h: nh }; - }, []); - - 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) { - dispatch(setWorkflowsHubPosition({ x: r.x, y: r.y })); - dispatch(setWorkflowsHubSize({ width: r.w, height: r.h })); - } - resizeRef.current = null; - setLocalResize(null); - setIsResizing(false); - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }, [compute, dispatch]); - - 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; - const dy = (localResize?.y ?? localDragPos?.y ?? cardY) + mdDy; - const dw = localResize?.w ?? cardWidth; - const dh = localResize?.h ?? cardHeight; - - const border = isHighlighted ? `2px solid ${WC.accent}` : isSelected ? '2px solid #3b82f6' : `1px solid ${WC.border.subtle}`; - const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); + const commitPosition = useCallback((x: number, y: number) => { + dispatch(setWorkflowsHubPosition({ x, y })); + }, [dispatch]); + const commitSize = useCallback((width: number, height: number) => { + dispatch(setWorkflowsHubSize({ width, height })); + }, [dispatch]); return ( -
{ - const target = e.target as HTMLElement; - if (target.closest('[data-no-drag]')) return; - onBringToFront?.('workflows-hub', 'workflows-hub'); - }} - onClick={(e: React.MouseEvent) => { - if (justDraggedRef.current) return; - const target = e.target as HTMLElement; - if (target.closest('[data-no-drag]')) return; - onCardSelect?.('workflows-hub', 'workflows-hub', e.shiftKey); - }} - style={{ - position: 'absolute', - contain: 'layout style', - willChange: 'transform', - left: fsStyle ? fsStyle.left : dx, - top: fsStyle ? fsStyle.top : dy, - width: fsStyle ? fsStyle.width : dw, - height: fsStyle ? fsStyle.height : dh, - transform: fsStyle ? fsStyle.transform : undefined, - transformOrigin: fsStyle ? fsStyle.transformOrigin : undefined, - background: WC.page, - border: fsStyle ? 'none' : border, - borderRadius: WC.radius.lg, - boxShadow: (isDragging || isResizing) ? WC.shadow.lg : WC.shadow.md, - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', - zIndex: fsStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, - transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease', - }} + - { - const z = TILE_ZONES[zone]; - const vp = document.querySelector('[data-canvas-viewport]')?.getBoundingClientRect(); - if (!z || !vp) return; - const cam = getCanvasState(); - const GAP = 8; - dispatch(setWorkflowsHubPosition({ x: (z.x * vp.width + GAP - cam.panX) / cam.zoom, y: (z.y * vp.height + GAP - cam.panY) / cam.zoom })); - dispatch(setWorkflowsHubSize({ width: (z.w * vp.width - GAP * 2) / cam.zoom, height: (z.h * vp.height - GAP * 2) / cam.zoom })); - }} - /> - - {!isFullscreen && HANDLE_DEFS.map(({ dir, css }) => ( -
- ))} -
+ {({ header, onTileZone }) => ( + + )} +
); }; diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 679c062b..c00233d9 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -24,6 +24,9 @@ export const DEFAULT_WORKFLOW_CARD_H = 520; // Open at the same default footprint as a browser/view card so it lands at a comfortable size automatically. 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; +export const SETTINGS_CARD_ID = 'settings'; 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. @@ -31,7 +34,7 @@ export const WORKFLOW_CARD_GAP = 140; const GRID_ORIGIN = { x: 40, y: 100 }; const GRID_COLS_FALLBACK = 4; -export type CardType = 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub' | 'workflows-monitor'; +export type CardType = 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub' | 'workflows-monitor' | 'settings'; export interface CardPosition { session_id: string; @@ -167,6 +170,10 @@ export interface DashboardLayoutState { workflowsMonitorCard: WorkflowsHubPosition | null; /** A run attached to a workflow's chat as a removable context chip; its transcript rides along each send until removed. */ workflowsRunContext: WorkflowsRunContext | null; + /** Settings as an on-canvas window (singleton). Ephemeral, not persisted: a fresh boot opens on a clean canvas. */ + settingsCard: WorkflowsHubPosition | null; + /** Transient: signals Dashboard to pan/zoom to the Settings window on open. */ + pendingFocusSettingsCard: boolean; } export interface WorkflowsRunContext { @@ -206,6 +213,8 @@ const initialState: DashboardLayoutState = { workflowsMonitorRunId: null, workflowsMonitorCard: null, workflowsRunContext: null, + settingsCard: null, + pendingFocusSettingsCard: false, }; interface LayoutPayload { @@ -325,6 +334,9 @@ function collectOccupiedRects( if (state.workflowsHub) { rects.push({ x: state.workflowsHub.x, y: state.workflowsHub.y, w: state.workflowsHub.width, h: state.workflowsHub.height }); } + if (state.settingsCard) { + rects.push({ x: state.settingsCard.x, y: state.settingsCard.y, w: state.settingsCard.width, h: state.settingsCard.height }); + } return rects; } @@ -650,11 +662,13 @@ const dashboardLayoutSlice = createSlice({ for (const c of Object.values(state.workflowCards)) tally(c.zOrder); if (state.workflowsHub) tally(state.workflowsHub.zOrder); if (state.workflowsMonitorCard) tally(state.workflowsMonitorCard.zOrder); + if (state.settingsCard) tally(state.settingsCard.zOrder); if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0; else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0; else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0; else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0; else if (type === 'workflows-monitor') currentZ = state.workflowsMonitorCard?.zOrder ?? 0; + else if (type === 'settings') currentZ = state.settingsCard?.zOrder ?? 0; else currentZ = state.browserCards[id]?.zOrder ?? 0; if (currentZ >= maxZ) return; // Already on top: no-op. @@ -672,6 +686,8 @@ const dashboardLayoutSlice = createSlice({ if (state.workflowsHub) state.workflowsHub.zOrder = z; } else if (type === 'workflows-monitor') { if (state.workflowsMonitorCard) state.workflowsMonitorCard.zOrder = z; + } else if (type === 'settings') { + if (state.settingsCard) state.settingsCard.zOrder = z; } else { const card = state.browserCards[id]; if (card) card.zOrder = z; @@ -735,7 +751,8 @@ const dashboardLayoutSlice = createSlice({ const wCards = Object.values(state.workflowCards); const hub = state.workflowsHub; const mon = state.workflowsMonitorCard; - const total = agentCards.length + viewCards.length + bCards.length + wCards.length + (hub ? 1 : 0) + (mon ? 1 : 0); + const settings = state.settingsCard; + const total = agentCards.length + viewCards.length + bCards.length + wCards.length + (hub ? 1 : 0) + (mon ? 1 : 0) + (settings ? 1 : 0); if (total === 0) return; const allItems = [ @@ -745,6 +762,7 @@ const dashboardLayoutSlice = createSlice({ ...wCards.map((c) => ({ kind: 'workflow' as const, id: c.workflow_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })), ...(hub ? [{ kind: 'workflows-hub' as const, id: 'workflows-hub', x: hub.x, y: hub.y, storedW: hub.width, storedH: hub.height }] : []), ...(mon ? [{ kind: 'workflows-monitor' as const, id: 'workflows-monitor', x: mon.x, y: mon.y, storedW: mon.width, storedH: mon.height }] : []), + ...(settings ? [{ kind: 'settings' as const, id: SETTINGS_CARD_ID, x: settings.x, y: settings.y, storedW: settings.width, storedH: settings.height }] : []), ]; allItems.sort((a, b) => a.y - b.y || a.x - b.x); @@ -776,6 +794,8 @@ const dashboardLayoutSlice = createSlice({ if (state.workflowsHub) { state.workflowsHub.x = pos.x; state.workflowsHub.y = pos.y; } } else if (item.kind === 'workflows-monitor') { if (state.workflowsMonitorCard) { state.workflowsMonitorCard.x = pos.x; state.workflowsMonitorCard.y = pos.y; } + } else if (item.kind === 'settings') { + if (state.settingsCard) { state.settingsCard.x = pos.x; state.settingsCard.y = pos.y; } } else { const card = state.browserCards[item.id]; if (card) { card.x = pos.x; card.y = pos.y; } @@ -1208,6 +1228,52 @@ const dashboardLayoutSlice = createSlice({ state.workflowsHub.height = Math.max(420, action.payload.height); }, + // Settings is an on-canvas window like the Workflows app, not a modal: opening it creates or raises that card and pans to it. + openSettingsCard(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) { + if (state.settingsCard) { + state.settingsCard.zOrder = state.nextZOrder++; + state.pendingFocusSettingsCard = true; + return; + } + const rects = collectOccupiedRects(state, action.payload?.expandedSessionIds); + const pos = findOpenGridCell(rects, DEFAULT_SETTINGS_CARD_W, DEFAULT_SETTINGS_CARD_H); + state.settingsCard = { + x: pos.x, + y: pos.y, + width: DEFAULT_SETTINGS_CARD_W, + height: DEFAULT_SETTINGS_CARD_H, + zOrder: state.nextZOrder++, + }; + state.pendingFocusSettingsCard = true; + }, + + closeSettingsCard(state) { + state.settingsCard = null; + state.pendingFocusSettingsCard = false; + }, + + clearPendingFocusSettingsCard(state) { + 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; + state.settingsCard.y = action.payload.y; + }, + + setSettingsCardSize(state, action: PayloadAction<{ width: number; height: number }>) { + if (!state.settingsCard) return; + state.settingsCard.width = Math.max(640, action.payload.width); + state.settingsCard.height = Math.max(460, action.payload.height); + }, + pasteBrowserCard( state, action: PayloadAction<{ @@ -1449,6 +1515,11 @@ const dashboardLayoutSlice = createSlice({ state.workflowsHub.x += dx; state.workflowsHub.y += dy; } + } else if (item.type === 'settings') { + if (state.settingsCard) { + state.settingsCard.x += dx; + state.settingsCard.y += dy; + } } else { const card = state.browserCards[item.id]; if (card) { @@ -1594,6 +1665,8 @@ const dashboardLayoutSlice = createSlice({ state.browserCards = keptBrowsers; state.workflowCards = {}; state.workflowsHub = null; + state.settingsCard = null; + state.pendingFocusSettingsCard = false; state.closedCardPositions = {}; state.glowingBrowserCards = {}; state.glowingAgentCards = {}; @@ -1790,6 +1863,12 @@ export const { setWorkflowsHubPosition, setWorkflowsHubSize, clearPendingFocusWorkflowsHub, + openSettingsCard, + closeSettingsCard, + clearPendingFocusSettingsCard, + toggleSettingsCardFullscreen, + setSettingsCardPosition, + setSettingsCardSize, recordClosedCard, restoreClosedCard, popClosedCard,