diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 9d91456b..b9248375 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -612,6 +612,12 @@ class AgentManager: "mcp__openswarm-schedule__DeleteScheduledWorkflow", "mcp__openswarm-schedule__PauseAllWorkflows", } + _CLAUDE_INTERNAL_SCHEDULER_TOOLS = ("CronCreate", "CronList", "CronDelete") + + def _is_claude_schedule_skill(tool_name: str, tool_input) -> bool: + if tool_name != "Skill" or not isinstance(tool_input, dict): + return False + return str(tool_input.get("skill") or "").strip().lower() == "schedule" # OS-level scheduling across macOS/Linux/Windows. Agent must # not install cron entries, launchd plists, Windows scheduled @@ -732,6 +738,8 @@ class AgentManager: """ if tool_name == "Bash" and _looks_like_os_scheduling(tool_input): return "ask", None + if tool_name in _CLAUDE_INTERNAL_SCHEDULER_TOOLS: + return "deny", None # Committing or mutating a native recurring schedule is the in-app # twin of the crontab gate above: real, user-visible, hard-to-undo, # so it goes through ApprovalBar every time regardless of the @@ -907,6 +915,14 @@ class AgentManager: return decision async def can_use_tool(tool_name, input_data, context): + if _is_claude_schedule_skill(tool_name, input_data): + p_note_tool_used(tool_name, False) + return PermissionResultDeny( + message=( + "Use the openswarm-schedule MCP tools instead of " + "Claude's internal schedule skill." + ) + ) sensitive_pattern: str | None = None if tool_name != "AskUserQuestion": policy, sensitive_pattern = _maybe_override_policy( @@ -936,6 +952,18 @@ class AgentManager: if tool_name and tool_name != "AskUserQuestion": tool_input = input_data.get("tool_input", {}) + if _is_claude_schedule_skill(tool_name, tool_input): + p_note_tool_used(tool_name, False) + return { + "hookSpecificOutput": { + "hookEventName": hook_event, + "permissionDecision": "deny", + "permissionDecisionReason": ( + "Use the openswarm-schedule MCP tools instead " + "of Claude's internal schedule skill." + ), + } + } policy, sensitive_pattern = _maybe_override_policy( _get_effective_policy(tool_name), tool_name, tool_input ) @@ -1225,17 +1253,35 @@ class AgentManager: mcp_registry_ctx = self._build_mcp_registry_summary(session.allowed_tools, session.active_mcps) global_settings = load_settings() + # Nudge the agent to surface workflow conversion proactively when + # the user's ask looks recurring. The actual conversion is owned + # by the UI prompt; chat should not turn this into a cadence Q&A. schedule_ctx = ( "\n" - "After completing a substantive task, if the work looks " - "repeatable (the user said 'every', 'each', 'daily', " - "'weekly', 'morning', 'before standup', or you just did " - "the same sequence twice in this session), offer to " - "schedule it. Use AskUserQuestion to confirm cadence, " - "then ScheduleWorkflow to create it. Never reach for " - "crontab, launchctl, or schtasks; always use the native " - "scheduler so the user can see, pause, and edit it. " - "Don't ask after trivial one-off requests.\n" + "When a normal chat produces work that would naturally be " + "useful again later, prefer the openswarm-schedule MCP " + "workflow path over Claude's internal scheduler. If the user " + "explicitly says 'daily', 'weekly', 'every', 'each', " + "'morning', 'before standup', 'monitor', 'alert me', or " + "'keep this updated', treat that as a strong recurring-work " + "signal. After completing that work, call " + "SuggestConvertToWorkflow with a short reason and cadence " + "hint so the UI can open its workflow-conversion prompt. You " + "may say one short sentence before the tool call, such as " + "'This is a good fit for built-in Workflows.' Do not repeat " + "that advice after the tool call, do not send a follow-up " + "assistant message like 'Done', do not say you are 'nudging' " + "the UI, and do not mention the tool. If the user " + "then says 'ok', 'yes', or 'do it', do not ask what time and " + "do not schedule from chat; the UI prompt/Convert button owns " + "the conversion flow. Only call ScheduleWorkflow when the user " + "explicitly asks you to create a live schedule and has already " + "given an exact cadence and time (for example, 'every weekday " + "at 8am'). If cadence or time is missing, use " + "SuggestConvertToWorkflow instead of asking a follow-up. Never " + "use Skill('schedule'), CronCreate, CronList, CronDelete, " + "crontab, launchctl, or schtasks for user-facing recurring " + "work.\n" "" ) composed_prompt = self._compose_system_prompt( @@ -1551,6 +1597,15 @@ class AgentManager: else: effective_allowed.append(f"mcp__{name}__*") + if "openswarm-schedule" in mcp_servers: + effective_allowed = [ + t for t in effective_allowed + if t not in _CLAUDE_INTERNAL_SCHEDULER_TOOLS + ] + for _bt in _CLAUDE_INTERNAL_SCHEDULER_TOOLS: + if _bt not in effective_disallowed: + effective_disallowed.append(_bt) + # If the openswarm-web MCP was registered, the CLI's built-in # WebSearch/WebFetch are guaranteed to fail (no Anthropic # backend). Suppress them so the model picks our MCP variants diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index a4d20c8f..a9f2228f 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -4,9 +4,9 @@ Why this exists: the agent should be able to schedule recurring work on the user's behalf, but ALWAYS through the native scheduler (visible, auditable, cost-capped) rather than `crontab`. Each tool is a thin -wrapper around /api/workflows/*. The descriptions are written to nudge -the agent toward AskUserQuestion-first behavior (confirm cadence with -the user before calling ScheduleWorkflow). +wrapper around /api/workflows/*. The descriptions are written to prefer +UI-owned workflow conversion for vague recurring asks, and to reserve +ScheduleWorkflow for exact, user-specified live schedules. """ import json @@ -52,8 +52,13 @@ TOOLS = [ "name": "ScheduleWorkflow", "description": ( "Create a recurring scheduled workflow for the user. Use this " - "ONLY after confirming cadence with the user via AskUserQuestion " - "(do not assume — the user must pick or accept the time). " + "ONLY when the user explicitly asks you to create a live schedule " + "and has already supplied an exact cadence and time. Do not use " + "this after a generic convert-to-workflow suggestion, and do not " + "ask follow-up questions like 'what time should it run' from a " + "normal chat. If cadence or time is missing, call " + "SuggestConvertToWorkflow instead so the UI can open the workflow " + "conversion prompt. " "The workflow runs the listed steps on the schedule and is " "visible in the user's Workflows hub. Never use crontab, " "launchctl, or schtasks to schedule recurring work; always use " @@ -237,6 +242,44 @@ TOOLS = [ "required": ["workflow_id"], }, }, + { + "name": "SuggestConvertToWorkflow", + "description": ( + "Call this at the end of a response when the completed task is a clear " + "candidate for repeatable scheduled work (e.g. a daily report, weekly " + "digest, recurring data check, monitoring ping, inbox triage, or status " + "briefing). Prefer this native workflow nudge over Claude's internal " + "schedule skill or CronCreate/CronList/CronDelete tools. Use it whenever " + "the user explicitly mentions daily, weekly, every, each, mornings, " + "standup, monitoring, alerts, or keeping something updated, and when you " + "have just done a sequence that would naturally be useful again later. Do " + "NOT call it for one-off tasks, debugging sessions, creative work, or " + "anything where 'repeat it tomorrow' would be odd. It is OK to call this " + "more than once per session for distinct workflow candidates, but avoid " + "repeated nudges for the same task. This nudges the frontend to highlight " + "the 'Convert to Workflow' button and open the workflow-conversion " + "prompt. In user-facing text, say at most one short sentence, such " + "as: 'This is a good fit for built-in Workflows.' Do not repeat the " + "advice after this tool returns, do not say you are nudging the UI, " + "and do not ask what time it should run. After this tool returns, " + "do not send another assistant message like 'Done'; the UI prompt " + "will handle the next step." + ), + "inputSchema": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "A brief, user-friendly explanation of why this task is a good candidate for a recurring workflow (e.g. 'This is a daily report that stays the same'). Shown in the tool bubble.", + }, + "suggested_cadence": { + "type": "string", + "description": "Optional freeform cadence hint (e.g. 'every weekday morning at 9am' or 'weekly on Monday'). Leave blank if uncertain. The frontend will parse it to prefill the schedule.", + }, + }, + "required": ["reason"], + }, + }, ] @@ -298,6 +341,7 @@ def handle_schedule_workflow(args: dict) -> dict: "steps": [{"id": f"s{i+1}", "text": s} for i, s in enumerate(steps_in) if s], "schedule": schedule, "source_session_id": args.get("source_session_id") or PARENT_SESSION_ID or None, + "dashboard_id": DASHBOARD_ID or None, } r = _call("POST", "/create", body) if "_error" in r: @@ -507,6 +551,15 @@ def handle_read_test_transcript(args: dict) -> dict: return _ok(f"Test Agent transcript (status: {status}):\n\n{transcript}") +def handle_suggest_convert_to_workflow(args: dict) -> dict: + reason = (args.get("reason") or "").strip() + if not reason: + return _err("reason is required.") + cadence = (args.get("suggested_cadence") or "").strip() + result = json.dumps({"reason": reason, "cadence": cadence}) + return {"content": [{"type": "text", "text": result}]} + + HANDLERS = { "ScheduleWorkflow": handle_schedule_workflow, "ListScheduledWorkflows": handle_list, @@ -520,6 +573,7 @@ HANDLERS = { "DeleteWorkflowStep": handle_delete_step, "TestWorkflow": handle_test_workflow, "ReadTestTranscript": handle_read_test_transcript, + "SuggestConvertToWorkflow": handle_suggest_convert_to_workflow, } diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 3c2f35d5..517cff95 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -282,7 +282,16 @@ async def create_workflow(body: WorkflowCreate): pass storage.save_workflow(wf) scheduler.kick() - return _enriched(wf) + enriched = _enriched(wf) + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:updated", { + "workflow_id": wf.id, + "workflow": enriched, + }) + except Exception: + pass + return enriched async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]: diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index a449a853..06821c35 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -244,6 +244,7 @@ const DashboardCanvas: React.FC = ({ {/* Tether lines between branched cards */} ; viewCards: Record; browserCards: Record; @@ -64,6 +65,7 @@ interface DashboardCardLayerProps { } const DashboardCardLayer: React.FC = ({ + dashboardId, cards, viewCards, browserCards, @@ -265,6 +267,7 @@ const DashboardCardLayer: React.FC = ({ ))} {workflowsHub && ( = ({ service, size = 16 }) => { if (service === 'gmail') { return ( @@ -259,6 +348,10 @@ const AgentCard: React.FC = ({ const defaultModel = useAppSelector((s) => s.settings.data.default_model); const defaultMode = useAppSelector((s) => s.settings.data.default_mode); const [converting, setConverting] = useState(false); + const [suggestGlowCycle, setSuggestGlowCycle] = useState(0); + const [workflowToast, setWorkflowToast] = useState(''); + const [dismissedWorkflowPromptKey, setDismissedWorkflowPromptKey] = useState(''); + const workflowSuggestion = useMemo(() => findWorkflowSuggestion(session), [session]); // Hide the "Convert to workflow" button when this chat is already // entangled with a workflow (Image #44 note). Two cases: // (a) The session is one of a workflow's runner sessions, OR @@ -302,6 +395,76 @@ const AgentCard: React.FC = ({ !sourceWorkflow.schedule?.enabled && (session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2; + const hasUserPrompt = useMemo( + () => (session.messages || []).some((m) => m.role === 'user' && !m.hidden), + [session.messages], + ); + const isConvertBlockedByTurn = session.status !== 'completed' && session.status !== 'stopped'; + const showConvertToWorkflow = + !session.is_welcome_draft && + !isWorkflowRunnerSession && + hasUserPrompt && + (session.messages.length >= 2 || isConvertBlockedByTurn || !!workflowSuggestion); + const canConvertToWorkflow = showConvertToWorkflow && !isConvertBlockedByTurn && !converting; + const workflowSuggestionKey = workflowSuggestion + ? `${session.id}:${workflowSuggestion.reason}:${workflowSuggestion.cadence}` + : ''; + const showWorkflowSuggestionPrompt = Boolean( + workflowSuggestion && + canConvertToWorkflow && + workflowSuggestionKey && + dismissedWorkflowPromptKey !== workflowSuggestionKey, + ); + const convertChatToWorkflow = useCallback(() => { + if (converting) return; + const steps = extractStepsFromSession(session); + if (steps.length === 0) { + setWorkflowToast('Add a prompt before converting this chat to a workflow.'); + return; + } + setConverting(true); + const draftId = `draft-${session.id}-${Date.now()}`; + dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds })); + dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY })); + dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight })); + dispatch(removeCard(session.id)); + dispatch(openWorkflowCard({ + workflowId: draftId, + sourceSessionId: session.id, + view: 'preview', + draft: { + title: session.name || 'New workflow', + description: '', + steps, + source_session_id: session.id, + use_synced_prompt: true, + model: defaultModel || session.model, + mode: defaultMode || session.mode, + suggested_cadence: workflowSuggestion?.cadence || undefined, + } as Partial, + })); + }, [ + cardHeight, + cardWidth, + cardX, + cardY, + converting, + defaultMode, + defaultModel, + dispatch, + expandedSessionIds, + isConvertBlockedByTurn, + session, + workflowSuggestion?.cadence, + ]); + const handleConvertToWorkflow = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (isConvertBlockedByTurn) { + setWorkflowToast('Cannot convert to a workflow while the agent is responding.'); + return; + } + convertChatToWorkflow(); + }, [convertChatToWorkflow, isConvertBlockedByTurn]); // Curated picker label with a tidy fallback for unknowns. const friendlyModelLabel = useMemo(() => { const value = session.model; @@ -318,6 +481,43 @@ const AgentCard: React.FC = ({ }, [session.model, modelsByProvider]); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); + const suggestionPulseRef = useRef(''); + const readyPulseRef = useRef(''); + useEffect(() => { + if (!workflowSuggestion) return; + const key = `${workflowSuggestion.reason}|${workflowSuggestion.cadence}`; + if (canConvertToWorkflow) { + if (readyPulseRef.current === key) return; + readyPulseRef.current = key; + } else { + if (suggestionPulseRef.current === key) return; + suggestionPulseRef.current = key; + } + setSuggestGlowCycle((n) => n + 1); + dispatch(fadeGlowingAgentCard(session.id, 3200)); + }, [workflowSuggestion, canConvertToWorkflow, dispatch, session.id]); + + // When the agent schedules a workflow from this chat, pop its card open + // next to the chat. Baseline the count once on mount so historical + // schedules (e.g. after an app reload) don't re-open on their own. + const scheduleWorkflowCount = useMemo(() => countScheduleWorkflowCalls(session), [session]); + const baselineScheduleCountRef = useRef(null); + const autoOpenedWorkflowIdsRef = useRef>(new Set()); + useEffect(() => { + if (baselineScheduleCountRef.current === null) { + baselineScheduleCountRef.current = scheduleWorkflowCount; + return; + } + if (scheduleWorkflowCount <= baselineScheduleCountRef.current) return; + for (const wf of Object.values(workflowItems || {})) { + if (wf.source_session_id !== session.id) continue; + if (autoOpenedWorkflowIdsRef.current.has(wf.id)) continue; + autoOpenedWorkflowIdsRef.current.add(wf.id); + dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: session.id, expandedSessionIds })); + dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' })); + } + }, [scheduleWorkflowCount, workflowItems, session.id, dispatch, expandedSessionIds]); + const cardBoxRef = useRef(null); // Ref so ResizeObserver sees latest value without re-attaching when active flips. const isDashboardActiveRef = useRef(isDashboardActive); @@ -907,58 +1107,6 @@ const AgentCard: React.FC = ({ )} - {(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession && ( - - { - e.stopPropagation(); - if (converting) return; - const steps = extractStepsFromSession(session); - if (steps.length === 0) return; - setConverting(true); - const draftId = `draft-${session.id}-${Date.now()}`; - // The chat card becomes a temporary workflow draft in the - // same slot. Nothing is persisted until the user chooses - // Save Draft or Schedule Workflow from the draft card. - dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds })); - dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY })); - dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight })); - dispatch(removeCard(session.id)); - dispatch(openWorkflowCard({ - workflowId: draftId, - sourceSessionId: session.id, - view: 'preview', - draft: { - title: session.name || 'New workflow', - description: '', - steps, - source_session_id: session.id, - use_synced_prompt: true, - model: defaultModel || session.model, - mode: defaultMode || session.mode, - } as Partial, - })); - }} - onMouseDown={(e) => e.stopPropagation()} - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.5, - color: '#fff', - bgcolor: c.accent.primary, - border: `1px solid ${c.accent.primary}`, - fontSize: '0.78rem', fontWeight: 700, - px: 1.1, py: 0.5, - borderRadius: `${c.radius.md}px`, - cursor: converting ? 'wait' : 'pointer', - opacity: converting ? 0.7 : 1, - '&:hover': { filter: 'brightness(1.05)' }, - }} - > - - {converting ? 'Converting…' : 'Convert to workflow'} - - - )} = ({ - - - {friendlyModelLabel} - - - - - {session.cost_usd > 0 && hasApiKey && ( - - ${session.cost_usd.toFixed(4)} + + + + {friendlyModelLabel} + + + + {session.cost_usd > 0 && hasApiKey && ( + + ${session.cost_usd.toFixed(4)} + + )} + + {showConvertToWorkflow && ( + + e.stopPropagation()} + animate={suggestGlowCycle > 0 ? { + scale: [1, 1.06, 1, 1.045, 1], + filter: ['brightness(1)', 'brightness(1.18)', 'brightness(1)', 'brightness(1.12)', 'brightness(1)'], + boxShadow: [ + `0 0 0 0 ${c.accent.primary}00`, + `0 0 0 4px ${c.accent.primary}99, 0 0 20px 6px ${c.accent.primary}66`, + `0 0 0 8px ${c.accent.primary}00`, + `0 0 0 3px ${c.accent.primary}88, 0 0 16px 4px ${c.accent.primary}55`, + canConvertToWorkflow ? c.shadow.sm : 'none', + ], + } : undefined} + transition={{ duration: 2.4, ease: 'easeInOut' }} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.35, + color: canConvertToWorkflow ? '#fff' : c.text.tertiary, + bgcolor: canConvertToWorkflow ? c.accent.primary : c.bg.secondary, + border: `1px solid ${canConvertToWorkflow ? c.accent.primary : c.border.medium}`, + fontSize: '0.68rem', + lineHeight: 1, + fontWeight: 700, + px: 0.8, + py: 0.35, + minHeight: 22, + borderRadius: `${c.radius.sm}px`, + cursor: canConvertToWorkflow ? (converting ? 'wait' : 'pointer') : 'not-allowed', + opacity: converting ? 0.7 : 1, + whiteSpace: 'nowrap', + flexShrink: 0, + boxShadow: canConvertToWorkflow ? c.shadow.sm : 'none', + '&:hover': canConvertToWorkflow ? { filter: 'brightness(1.05)' } : { bgcolor: c.bg.secondary }, + }} + > + + {converting ? 'Converting...' : 'Convert to workflow'} + + )} @@ -1204,6 +1407,95 @@ const AgentCard: React.FC = ({ ) : null} )} + + { + e.stopPropagation(); + setDismissedWorkflowPromptKey(workflowSuggestionKey); + }} + sx={{ + position: 'absolute', + inset: 0, + zIndex: 30, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + p: 2, + bgcolor: 'rgba(0,0,0,0.28)', + backdropFilter: 'blur(1.5px)', + }} + > + e.stopPropagation()} + sx={{ + width: '100%', + maxWidth: 340, + bgcolor: c.bg.surface, + color: c.text.primary, + border: `1px solid ${c.border.medium}`, + borderRadius: `${c.radius.lg}px`, + boxShadow: c.shadow.lg, + p: 2.25, + }} + > + + Would you like to make this a workflow? + + + I can open a workflow draft from this chat. You can review the steps and choose the schedule there. + + {workflowSuggestion?.cadence && ( + + Suggested cadence: {workflowSuggestion.cadence} + + )} + + + + + + + + setWorkflowToast('')} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setWorkflowToast('')} + sx={{ fontSize: '0.78rem' }} + > + {workflowToast} + + ); diff --git a/frontend/src/app/pages/Workflows/SchedulingView.tsx b/frontend/src/app/pages/Workflows/SchedulingView.tsx index 8a5cccd3..ae84d56e 100644 --- a/frontend/src/app/pages/Workflows/SchedulingView.tsx +++ b/frontend/src/app/pages/Workflows/SchedulingView.tsx @@ -66,11 +66,13 @@ export default function SchedulingView({ workflow, steps }: Props) { (async () => { try { const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const cadenceHint = workflow.suggested_cadence ? ` I think it should run ${workflow.suggested_cadence}.` : ''; + const prompt = `Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"${cadenceHint}`; await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(scheduleSessionId)}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, body: JSON.stringify({ - prompt: 'Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"', + prompt, hidden: true, }), }); diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index 853f4043..c3a463ce 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -19,7 +19,7 @@ import { setWorkflowsHubPosition, setWorkflowsHubSize, } from '@/shared/state/dashboardLayoutSlice'; -import { openWorkflowCard, createWorkflow, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice'; +import { openWorkflowCard, createWorkflow, fetchPausedState, fetchWorkflows, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice'; import type { Workflow } from '@/shared/state/workflowsSlice'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; @@ -56,6 +56,7 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ ]; interface Props { + dashboardId?: string; cardX: number; cardY: number; cardWidth: number; @@ -125,6 +126,7 @@ function TimeSavedBadge() { } const WorkflowsHubCard: React.FC = ({ + dashboardId, cardX, cardY, cardWidth, cardHeight, cardZOrder = 0, zoom = 1, panX = 0, panY = 0, isSelected = false, isHighlighted = false, multiDragDelta = null, @@ -136,7 +138,10 @@ const WorkflowsHubCard: React.FC = ({ const paused = useAppSelector((s) => s.workflows.paused); const defaultModel = useAppSelector((s) => s.settings.data.default_model); - useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]); + useEffect(() => { + dispatch(fetchPausedState()); + dispatch(fetchWorkflows(dashboardId)); + }, [dispatch, dashboardId]); const togglePaused = useCallback(() => { dispatch(setPausedAll(!paused)); diff --git a/frontend/src/shared/mcpToolMeta.ts b/frontend/src/shared/mcpToolMeta.ts index 89b6f122..4e5379f3 100644 --- a/frontend/src/shared/mcpToolMeta.ts +++ b/frontend/src/shared/mcpToolMeta.ts @@ -121,6 +121,7 @@ export function getWorkflowToolLabel(action: string): string | null { if (lower === 'testworkflow') return 'Test workflow'; if (lower === 'readtesttranscript') return 'Read test results'; if (lower === 'listworkflows') return 'List workflows'; + if (lower === 'suggestconverttoworkflow') return 'Suggest workflow'; return null; } diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 2777b08b..b35bd1bc 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -101,6 +101,9 @@ export interface Workflow { * conversion). Compared against the current steps to decide whether to warn * before scheduling. See scheduleUtils.needsScheduleTestWarning. */ tested_signature?: string | null; + /** Suggested cadence from a SuggestConvertToWorkflow tool call (e.g. "every weekday at 9am"). + * Used to seed the scheduling agent's prompt. Transient draft field only. */ + suggested_cadence?: string; } export interface WorkflowRun {