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 64cf7d3d..420cde9a 100644
--- a/backend/apps/workflows/workflows.py
+++ b/backend/apps/workflows/workflows.py
@@ -288,7 +288,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
@workflows.router.post("/generate-metadata")
diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx
index 61b370bb..38bfc8d6 100644
--- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx
+++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx
@@ -30,7 +30,7 @@ import ChatInput from '@/app/pages/AgentChat/ChatInput';
import type { ContextPath } from '@/app/components/editor/DirectoryBrowser';
import SchedulePopover from '@/app/pages/Workflows/SchedulePopover';
import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice';
-import { addWorkflowCard, openWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
+import { addWorkflowCard, openWorkflowsHub, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -191,6 +191,7 @@ const DashboardToolbar = React.forwardRef(
const allRuns = useAppSelector((s) => s.workflows.allRuns);
const allRunsLoading = useAppSelector((s) => s.workflows.allRunsLoading);
const workflowItems = useAppSelector((s) => s.workflows.items);
+ const workflowsHubOpen = useAppSelector((s) => Boolean(s.dashboardLayout.workflowsHub));
const outputList = useMemo(() => Object.values(outputs), [outputs]);
const filteredOutputs = useMemo(() => {
@@ -814,15 +815,16 @@ const DashboardToolbar = React.forwardRef(
enterDelay={200}
title={
- History ⌘O
+ Workflows
+ Schedule and calendar
}
>
dispatch(workflowsHubOpen ? closeWorkflowsHub() : openWorkflowsHub({ expandedSessionIds: [] }))}
sx={{
display: 'flex',
alignItems: 'center',
@@ -830,14 +832,15 @@ const DashboardToolbar = React.forwardRef(
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
- color: c.text.tertiary,
+ color: workflowsHubOpen ? c.accent.primary : c.text.tertiary,
+ bgcolor: workflowsHubOpen ? c.bg.secondary : 'transparent',
cursor: 'pointer',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
...popIn(3),
}}
>
-
+
@@ -876,6 +879,40 @@ const DashboardToolbar = React.forwardRef(
+
+ History ⌘O
+
+ }
+ >
+
+
+
+
+
{placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => (
= ({
toolbarPrefill,
toolbarPrefillMode,
}) => {
+ const missedRunsCard = useAppSelector((s) => s.dashboardLayout.missedRunsCard);
return (
<>
{/* Floating bottom toolbar */}
@@ -131,6 +133,7 @@ const DashboardOverlays: React.FC = ({
browserCards,
workflowCards,
workflowsHub,
+ missedRunsCard,
}}
onMinimapPan={(px, py) => canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })}
/>
diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx
index f025c5f6..0392ea6c 100644
--- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx
+++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx
@@ -7,6 +7,7 @@ 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';
@@ -69,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 (
@@ -260,7 +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 [convertToast, setConvertToast] = useState(null);
+ 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
@@ -304,24 +395,35 @@ const AgentCard: React.FC = ({
!sourceWorkflow.schedule?.enabled &&
(session.status === 'completed' || session.status === 'stopped') &&
session.messages.length >= 2;
- // The convert button stays visible whenever this is a real, convertible
- // chat; it's only greyed out (not hidden) while a turn is still running so
- // the affordance doesn't flicker in and out mid-response.
- const turnSettled = session.status === 'completed' || session.status === 'stopped';
- const showConvert = session.messages.length >= 2 && !isWorkflowRunnerSession;
- const handleConvert = useCallback((e: React.MouseEvent) => {
- e.stopPropagation();
+ 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;
- if (session.status !== 'completed' && session.status !== 'stopped') {
- setConvertToast("Can't convert to workflow mid-turn. Wait for the agent to finish.");
+ const steps = extractStepsFromSession(session);
+ if (steps.length === 0) {
+ setWorkflowToast('Add a prompt before converting this chat to a workflow.');
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.
dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds }));
dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY }));
dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight }));
@@ -341,6 +443,7 @@ const AgentCard: React.FC = ({
use_synced_prompt: true,
model: defaultModel || session.model,
mode: defaultMode || session.mode,
+ suggested_cadence: workflowSuggestion?.cadence || undefined,
} as Partial,
}));
const genModel = defaultModel || session.model;
@@ -352,7 +455,28 @@ const AgentCard: React.FC = ({
dispatch(updateWorkflowCard({ workflowId: draftId, patch: { metaLoading: false } }));
}
});
- }, [converting, session, dispatch, expandedSessionIds, cardX, cardY, cardWidth, cardHeight, defaultModel, defaultMode]);
+ }, [
+ 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;
@@ -369,6 +493,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);
@@ -975,62 +1136,82 @@ const AgentCard: React.FC = ({
-
-
- {friendlyModelLabel}
-
-
-
-
- {session.cost_usd > 0 && hasApiKey && (
-
- ${session.cost_usd.toFixed(4)}
+
+
+
+ {friendlyModelLabel}
- )}
- {showConvert && (
- <>
-
-
- 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' : turnSettled ? 'pointer' : 'not-allowed',
- opacity: converting ? 0.7 : turnSettled ? 1 : 0.45,
- '&:hover': turnSettled && !converting ? { filter: 'brightness(1.05)' } : {},
- }}
- >
-
- {converting ? 'Converting…' : 'Convert to workflow'}
-
-
- >
+
+
+
+ {session.cost_usd > 0 && hasApiKey && (
+
+ ${session.cost_usd.toFixed(4)}
+
+ )}
+
+ {showConvertToWorkflow && (
+
+ e.stopPropagation()}
+ 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 },
+ }}
+ >
+
+ {converting ? 'Converting...' : 'Convert to workflow'}
+
+
)}
- setConvertToast(null)}
- message={convertToast || ''}
- anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
- />
-
{expanded && (
e.stopPropagation()}
@@ -1239,6 +1420,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}
+
+ )}
+
+
+ }
+ 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
+
+
+
+
+
+ setWorkflowToast('')}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
+ >
+ setWorkflowToast('')}
+ sx={{ fontSize: '0.78rem' }}
+ >
+ {workflowToast}
+
+
);
diff --git a/frontend/src/app/pages/Dashboard/controls/Minimap.tsx b/frontend/src/app/pages/Dashboard/controls/Minimap.tsx
index 4bdee3b1..3b2b64eb 100644
--- a/frontend/src/app/pages/Dashboard/controls/Minimap.tsx
+++ b/frontend/src/app/pages/Dashboard/controls/Minimap.tsx
@@ -1,6 +1,6 @@
import React, { useRef, useCallback, useMemo } from 'react';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
-import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
+import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition, MissedRunsCardPosition } from '@/shared/state/dashboardLayoutSlice';
const MINIMAP_W = 200;
const MINIMAP_H = 140;
@@ -16,6 +16,7 @@ export interface MinimapProps {
browserCards: Record;
workflowCards: Record;
workflowsHub: WorkflowsHubPosition | null;
+ missedRunsCard: MissedRunsCardPosition | null;
onPan: (panX: number, panY: number) => void;
}
@@ -24,12 +25,12 @@ interface CardRect {
y: number;
width: number;
height: number;
- type: 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub';
+ type: 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub' | 'missed-runs';
}
const Minimap: React.FC = ({
panX, panY, zoom, viewportRef,
- cards, viewCards, browserCards, workflowCards, workflowsHub,
+ cards, viewCards, browserCards, workflowCards, workflowsHub, missedRunsCard,
onPan,
}) => {
const c = useClaudeTokens();
@@ -59,8 +60,17 @@ const Minimap: React.FC = ({
type: 'workflows-hub',
});
}
+ if (missedRunsCard) {
+ result.push({
+ x: missedRunsCard.x,
+ y: missedRunsCard.y,
+ width: missedRunsCard.width,
+ height: missedRunsCard.height,
+ type: 'missed-runs',
+ });
+ }
return result;
- }, [cards, viewCards, browserCards, workflowCards, workflowsHub]);
+ }, [cards, viewCards, browserCards, workflowCards, workflowsHub, missedRunsCard]);
const layout = useMemo(() => {
const vp = viewportRef.current;
@@ -149,6 +159,7 @@ const Minimap: React.FC = ({
case 'browser': return c.status.success;
case 'workflow': return c.status.warning;
case 'workflows-hub': return c.status.warning;
+ case 'missed-runs': return c.status.warning;
}
};
diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts
index ed5f0291..8e8e04fc 100644
--- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts
+++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts
@@ -2,7 +2,7 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react';
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice';
-import { removeViewCard, removeBrowserCard, removeNote, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
+import { removeViewCard, removeBrowserCard, removeNote, removeWorkflowCard, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { closeWorkflowCard } from '@/shared/state/workflowsSlice';
import type { useDashboardSelection } from '../state/useDashboardSelection';
@@ -83,6 +83,8 @@ export function useDashboardShortcuts({
} else if (type === 'workflow') {
dispatch(removeWorkflowCard(id));
dispatch(closeWorkflowCard(id));
+ } else if (type === 'workflows-hub') {
+ dispatch(closeWorkflowsHub());
}
}
selection.deselectAll();
diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx
index 2acbfe7c..7d5f75be 100644
--- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx
+++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx
@@ -255,25 +255,25 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
return (
{/* Day headers: muted weekday caps; today's date gets the filled circle */}
-
+
{!compact && (
{TZ_LABEL}
)}
{days.map((d) => {
- const isToday = sameDay(d, today);
+ const isToday = sameDay(d, now);
return (
{WEEKDAY_LABEL_SHORT[d.getDay()]}
- {d.getDate()}
+ {d.getDate()}
);
})}
-
+
{HOURS.map((hour, hourIdx) => (
{/* Hour label sits inside its row (top-aligned) rather than
@@ -348,43 +348,55 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
const accent = c.accent.primary;
return (
-
+
{/* Sticky weekday header so it stays visible even when the
calendar body scrolls. Slightly bigger + tinted bg so it
reads cleanly in both light and dark themes. */}
-
+
{WEEKDAY_LABEL_SHORT.map((l, i) => (
{l}
))}
-
+
{cells.map((d) => {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const evs = eventsByDay.map.get(key) || [];
- const isToday = sameDay(d, today);
+ const isToday = sameDay(d, now);
const inMonth = d.getMonth() === today.getMonth();
return (
-
+
{/* Out-of-month dates still need to be legible (Apple
Calendar shows them in a muted shade, not invisible).
Color tweak instead of opacity so dark themes stay
readable. */}
- {d.getDate()}
+ {d.getDate()}
- {evs.slice(0, compact ? 3 : 4).map((e, idx) => (
+ {evs.slice(0, compact ? 3 : 4).map((e, idx) => {
+ // Past fires read as a hollow ring, upcoming ones stay filled,
+ // so a glance down a day tells you what already ran.
+ const passed = e.date.getTime() < now.getTime();
+ return (
onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}>
-
+
{formatTime(e.date.getHours(), e.date.getMinutes())}
{e.workflow.title}
- ))}
+ );
+ })}
{evs.length > (compact ? 3 : 4) && (
- +{evs.length - (compact ? 3 : 4)} more
+
)}
);
@@ -571,6 +583,57 @@ function EventStack({ events, paused, onSelectWorkflow, eventFontSize, onContext
);
}
+// "+N more" on a packed month cell opens a scrollable popover listing every
+// run that day, so a heavy day isn't a dead end. Past fires keep the hollow
+// ring the cell rows use, for a consistent at-a-glance "already ran" read.
+function MonthDayOverflow({ date, count, events, now, fontSize, onSelectWorkflow }: {
+ date: Date;
+ count: number;
+ events: { workflow: Workflow; date: Date }[];
+ now: Date;
+ fontSize: string;
+ onSelectWorkflow?: (id: string) => void;
+}) {
+ const c = useClaudeTokens();
+ const [anchor, setAnchor] = useState(null);
+ const accent = c.accent.primary;
+ return (
+ <>
+ { e.stopPropagation(); setAnchor(e.currentTarget); }}
+ role="button"
+ sx={{ fontSize, color: c.text.muted, mt: 0.3, pl: 1.4, cursor: 'pointer', '&:hover': { color: accent } }}>
+ +{count} more
+
+ setAnchor(null)}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
+ transformOrigin={{ vertical: 'top', horizontal: 'left' }}>
+
+
+ {`${events.length} scheduled · ${date.toLocaleString('en', { weekday: 'short', month: 'short', day: 'numeric' })}`}
+
+ {events.map((e, idx) => {
+ const passed = e.date.getTime() < now.getTime();
+ return (
+ { setAnchor(null); onSelectWorkflow?.(e.workflow.id); }}
+ sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
+
+ {e.workflow.title}
+ {formatTime(e.date.getHours(), e.date.getMinutes())}
+
+ );
+ })}
+
+
+ >
+ );
+}
+
function EventTooltipBody({ event }: { event: { workflow: Workflow; date: Date } }) {
const wf = event.workflow;
const status = wf.last_run_status;
diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx
index 2183a931..3ca80972 100644
--- a/frontend/src/app/pages/Workflows/SchedulePopover.tsx
+++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx
@@ -226,8 +226,8 @@ export default function SchedulePopover({
{periodLabel}
-
-
+
+
)}
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/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx
index d69ec5bd..e0684204 100644
--- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx
+++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx
@@ -678,9 +678,9 @@ function groupKey(iso: string): string {
export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleFor }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void; showWorkflow?: boolean; workflowTitleFor?: (workflowId: string) => string }) {
const c = useClaudeTokens();
const [expandedId, setExpandedId] = useState(null);
- // Filter chips: all / failures / late. Power-users debugging a flaky
- // workflow shouldn't have to scroll past successes.
- const [filter, setFilter] = useState<'all' | 'failure' | 'ran_late'>('all');
+ // Filter chips: all / success / failures / skipped. Power-users debugging a
+ // flaky workflow shouldn't have to scroll past the runs they don't care about.
+ const [filter, setFilter] = useState<'all' | 'success' | 'failure' | 'skipped'>('all');
const filtered = useMemo(() => {
if (filter === 'all') return runs;
return (runs || []).filter((r) => r.status === filter);
@@ -707,7 +707,7 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
)}
- {(['all', 'failure', 'ran_late'] as const).map((k) => (
+ {(['all', 'success', 'failure', 'skipped'] as const).map((k) => (
setFilter(k)} role="button" sx={{
fontSize: '0.72rem', fontWeight: 600,
color: filter === k ? c.accent.primary : c.text.muted,
@@ -716,7 +716,7 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
px: 0.75, py: 0.3, borderRadius: c.radius.full, cursor: 'pointer',
'&:hover': { color: c.accent.primary },
}}>
- {k === 'all' ? 'All' : k === 'failure' ? 'Failures only' : 'Ran late only'}
+ {k === 'all' ? 'All' : k === 'success' ? 'Success' : k === 'failure' ? 'Failures' : 'Skipped'}
))}
@@ -754,19 +754,17 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
▾
{expanded && (
-
+
+
{r.error ? (
{r.error}
- ) : (
-
- {r.session_id ? 'Click below to see the full conversation.' : 'No session was recorded for this run. Click below to see the full conversation.'}
-
- )}
-
- { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
- See full conversation →
+ ) : r.session_id ? (
+ { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
+ Click to see the full conversation →
-
+ ) : (
+ No session was recorded for this run.
+ )}
)}
diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
index 1eed4b13..a4bdc15c 100644
--- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
+++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
@@ -342,6 +342,7 @@ const WorkflowsHubCard: React.FC = ({
border,
borderRadius: 3,
boxShadow: shadow,
+ overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
@@ -356,7 +357,8 @@ const WorkflowsHubCard: React.FC = ({
onPointerUp={onHeaderPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 0.6,
- px: 1.5, py: 0.6,
+ px: 1.5, py: 0.5,
+ bgcolor: c.bg.elevated,
borderBottom: `1px solid ${c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none',
@@ -381,7 +383,7 @@ const WorkflowsHubCard: React.FC = ({
{/* ===== Toolbar row (matches Figma image #8 header) ===== */}
-
+
setSidebarOpen((v) => !v)} sx={{ p: 0.5, color: sidebarOpen ? c.text.secondary : c.text.muted, '&:hover': { color: c.text.primary } }}>
@@ -393,9 +395,9 @@ const WorkflowsHubCard: React.FC = ({
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
- fontSize: '0.82rem', fontWeight: 600, color: c.text.primary,
- bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}`,
- px: 1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
+ fontSize: '0.85rem', fontWeight: 600, color: c.text.primary,
+ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`,
+ px: 1, py: 0.4, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary },
}}
>
@@ -436,9 +438,6 @@ const WorkflowsHubCard: React.FC = ({
{monthLabel}
-
-
-
setViewOpen((v) => !v)}
@@ -474,8 +473,8 @@ const WorkflowsHubCard: React.FC = ({
{/* Sidebar */}
{sidebarOpen && (
-
-
+
+
setSearch(e.target.value)}
@@ -485,7 +484,7 @@ const WorkflowsHubCard: React.FC = ({
/>
-
+
match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} onSchedule={(wf, el) => setSchedulePopover({ anchorEl: el, workflow: wf })} />
@@ -493,7 +492,7 @@ const WorkflowsHubCard: React.FC = ({
)}
{/* Main calendar area */}
-
+
@@ -571,7 +570,7 @@ function MiniMonth({ refDate, onPick }: { refDate: Date; onPick: (d: Date) => vo
const today = new Date();
const label = refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
return (
-
+
{label}
onPick(addMonths(refDate, -1))} sx={{ p: 0.15 }}>
diff --git a/frontend/src/app/pages/Workflows/workflowVisuals.tsx b/frontend/src/app/pages/Workflows/workflowVisuals.tsx
index 27871ae9..eb7af1e1 100644
--- a/frontend/src/app/pages/Workflows/workflowVisuals.tsx
+++ b/frontend/src/app/pages/Workflows/workflowVisuals.tsx
@@ -386,15 +386,14 @@ function relTime(ms: number): string {
// ---------- Run history sparkline ----------
// 10-dot horizontal strip of last N runs colored by status. Easy "lately
-// healthy?" check without opening the History tab. Tooltip names the
-// pattern out loud so a non-dev knows the dots aren't decorative.
+// healthy?" check without opening the History tab.
export function RunSparkline({ runs, max = 10 }: { runs: WorkflowRun[]; max?: number }) {
const c = useClaudeTokens();
if (!runs || runs.length === 0) return null;
const slice = runs.slice(0, max).reverse();
const successes = slice.filter((r) => r.status === 'success').length;
const failures = slice.filter((r) => r.status === 'failure').length;
- const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} ok, ${failures} failed (oldest left → newest right). Green = success, red = failure, amber = ran late.`;
+ const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} successful, ${failures} failed`;
return (
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 307ec496..4358de95 100644
--- a/frontend/src/shared/state/workflowsSlice.ts
+++ b/frontend/src/shared/state/workflowsSlice.ts
@@ -100,6 +100,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 {