[aidan] feat/scheduled-tasks: agent workflow scheduling and in-chat convert (#98)

* [aidan] feat/workflow-suggest: nudge user to convert repeatable chat to workflow

Add SuggestConvertToWorkflow MCP tool that agents call at the end of a task
when they've completed something worth repeating (daily report, weekly check,
recurring data pull). Frontend detects the tool call and glows the "Convert to
workflow" button 3 times to draw the eye. When user clicks it, the suggested
cadence (e.g. "every weekday at 9am") is stored in the draft and seeded into
the scheduling agent's first prompt, so the agent can act on the suggestion
rather than asking the user again.

Tool is never auto-called — agents decide when a task is genuinely repeatable
(not debugging, creative work, one-off lookup). Tool description emphasizes
sparse, high-confidence use only (once per session max).

Files changed:
- backend/apps/agents/schedule_mcp_server.py: add SuggestConvertToWorkflow tool
- frontend/src/shared/mcpToolMeta.ts: add label for new tool
- frontend/src/app/pages/Dashboard/cards/AgentCard.tsx: detect suggestion in
  session messages, show+glow "Convert to workflow" button, pass cadence to draft
- frontend/src/shared/state/workflowsSlice.ts: add suggested_cadence field to
  Workflow interface
- frontend/src/app/pages/Workflows/SchedulingView.tsx: seed scheduling agent
  prompt with suggested cadence hint

* [aidan] feat/agent-scheduling: route recurring asks through native workflows, deny claude cron skill

* [aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card
This commit is contained in:
Aidan
2026-06-19 03:47:35 -07:00
committed by GitHub
parent 111b527979
commit 6404d98b3a
10 changed files with 510 additions and 85 deletions
+64 -9
View File
@@ -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 = (
"<scheduling_guidance>\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"
"</scheduling_guidance>"
)
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
+59 -5
View File
@@ -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,
}
+10 -1
View File
@@ -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]]:
@@ -244,6 +244,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
{/* Tether lines between branched cards */}
<TetherLayer tethers={tethers} c={c} />
<DashboardCardLayer
dashboardId={dashboardId}
cards={cards}
viewCards={viewCards}
browserCards={browserCards}
@@ -28,6 +28,7 @@ type GlowingAgentCard = { sourceId: string; fading: boolean; sourceYRatio?: numb
type Direction = 'left' | 'right' | 'up' | 'down';
interface DashboardCardLayerProps {
dashboardId: string;
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
@@ -64,6 +65,7 @@ interface DashboardCardLayerProps {
}
const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
dashboardId,
cards,
viewCards,
browserCards,
@@ -265,6 +267,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
))}
{workflowsHub && (
<WorkflowsHubCard
dashboardId={dashboardId}
cardX={workflowsHub.x}
cardY={workflowsHub.y}
cardWidth={workflowsHub.width}
@@ -6,6 +6,8 @@ import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import Fade from '@mui/material/Fade';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import CheckIcon from '@mui/icons-material/Check';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
@@ -68,6 +70,93 @@ function extractStepsFromSession(session: { messages: Array<{ role: string; cont
return out;
}
function isWorkflowSuggestionTool(toolName: unknown, mcpServer?: unknown): boolean {
const normalizedTool = String(toolName || '').toLowerCase();
const normalizedServer = String(mcpServer || '').toLowerCase();
if (!normalizedTool) return false;
if (normalizedTool === 'suggestconverttoworkflow') return true;
if (normalizedTool.endsWith('__suggestconverttoworkflow')) return true;
return normalizedTool.includes('suggestconverttoworkflow') && (
normalizedTool.includes('openswarm-schedule') ||
normalizedServer.includes('openswarm-schedule')
);
}
function isScheduleWorkflowTool(toolName: unknown): boolean {
const normalizedTool = String(toolName || '').toLowerCase();
if (!normalizedTool) return false;
return normalizedTool === 'scheduleworkflow' || normalizedTool.endsWith('__scheduleworkflow');
}
function parseWorkflowSuggestion(text: unknown): { reason: string; cadence: string } | null {
if (typeof text !== 'string' || !text.trim()) return null;
try {
const parsed = JSON.parse(text);
if (!parsed?.reason || typeof parsed.reason !== 'string') return null;
return {
reason: parsed.reason,
cadence: typeof parsed.cadence === 'string'
? parsed.cadence
: (typeof parsed.suggested_cadence === 'string' ? parsed.suggested_cadence : ''),
};
} catch {
return null;
}
}
function parseWorkflowSuggestionFromContent(content: any): { reason: string; cadence: string } | null {
return parseWorkflowSuggestion(
content?.text ??
content?.content?.[0]?.text ??
content?.result ??
content?.output,
);
}
/** Detect if the session has a completed SuggestConvertToWorkflow tool call. */
function findWorkflowSuggestion(session: AgentSession): { reason: string; cadence: string } | null {
let found: { reason: string; cadence: string } | null = null;
for (const msg of session.messages || []) {
const msgAny = msg as any;
const directContent = msgAny.content;
if (msgAny.role === 'tool_result') {
const toolName = directContent?.tool_name ?? directContent?.tool ?? directContent?.name ?? msgAny.tool_name;
if (isWorkflowSuggestionTool(toolName, directContent?.mcpServer ?? msgAny.mcpServer)) {
found = parseWorkflowSuggestionFromContent(directContent) || found;
}
}
const blocks = Array.isArray(directContent) ? directContent : [];
for (const block of blocks) {
if (block?.type !== 'tool_result') continue;
const toolName = block?.tool_name ?? block?.tool ?? block?.name;
if (isWorkflowSuggestionTool(toolName, block?.mcpServer)) {
found = parseWorkflowSuggestionFromContent(block) || found;
}
}
}
return found;
}
/** Count completed ScheduleWorkflow tool calls so a new one (vs the mount baseline) can pop the workflow open. */
function countScheduleWorkflowCalls(session: AgentSession): number {
let count = 0;
for (const msg of session.messages || []) {
const msgAny = msg as any;
const directContent = msgAny.content;
if (msgAny.role === 'tool_result') {
const toolName = directContent?.tool_name ?? directContent?.tool ?? directContent?.name ?? msgAny.tool_name;
if (isScheduleWorkflowTool(toolName)) count += 1;
}
const blocks = Array.isArray(directContent) ? directContent : [];
for (const block of blocks) {
if (block?.type !== 'tool_result') continue;
if (isScheduleWorkflowTool(block?.tool_name ?? block?.tool ?? block?.name)) count += 1;
}
}
return count;
}
const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => {
if (service === 'gmail') {
return (
@@ -259,6 +348,10 @@ const AgentCard: React.FC<Props> = ({
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<Props> = ({
!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<Workflow>,
}));
}, [
cardHeight,
cardWidth,
cardX,
cardY,
converting,
defaultMode,
defaultModel,
dispatch,
expandedSessionIds,
isConvertBlockedByTurn,
session,
workflowSuggestion?.cadence,
]);
const handleConvertToWorkflow = useCallback((e: React.MouseEvent<HTMLElement>) => {
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<Props> = ({
}, [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<number | null>(null);
const autoOpenedWorkflowIdsRef = useRef<Set<string>>(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<HTMLDivElement>(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<Props> = ({
</Box>
</Tooltip>
)}
{(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession && (
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
<Box
role="button"
onClick={(e) => {
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<Workflow>,
}));
}}
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)' },
}}
>
<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />
{converting ? 'Converting…' : 'Convert to workflow'}
</Box>
</Tooltip>
)}
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
@@ -976,22 +1124,77 @@ const AgentCard: React.FC<Props> = ({
</Box>
</Box>
<Box sx={{
display: isDraft && !expanded ? 'none' : 'flex',
gap: 1.5,
flexShrink: 0,
...(isDraft && { visibility: 'hidden' }),
}}>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{friendlyModelLabel}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
<ElapsedTimer messages={session.messages} status={session.status} />
</Typography>
{session.cost_usd > 0 && hasApiKey && (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
<Box
sx={{
display: isDraft && !expanded ? 'none' : 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
flexShrink: 0,
minWidth: 0,
...(isDraft && { visibility: 'hidden' }),
}}
>
<Box sx={{ display: 'flex', gap: 1.5, minWidth: 0, overflow: 'hidden' }}>
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
{friendlyModelLabel}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
<ElapsedTimer messages={session.messages} status={session.status} />
</Typography>
{session.cost_usd > 0 && hasApiKey && (
<Typography variant="caption" sx={{ color: c.accent.primary, whiteSpace: 'nowrap' }}>
${session.cost_usd.toFixed(4)}
</Typography>
)}
</Box>
{showConvertToWorkflow && (
<Tooltip title={canConvertToWorkflow ? 'Turn this chat into a reusable workflow' : 'Wait for the current response to finish before converting'}>
<Box
key={`convert-workflow-${suggestGlowCycle}-${canConvertToWorkflow ? 'ready' : 'blocked'}`}
component={motion.div}
role="button"
aria-disabled={!canConvertToWorkflow}
onClick={handleConvertToWorkflow}
onMouseDown={(e) => 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 },
}}
>
<AutoAwesomeOutlinedIcon sx={{ fontSize: 13 }} />
{converting ? 'Converting...' : 'Convert to workflow'}
</Box>
</Tooltip>
)}
</Box>
</Box>
@@ -1204,6 +1407,95 @@ const AgentCard: React.FC<Props> = ({
) : null}
</>
)}
<Fade in={showWorkflowSuggestionPrompt} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
onClick={(e) => {
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)',
}}
>
<Box
onClick={(e) => 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,
}}
>
<Typography sx={{ fontSize: '0.98rem', fontWeight: 700, mb: 0.75 }}>
Would you like to make this a workflow?
</Typography>
<Typography sx={{ fontSize: '0.85rem', lineHeight: 1.5, color: c.text.secondary }}>
I can open a workflow draft from this chat. You can review the steps and choose the schedule there.
</Typography>
{workflowSuggestion?.cadence && (
<Typography sx={{ mt: 1, fontSize: '0.78rem', color: c.text.tertiary }}>
Suggested cadence: {workflowSuggestion.cadence}
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}>
<Button
variant="text"
size="small"
onClick={() => setDismissedWorkflowPromptKey(workflowSuggestionKey)}
sx={{ textTransform: 'none', color: c.text.tertiary, fontWeight: 700 }}
>
No, keep chatting
</Button>
<Button
variant="contained"
size="small"
startIcon={<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />}
onClick={() => {
setDismissedWorkflowPromptKey(workflowSuggestionKey);
convertChatToWorkflow();
}}
sx={{
textTransform: 'none',
bgcolor: c.accent.primary,
color: '#fff',
fontWeight: 700,
boxShadow: c.shadow.sm,
'&:hover': { bgcolor: c.accent.hover },
}}
>
Yes, open workflow
</Button>
</Box>
</Box>
</Box>
</Fade>
<Snackbar
open={!!workflowToast}
autoHideDuration={3200}
onClose={() => setWorkflowToast('')}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="info"
variant="filled"
onClose={() => setWorkflowToast('')}
sx={{ fontSize: '0.78rem' }}
>
{workflowToast}
</Alert>
</Snackbar>
</Box>
</motion.div>
);
@@ -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,
}),
});
@@ -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<string, any> }[] = [
];
interface Props {
dashboardId?: string;
cardX: number;
cardY: number;
cardWidth: number;
@@ -125,6 +126,7 @@ function TimeSavedBadge() {
}
const WorkflowsHubCard: React.FC<Props> = ({
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<Props> = ({
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));
+1
View File
@@ -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;
}
@@ -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 {