From 6404d98b3a36fa68dd8a5d5eb9f95e48d2d6f96a Mon Sep 17 00:00:00 2001 From: Aidan Date: Fri, 19 Jun 2026 03:47:35 -0700 Subject: [PATCH 1/9] [aidan] feat/scheduled-tasks: agent workflow scheduling and in-chat convert (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 --- backend/apps/agents/agent_manager.py | 73 ++- backend/apps/agents/schedule_mcp_server.py | 64 ++- backend/apps/workflows/workflows.py | 11 +- .../Dashboard/canvas/DashboardCanvas.tsx | 1 + .../Dashboard/canvas/DashboardCardLayer.tsx | 3 + .../app/pages/Dashboard/cards/AgentCard.tsx | 426 +++++++++++++++--- .../app/pages/Workflows/SchedulingView.tsx | 4 +- .../app/pages/Workflows/WorkflowsHubCard.tsx | 9 +- frontend/src/shared/mcpToolMeta.ts | 1 + frontend/src/shared/state/workflowsSlice.ts | 3 + 10 files changed, 510 insertions(+), 85 deletions(-) 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 { From f014ff3cbb72303383ac8094644671869196407c Mon Sep 17 00:00:00 2001 From: Aidan Date: Fri, 19 Jun 2026 16:56:04 -0700 Subject: [PATCH 2/9] [aidan] ux/calendar-page: schedule calendar restyle + popover fixes (#99) * [aidan] fix/dashboard-delete: remove workflows calendar panel on delete key * [aidan] ux/workflows-calendar: restyle hub, fix today highlight, add toolbar toggle * [aidan] ux/schedule-popover: compact density, fix sticky header bleed, add header spacing * [aidan] ux/schedule-calendar: hollow ring dot for past fires in month view * [aidan] ux/schedule-calendar: clickable +N more opens day's full run list --- .../app/pages/Dashboard/DashboardToolbar.tsx | 49 ++++++++-- .../interaction/useDashboardShortcuts.ts | 4 +- .../app/pages/Workflows/ScheduleCalendar.tsx | 93 ++++++++++++++++--- .../app/pages/Workflows/SchedulePopover.tsx | 4 +- .../app/pages/Workflows/WorkflowsHubCard.tsx | 21 ++--- 5 files changed, 136 insertions(+), 35 deletions(-) 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 }) => ( {/* 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 @@ -278,43 +278,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 + )} ); @@ -334,7 +346,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD const day = addDays(today, i); const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`; const arr = eventsByDay.map.get(key) || []; - const isToday = sameDay(day, today); + const isToday = sameDay(day, now); if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday }); } const accent = c.accent.primary; @@ -489,6 +501,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 b0cd2fa7..ad1d3ac6 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/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index c3a463ce..a8b2c343 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -387,6 +387,7 @@ const WorkflowsHubCard: React.FC = ({ border, borderRadius: `${c.radius.lg}px`, boxShadow: shadow, + overflow: 'hidden', display: 'flex', flexDirection: 'column', zIndex: (isDragging || isResizing) ? 999999 : cardZOrder, @@ -401,7 +402,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', @@ -426,7 +428,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 } }}> @@ -439,7 +441,7 @@ const WorkflowsHubCard: React.FC = ({ sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.85rem', fontWeight: 600, color: c.text.primary, - bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}`, + 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 }, }} @@ -482,9 +484,6 @@ const WorkflowsHubCard: React.FC = ({ - - - setViewOpen((v) => !v)} @@ -520,8 +519,8 @@ const WorkflowsHubCard: React.FC = ({ {/* Sidebar */} {sidebarOpen && ( - - + + setSearch(e.target.value)} @@ -531,7 +530,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 })} /> @@ -539,7 +538,7 @@ const WorkflowsHubCard: React.FC = ({ )} {/* Main calendar area */} - + @@ -617,7 +616,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 }}> From 93983244b967ff070b6a8ef71c64e5065b1cffb2 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 19 Jun 2026 17:35:53 -0700 Subject: [PATCH 3/9] [aidan] feat/run-log-filters: add success and skipped pills to scheduled task history --- .../src/app/pages/Workflows/WorkflowCardSubviews.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 2536678f..1a3049e7 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -677,9 +677,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); @@ -706,7 +706,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, @@ -715,7 +715,7 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF px: 0.7, py: 0.2, borderRadius: 999, 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'} ))} From 928da9185f81250234a7ca5dfdf7adcd51d163b9 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 19 Jun 2026 17:59:59 -0700 Subject: [PATCH 4/9] [aidan] fix/convert-button: stop drag capture so convert-to-workflow click fires --- frontend/src/app/pages/Dashboard/cards/AgentCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index fcaa2569..b2e5da43 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -1156,6 +1156,7 @@ const AgentCard: React.FC = ({ role="button" aria-disabled={!canConvertToWorkflow} onClick={handleConvertToWorkflow} + onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} animate={suggestGlowCycle > 0 ? { scale: [1, 1.06, 1, 1.045, 1], From cec6e4e494f1a752d9b4f7f627d3f9d77c9af795 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 19 Jun 2026 17:59:59 -0700 Subject: [PATCH 5/9] [aidan] ux/calendar-card: match border color and radius to chat and workflow cards --- frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index a8b2c343..212bbf90 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -349,7 +349,7 @@ const WorkflowsHubCard: React.FC = ({ ? `2px solid ${c.accent.primary}` : isSelected ? '2px solid #3b82f6' - : `1px solid ${c.border.medium}`; + : `1px solid ${c.border.strong}`; const shadow = isHighlighted ? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15` : (isDragging || isResizing) @@ -385,7 +385,7 @@ const WorkflowsHubCard: React.FC = ({ height: dh, bgcolor: c.bg.surface, border, - borderRadius: `${c.radius.lg}px`, + borderRadius: 3, boxShadow: shadow, overflow: 'hidden', display: 'flex', From 0fea78c2a775b67a1243b124e06f9fb17185d5b2 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 19 Jun 2026 19:02:56 -0700 Subject: [PATCH 6/9] [aidan] fix/minimap: render missed-runs card on the minimap --- .../Dashboard/canvas/DashboardOverlays.tsx | 3 +++ .../app/pages/Dashboard/controls/Minimap.tsx | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 99a6135a..6e5badb5 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -1,5 +1,6 @@ import React, { type RefObject } from 'react'; import Box from '@mui/material/Box'; +import { useAppSelector } from '@/shared/hooks'; import DashboardToolbar from '../DashboardToolbar'; import CanvasControls from '../controls/CanvasControls'; import CardSearchPalette from '../controls/CardSearchPalette'; @@ -80,6 +81,7 @@ const DashboardOverlays: React.FC = ({ toolbarPrefill, toolbarPrefillMode, }) => { + const missedRunsCard = useAppSelector((s) => s.dashboardLayout.missedRunsCard); return ( <> {/* Floating bottom toolbar */} @@ -130,6 +132,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/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; } }; From 5176c8e35c376db2cd68b62a55f7bc82beabff1b Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 19 Jun 2026 19:09:28 -0700 Subject: [PATCH 7/9] [aidan] ux/run-sparkline: simplify tooltip to plain run tally --- frontend/src/app/pages/Workflows/workflowVisuals.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/pages/Workflows/workflowVisuals.tsx b/frontend/src/app/pages/Workflows/workflowVisuals.tsx index acfcbeeb..b51540ec 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 ( From cc3da8b74c54f5af0cfe328b2b05ec9a393d1335 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 19 Jun 2026 19:09:28 -0700 Subject: [PATCH 8/9] [aidan] ux/run-history: collapse expanded run view to one clickable line --- .../pages/Workflows/WorkflowCardSubviews.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 1a3049e7..ff6b6753 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -753,19 +753,16 @@ 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. + )} )} From 7daa8d573b341b423fbd47cf0ea7cad39a1f24a9 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 19 Jun 2026 19:09:28 -0700 Subject: [PATCH 9/9] [aidan] ux/calendar-card: match corner radius to browser cards --- frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index 212bbf90..96e90860 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -385,7 +385,7 @@ const WorkflowsHubCard: React.FC = ({ height: dh, bgcolor: c.bg.surface, border, - borderRadius: 3, + borderRadius: `${c.radius.lg}px`, boxShadow: shadow, overflow: 'hidden', display: 'flex',