mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] dashboard: dock Settings opens as a canvas window (shared card chrome), not a modal
This commit is contained in:
@@ -170,8 +170,9 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
// 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(() => {
|
||||
|
||||
@@ -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<DashboardCardLayerProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
@@ -233,38 +222,18 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
))}
|
||||
{workflowsHub && (
|
||||
<WorkflowsAppCard
|
||||
cardX={workflowsHub.x}
|
||||
cardY={workflowsHub.y}
|
||||
cardWidth={workflowsHub.width}
|
||||
cardHeight={workflowsHub.height}
|
||||
cardZOrder={workflowsHub.zOrder ?? 0}
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={selection.isSelected('workflows-hub')}
|
||||
isHighlighted={highlightedCardId === 'workflows-hub'}
|
||||
multiDragDelta={selection.isSelected('workflows-hub') ? multiDragDelta : null}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
)}
|
||||
{monitorCard && monitorWorkflow && (
|
||||
<RunMonitor
|
||||
workflow={monitorWorkflow}
|
||||
cardX={monitorCard.x}
|
||||
cardY={monitorCard.y}
|
||||
cardWidth={monitorCard.width}
|
||||
cardHeight={monitorCard.height}
|
||||
cardZOrder={monitorCard.zOrder ?? 0}
|
||||
getCanvasState={getCanvasState}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
)}
|
||||
<DashboardWindowCards
|
||||
workflowsHub={workflowsHub}
|
||||
selection={selection}
|
||||
highlightedCardId={highlightedCardId}
|
||||
multiDragDelta={multiDragDelta}
|
||||
getCanvasState={getCanvasState}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
{/* Marquee selection rectangle */}
|
||||
{selection.marquee && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import WorkflowsAppCard from '@/app/pages/Workflows/app/WorkflowsAppCard';
|
||||
import RunMonitor from '@/app/pages/Workflows/app/RunMonitor';
|
||||
import SettingsAppCard from '@/app/pages/Settings/SettingsAppCard';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
closeWorkflowMonitor,
|
||||
SETTINGS_CARD_ID,
|
||||
type WorkflowsHubPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
|
||||
|
||||
interface DashboardWindowCardsProps {
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
selection: ReturnType<typeof useDashboardSelection>;
|
||||
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<DashboardWindowCardsProps> = ({
|
||||
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 && (
|
||||
<WorkflowsAppCard
|
||||
cardX={workflowsHub.x}
|
||||
cardY={workflowsHub.y}
|
||||
cardWidth={workflowsHub.width}
|
||||
cardHeight={workflowsHub.height}
|
||||
cardZOrder={workflowsHub.zOrder ?? 0}
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={selection.isSelected('workflows-hub')}
|
||||
isHighlighted={highlightedCardId === 'workflows-hub'}
|
||||
multiDragDelta={selection.isSelected('workflows-hub') ? multiDragDelta : null}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
)}
|
||||
{settingsCard && (
|
||||
<SettingsAppCard
|
||||
cardX={settingsCard.x}
|
||||
cardY={settingsCard.y}
|
||||
cardWidth={settingsCard.width}
|
||||
cardHeight={settingsCard.height}
|
||||
cardZOrder={settingsCard.zOrder ?? 0}
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={selection.isSelected(SETTINGS_CARD_ID)}
|
||||
isHighlighted={highlightedCardId === SETTINGS_CARD_ID}
|
||||
multiDragDelta={selection.isSelected(SETTINGS_CARD_ID) ? multiDragDelta : null}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
)}
|
||||
{monitorCard && monitorWorkflow && (
|
||||
<RunMonitor
|
||||
workflow={monitorWorkflow}
|
||||
cardX={monitorCard.x}
|
||||
cardY={monitorCard.y}
|
||||
cardWidth={monitorCard.width}
|
||||
cardHeight={monitorCard.height}
|
||||
cardZOrder={monitorCard.zOrder ?? 0}
|
||||
getCanvasState={getCanvasState}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardWindowCards;
|
||||
@@ -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<ResizeDir, string> = {
|
||||
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
|
||||
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
|
||||
};
|
||||
const HANDLE_DEFS: { dir: ResizeDir; css: React.CSSProperties }[] = [
|
||||
{ dir: 'n', css: { top: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } },
|
||||
{ dir: 's', css: { bottom: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } },
|
||||
{ dir: 'w', css: { left: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } },
|
||||
{ dir: 'e', css: { right: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } },
|
||||
{ dir: 'nw', css: { top: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
{ dir: 'ne', css: { top: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
{ dir: 'sw', css: { bottom: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
{ dir: 'se', css: { bottom: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
];
|
||||
|
||||
/** Drag handlers the window hands down to whatever renders its title bar. */
|
||||
export interface CanvasWindowHeader {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
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<CanvasWindowCardProps> = ({
|
||||
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 (
|
||||
<div
|
||||
data-select-type={selectType}
|
||||
data-select-id={cardId}
|
||||
data-select-meta={JSON.stringify({ name: selectName })}
|
||||
onPointerDownCapture={(e: React.PointerEvent) => {
|
||||
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 }) => (
|
||||
<div
|
||||
key={dir}
|
||||
data-no-drag
|
||||
onPointerDown={onResizeDown(dir)}
|
||||
onPointerMove={onResizeMove}
|
||||
onPointerUp={onResizeUp}
|
||||
style={{ position: 'absolute', cursor: CURSOR_MAP[dir], zIndex: 25, ...css }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasWindowCard;
|
||||
@@ -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: <LanguageIcon sx={{ fontSize: 17, color: '#e8e8ee' }} />, act: onAddBrowser },
|
||||
{ label: 'Workflows', icon: <EventRepeatIcon sx={{ fontSize: 16, color: '#e8e8ee' }} />, act: () => dispatch(openWorkflowsApp()) },
|
||||
{ label: 'Settings', icon: <SettingsIcon sx={{ fontSize: 18, color: '#e8e8ee' }} />, act: () => dispatch(openSettingsModal(undefined)), divider: true },
|
||||
{ label: 'Settings', icon: <SettingsIcon sx={{ fontSize: 18, color: '#e8e8ee' }} />, act: () => dispatch(openSettingsCard()), divider: true },
|
||||
{ label: 'Applications', icon: <AppsRoundedIcon sx={{ fontSize: 18, color: '#e8e8ee' }} />, act: onApplications, bg: 'linear-gradient(135deg, #3d3d46, #232329)' },
|
||||
] as { label: string; icon: React.ReactNode; act: () => void; divider?: boolean; bg?: string }[]).map((a) => (
|
||||
<React.Fragment key={a.label}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, CardType>, 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, Array<{ value: string; label: string }>> = {};
|
||||
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<SettingsTab>(
|
||||
isValidTab(lastOpenTab) ? lastOpenTab : 'general',
|
||||
);
|
||||
const [form, setForm] = useState<AppSettings>({ ...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<AppSettings>(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<ReturnType<typeof setTimeout> | 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<AppSettings> } | null => {
|
||||
const base = baselineRef.current as unknown as Record<string, unknown>;
|
||||
const f = form as unknown as Record<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
for (const k of touched) patch[k] = f[k];
|
||||
return { touched, patch: patch as Partial<AppSettings> };
|
||||
}, [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<string, unknown>;
|
||||
for (const k of payload.touched) nextBase[k] = (form as unknown as Record<string, unknown>)[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 (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleRequestClose}
|
||||
onClose={handleClose}
|
||||
maxWidth={false}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
@@ -262,109 +38,8 @@ const Settings: React.FC = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SettingsRail activeTab={activeTab} onTabChange={(v) => setActiveTab(v as SettingsTab)} />
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 1.75, pb: 0.75, flexShrink: 0 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
|
||||
{railLabelFor(activeTab)}
|
||||
</Typography>
|
||||
<IconButton onClick={handleRequestClose} size="small" data-onboarding="settings-close-button" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<DialogContent sx={{
|
||||
px: 3,
|
||||
py: 0,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3, '&:hover': { background: c.border.strong } },
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}>
|
||||
{activeTab === 'account' ? (
|
||||
<Box sx={{ pt: 1.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<AccountCard />
|
||||
</Box>
|
||||
) : activeTab === 'general' ? (
|
||||
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<GeneralAgentDefaults
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
styles={styles}
|
||||
setBrowseOpen={setBrowseOpen}
|
||||
modelOptions={modelOptions}
|
||||
modesList={modesList}
|
||||
providerColors={PROVIDER_COLORS}
|
||||
openswarmGradient={OPENSWARM_GRADIENT}
|
||||
/>
|
||||
</Box>
|
||||
) : activeTab === 'appearance' ? (
|
||||
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<GeneralInterface form={form} setForm={setForm} styles={styles} />
|
||||
</Box>
|
||||
) : activeTab === 'privacy' ? (
|
||||
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<DataPrivacySection styles={styles} />
|
||||
</Box>
|
||||
) : activeTab === 'advanced' ? (
|
||||
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<GeneralAdvanced form={form} setForm={setForm} styles={styles} />
|
||||
</Box>
|
||||
) : activeTab === 'models' ? (
|
||||
<ModelsTab
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
showApiKey={showApiKey}
|
||||
setShowApiKey={setShowApiKey}
|
||||
styles={styles}
|
||||
/>
|
||||
) : activeTab === 'usage' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<UsageStats />
|
||||
</Box>
|
||||
) : activeTab === 'skills' ? (
|
||||
<Box sx={{ height: '100%', mx: -3, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<React.Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}><CircularProgress size={24} /></Box>}>
|
||||
<SkillsTab />
|
||||
</React.Suspense>
|
||||
</Box>
|
||||
) : activeTab === 'tools' ? (
|
||||
<Box sx={{ height: '100%', mx: -3, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<React.Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}><CircularProgress size={24} /></Box>}>
|
||||
<ToolsTab />
|
||||
</React.Suspense>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<CommandsContent />
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Box>
|
||||
|
||||
<DirectoryBrowser
|
||||
open={browseOpen}
|
||||
onClose={() => setBrowseOpen(false)}
|
||||
onSelect={(item) => setForm({ ...form, default_folder: item.path })}
|
||||
initialPath={form.default_folder ?? ''}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={saveError}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSaveError(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={() => 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.
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<SettingsBody active={open} requestedTab={initialTab} onRequestClose={handleClose} />
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Props> = ({
|
||||
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 (
|
||||
<CanvasWindowCard
|
||||
cardId={SETTINGS_CARD_ID}
|
||||
cardType="settings"
|
||||
selectType="settings-card"
|
||||
selectName="Settings"
|
||||
cardX={cardX}
|
||||
cardY={cardY}
|
||||
cardWidth={cardWidth}
|
||||
cardHeight={cardHeight}
|
||||
cardZOrder={cardZOrder}
|
||||
fullscreen={isFullscreen}
|
||||
minWidth={MIN_W}
|
||||
minHeight={MIN_H}
|
||||
background={c.bg.page}
|
||||
highlightColor={c.accent.primary}
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={isSelected}
|
||||
isHighlighted={isHighlighted}
|
||||
multiDragDelta={multiDragDelta}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onBringToFront={onBringToFront}
|
||||
onCommitPosition={commitPosition}
|
||||
onCommitSize={commitSize}
|
||||
>
|
||||
{({ header, onTileZone }) => (
|
||||
<>
|
||||
<div
|
||||
onPointerDown={header.onPointerDown}
|
||||
onPointerMove={header.onPointerMove}
|
||||
onPointerUp={header.onPointerUp}
|
||||
onPointerCancel={header.onPointerCancel}
|
||||
onLostPointerCapture={header.onLostPointerCapture}
|
||||
style={{
|
||||
height: 42, flex: 'none', display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '0 16px', borderBottom: `1px solid ${c.border.subtle}`, background: c.bg.surface,
|
||||
cursor: header.dragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="osw-card"
|
||||
data-no-drag
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<WindowControls
|
||||
onClose={close}
|
||||
onMinimize={close}
|
||||
onTile={(zone) => {
|
||||
if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleSettingsCardFullscreen()); return; }
|
||||
if (isFullscreen) dispatch(toggleSettingsCardFullscreen());
|
||||
onTileZone(zone);
|
||||
}}
|
||||
tiled={isFullscreen}
|
||||
noTileMenu={isFullscreen}
|
||||
/>
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SettingsIcon sx={{ fontSize: 18, color: c.text.secondary, display: 'block' }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: c.text.primary, lineHeight: 1 }}>Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsBody active requestedTab={null} onRequestClose={close} />
|
||||
</>
|
||||
)}
|
||||
</CanvasWindowCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAppCard;
|
||||
@@ -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<SettingsBodyProps> = ({ 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<SettingsTab>(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 (
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'row', overflow: 'hidden', bgcolor: c.bg.page }}>
|
||||
<SettingsRail activeTab={activeTab} onTabChange={(v) => setActiveTab(v as SettingsTab)} />
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 1.75, pb: 0.75, flexShrink: 0 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
|
||||
{railLabelFor(activeTab)}
|
||||
</Typography>
|
||||
<IconButton onClick={handleRequestClose} size="small" data-onboarding="settings-close-button" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
px: 3,
|
||||
py: 0,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3, '&:hover': { background: c.border.strong } },
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}>
|
||||
{activeTab === 'account' ? (
|
||||
<Box sx={{ pt: 1.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<AccountCard />
|
||||
</Box>
|
||||
) : activeTab === 'general' ? (
|
||||
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<GeneralAgentDefaults
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
styles={styles}
|
||||
setBrowseOpen={setBrowseOpen}
|
||||
modelOptions={modelOptions}
|
||||
modesList={modesList}
|
||||
providerColors={PROVIDER_COLORS}
|
||||
openswarmGradient={OPENSWARM_GRADIENT}
|
||||
/>
|
||||
</Box>
|
||||
) : activeTab === 'appearance' ? (
|
||||
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<GeneralInterface form={form} setForm={setForm} styles={styles} />
|
||||
</Box>
|
||||
) : activeTab === 'privacy' ? (
|
||||
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<DataPrivacySection styles={styles} />
|
||||
</Box>
|
||||
) : activeTab === 'advanced' ? (
|
||||
<Box sx={{ pt: 0.5, pb: 2, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<GeneralAdvanced form={form} setForm={setForm} styles={styles} />
|
||||
</Box>
|
||||
) : activeTab === 'models' ? (
|
||||
<ModelsTab
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
showApiKey={showApiKey}
|
||||
setShowApiKey={setShowApiKey}
|
||||
styles={styles}
|
||||
/>
|
||||
) : activeTab === 'usage' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<UsageStats />
|
||||
</Box>
|
||||
) : activeTab === 'skills' ? (
|
||||
<Box sx={{ height: '100%', mx: -3, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<React.Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}><CircularProgress size={24} /></Box>}>
|
||||
<SkillsTab />
|
||||
</React.Suspense>
|
||||
</Box>
|
||||
) : activeTab === 'tools' ? (
|
||||
<Box sx={{ height: '100%', mx: -3, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<React.Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}><CircularProgress size={24} /></Box>}>
|
||||
<ToolsTab />
|
||||
</React.Suspense>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<CommandsContent />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<DirectoryBrowser
|
||||
open={browseOpen}
|
||||
onClose={() => setBrowseOpen(false)}
|
||||
onSelect={(item) => setForm({ ...form, default_folder: item.path })}
|
||||
initialPath={form.default_folder ?? ''}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={saveError}
|
||||
autoHideDuration={4000}
|
||||
onClose={dismissSaveError}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={dismissSaveError} 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.
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsBody;
|
||||
@@ -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<string, string> = {
|
||||
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<string, Array<{ value: string; label: string }>>;
|
||||
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<string, Array<{ value: string; label: string }>> = {};
|
||||
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]);
|
||||
}
|
||||
@@ -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<React.SetStateAction<AppSettings>>;
|
||||
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<AppSettings>({ ...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<AppSettings>(settings);
|
||||
useEffect(() => {
|
||||
if (active && loaded) {
|
||||
setForm({ ...settings });
|
||||
baselineRef.current = settings;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, loaded]);
|
||||
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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<AppSettings> } | null => {
|
||||
const base = baselineRef.current as unknown as Record<string, unknown>;
|
||||
const f = form as unknown as Record<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
for (const k of touched) patch[k] = f[k];
|
||||
return { touched, patch: patch as Partial<AppSettings> };
|
||||
}, [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<string, unknown>;
|
||||
for (const k of payload.touched) nextBase[k] = (form as unknown as Record<string, unknown>)[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 };
|
||||
}
|
||||
@@ -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<ResizeDir, string> = {
|
||||
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
|
||||
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
|
||||
};
|
||||
const HANDLE_DEFS: { dir: ResizeDir; css: React.CSSProperties }[] = [
|
||||
{ dir: 'n', css: { top: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } },
|
||||
{ dir: 's', css: { bottom: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } },
|
||||
{ dir: 'w', css: { left: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } },
|
||||
{ dir: 'e', css: { right: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } },
|
||||
{ dir: 'nw', css: { top: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
{ dir: 'ne', css: { top: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
{ dir: 'sw', css: { bottom: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
{ dir: 'se', css: { bottom: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
cardX: number;
|
||||
@@ -56,223 +35,49 @@ const WorkflowsAppCard: React.FC<Props> = ({
|
||||
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 (
|
||||
<div
|
||||
data-select-type="workflows-hub-card"
|
||||
data-select-id="workflows-hub"
|
||||
data-select-meta={JSON.stringify({ name: 'Workflows' })}
|
||||
onPointerDownCapture={(e: React.PointerEvent) => {
|
||||
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',
|
||||
}}
|
||||
<CanvasWindowCard
|
||||
cardId="workflows-hub"
|
||||
cardType="workflows-hub"
|
||||
selectType="workflows-hub-card"
|
||||
selectName="Workflows"
|
||||
cardX={cardX}
|
||||
cardY={cardY}
|
||||
cardWidth={cardWidth}
|
||||
cardHeight={cardHeight}
|
||||
cardZOrder={cardZOrder}
|
||||
fullscreen={isFullscreen}
|
||||
minWidth={MIN_W}
|
||||
minHeight={MIN_H}
|
||||
background={WC.page}
|
||||
highlightColor={WC.accent}
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={isSelected}
|
||||
isHighlighted={isHighlighted}
|
||||
multiDragDelta={multiDragDelta}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onBringToFront={onBringToFront}
|
||||
onCommitPosition={commitPosition}
|
||||
onCommitSize={commitSize}
|
||||
>
|
||||
<WorkflowsAppContent
|
||||
header={{
|
||||
onPointerDown: onHeaderPointerDown,
|
||||
onPointerMove: onHeaderPointerMove,
|
||||
onPointerUp: onHeaderPointerUp,
|
||||
onPointerCancel: abortDrag,
|
||||
onLostPointerCapture: abortDrag,
|
||||
dragging: isDragging,
|
||||
}}
|
||||
onTileZone={(zone) => {
|
||||
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 }) => (
|
||||
<div
|
||||
key={dir}
|
||||
data-no-drag
|
||||
onPointerDown={onResizeDown(dir)}
|
||||
onPointerMove={onResizeMove}
|
||||
onPointerUp={onResizeUp}
|
||||
style={{ position: 'absolute', cursor: CURSOR_MAP[dir], zIndex: 25, ...css }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{({ header, onTileZone }) => (
|
||||
<WorkflowsAppContent header={header} onTileZone={onTileZone} />
|
||||
)}
|
||||
</CanvasWindowCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user