diff --git a/backend/apps/swarm/entities/workflows.py b/backend/apps/swarm/entities/workflows.py index 42a8e92e..0b2ddef3 100644 --- a/backend/apps/swarm/entities/workflows.py +++ b/backend/apps/swarm/entities/workflows.py @@ -1,8 +1,5 @@ """WorkflowExportable: shares a scheduled-task/workflow recipe (steps, schedule -shape, actions, model). The workflow store lives on the eric/workflow branch and -is NOT on eric/dev yet, so every store touch is lazy: on a build without it, -export finds nothing and import fails with a clear message, and the module still -imports cleanly. It lights up the moment the workflow forward-port lands. +shape, actions, model). Safety: an imported workflow must never silently start running on someone else's machine, so the schedule is forced off on import (the importer re-arms it). The @@ -12,6 +9,8 @@ from __future__ import annotations from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable from backend.apps.swarm.models import EntityType, Requirement, RequirementKind +from backend.apps.workflows import storage +from backend.apps.workflows.models import Workflow P_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"} @@ -52,10 +51,7 @@ class WorkflowExportable: @classmethod def load(cls, local_id: str) -> "WorkflowExportable | None": - store = p_store() - if store is None: - return None - wf = store.get_workflow(local_id) + wf = storage.get_workflow(local_id) if wf is None: return None data = wf.model_dump(mode="json") @@ -92,38 +88,15 @@ class WorkflowExportable: @classmethod def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: - store = p_store() - model = p_model() - if store is None or model is None: - from backend.apps.swarm.ziputil import BundleError - raise BundleError("this build doesn't support workflows yet; please update OpenSwarm") clean = sanitize_workflow(payload) clean.pop("id", None) # fresh id via the model's default_factory - wf = model(**clean) - store.save_workflow(wf) + wf = Workflow(**clean) + storage.save_workflow(wf) return wf.id @classmethod def rollback(cls, local_id: str) -> None: - store = p_store() - if store is not None: - try: - store.delete_workflow(local_id) - except Exception: - pass - - -def p_store(): - try: - from backend.apps.workflows import storage - return storage - except Exception: - return None - - -def p_model(): - try: - from backend.apps.workflows.models import Workflow - return Workflow - except Exception: - return None + try: + storage.delete_workflow(local_id) + except Exception: + pass diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx index a9a8ead0..8089d2b5 100644 --- a/frontend/src/app/components/share/ImportEntryPoint.tsx +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -9,6 +9,8 @@ import FileDownloadIcon from '@mui/icons-material/FileDownload'; import { useNavigate } from 'react-router-dom'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { fetchWorkflows } from '@/shared/state/workflowsSlice'; import ImportDigest, { DigestHandle } from './ImportDigest'; import ImportModal from './ImportModal'; @@ -43,6 +45,8 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); const ImportEntryPoint: React.FC = () => { const c = useClaudeTokens(); const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined; const inputRef = useRef(null); const digestRef = useRef(null); const depth = useRef(0); @@ -56,10 +60,12 @@ const ImportEntryPoint: React.FC = () => { (rootType: string, rootId: string, name: string) => { const msg = rootType === 'app' ? `Added ${name} to your Apps` : `Added ${name}`; setToast({ msg, sev: 'success' }); + // A workflow has no route of its own, so nothing would pull it in: an open Workflows hub only fetches on mount and would keep showing a stale list. Import drops dashboard_id, and /list keeps unassigned workflows for every dashboard, so this surfaces it wherever the user is. + if (rootType === 'workflow') dispatch(fetchWorkflows(dashboardId)); const to = DEST[rootType]?.(rootId); if (to) navigate(to); }, - [navigate], + [navigate, dispatch, dashboardId], ); const commitAndFinish = useCallback( diff --git a/frontend/src/app/pages/Workflows/app/LeftRail.tsx b/frontend/src/app/pages/Workflows/app/LeftRail.tsx index ad74e9d3..11625702 100644 --- a/frontend/src/app/pages/Workflows/app/LeftRail.tsx +++ b/frontend/src/app/pages/Workflows/app/LeftRail.tsx @@ -3,6 +3,7 @@ import type { CSSProperties } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { deleteWorkflow } from '@/shared/state/workflowsSlice'; import { isScheduleActive, describeSchedule } from '@/app/pages/Workflows/scheduleUtils'; +import ShareButton from '@/app/components/share/ShareButton'; import { colorForWorkflow, useWC } from './uiKit'; import WorkflowTitle from './WorkflowTitle'; import type { AppNav } from './types'; @@ -18,6 +19,7 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => { const items = useAppSelector((s) => s.workflows.items); const trashCount = useAppSelector((s) => s.workflows.deleted.length); const [query, setQuery] = useState(''); + const [hovered, setHovered] = useState(null); const workflows = useMemo(() => Object.values(items) .filter((w) => !w.unsaved) @@ -93,6 +95,8 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
nav.selectWorkflow(w.id)} + onMouseEnter={() => setHovered(w.id)} + onMouseLeave={() => setHovered((h) => (h === w.id ? null : h))} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '5px 9px', borderRadius: 8, cursor: 'pointer', background: isSel ? WC.selBg : 'transparent' }} >
@@ -104,6 +108,22 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => { {active ? describeSchedule(w.schedule) : 'Paused'}
+ {/* Faded rather than unmounted on hover-out: ShareButton owns the modal's open state, so unmounting it would close the modal the moment the pointer left the row for the dialog. Also keeps the row from reflowing on hover. */} + e.stopPropagation()} + style={{ + display: 'flex', + flex: 'none', + opacity: hovered === w.id ? 1 : 0, + pointerEvents: hovered === w.id ? 'auto' : 'none', + transition: 'opacity 0.12s', + }} + > + +
{ e.stopPropagation(); onDelete(w.id); }} style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }} diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index 60c150ad..88aab2f6 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -1,11 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAppDispatch } from '@/shared/hooks'; -import { closeWorkflowsApp, setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice'; -import EventRepeatIcon from '@mui/icons-material/EventRepeat'; -import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { useWC, FONT_SERIF } from './uiKit'; +import { setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice'; +import { useWC } from './uiKit'; import WorkflowsAppContent from './WorkflowsAppContent'; type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; @@ -58,7 +54,6 @@ const WorkflowsAppCard: React.FC = ({ onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront, }) => { const WC = useWC(); - const c = useClaudeTokens(); const dispatch = useAppDispatch(); const panRef = useRef({ panX, panY }); @@ -216,31 +211,14 @@ const WorkflowsAppCard: React.FC = ({ transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease', }} > - {/* TITLE BAR (drag handle) */} -
-
- - Workflows -
-
- { e.stopPropagation(); dispatch(closeWorkflowsApp()); }} - onPointerDown={(e) => e.stopPropagation()} - sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }} - > - - -
- - + {HANDLE_DEFS.map(({ dir, css }) => (
{ +// The three-pane Workflows body plus its title bar. The card wraps this with drag/resize geometry and passes the drag handlers in; the title bar lives here because Share needs to know which workflow is open. +const WorkflowsAppContent: React.FC<{ header: CardHeader }> = ({ header }) => { const WC = useWC(); + const c = useClaudeTokens(); const dispatch = useAppDispatch(); const target = useAppSelector((s) => s.dashboardLayout.workflowsAppTarget); const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined; @@ -26,6 +32,10 @@ const WorkflowsAppContent: React.FC = () => { const [calView, setCalView] = useState('month'); const [refDate, setRefDate] = useState(() => new Date()); + // goHome leaves selectedId set, so gate on the mode too or Share lingers in the title bar after leaving the workflow. + const shared = useAppSelector((s) => (selectedId ? s.workflows.items[selectedId] : undefined)); + const selected = mode === 'detail' ? shared : undefined; + useEffect(() => { dispatch(fetchWorkflows(dashboardId)); dispatch(fetchAllRuns(200)); @@ -56,13 +66,53 @@ const WorkflowsAppContent: React.FC = () => { }), [mode, selectedId, calView, refDate, dashboardId, dispatch]); return ( -
- - {mode === 'home' && } - {mode === 'calendar' && } - {mode === 'detail' && selectedId && } - {mode === 'new' && } - {mode === 'trash' && } +
+ {/* TITLE BAR (drag handle) */} +
+
+ + Workflows +
+
+ {selected && ( + // The share dialog portals to the body but its events still bubble the React tree, so stop them here or dragging the card follows a click inside the modal. + e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + style={{ display: 'flex' }} + > + + + )} + { e.stopPropagation(); dispatch(closeWorkflowsApp()); }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }} + > + + +
+ +
+ + {mode === 'home' && } + {mode === 'calendar' && } + {mode === 'detail' && selectedId && } + {mode === 'new' && } + {mode === 'trash' && } +
); }; diff --git a/frontend/src/app/pages/Workflows/app/types.ts b/frontend/src/app/pages/Workflows/app/types.ts index ca086556..6340614b 100644 --- a/frontend/src/app/pages/Workflows/app/types.ts +++ b/frontend/src/app/pages/Workflows/app/types.ts @@ -1,6 +1,16 @@ +import type { PointerEvent } from 'react'; + export type AppMode = 'home' | 'calendar' | 'detail' | 'new' | 'trash'; export type CalView = 'week' | 'month'; +// The card owns drag geometry but the title bar renders inside the content (it needs nav state to know which workflow to share), so the card hands its drag handlers down. +export interface CardHeader { + onPointerDown: (e: PointerEvent) => void; + onPointerMove: (e: PointerEvent) => void; + onPointerUp: (e: PointerEvent) => void; + dragging: boolean; +} + // Navigation + ephemeral UI state for the Workflows app window. Data lives in Redux; this is only "where am I looking right now". export interface AppNav { mode: AppMode;