diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py index cbee5521..7fa620a6 100644 --- a/backend/apps/workflows/scheduler.py +++ b/backend/apps/workflows/scheduler.py @@ -101,8 +101,14 @@ def _js_weekday(d: datetime) -> int: return (d.weekday() + 1) % 7 +def is_schedule_configured(sched: ScheduleConfig) -> bool: + if sched.repeat_unit == "week": + return bool(sched.on_days) + return True + + def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datetime]: - if not sched.enabled: + if not sched.enabled or not is_schedule_configured(sched): return None tz = _resolve_tz(sched.timezone) ref_local = ref_utc.astimezone(tz) @@ -135,7 +141,7 @@ def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datet return candidate.astimezone(timezone.utc) if sched.repeat_unit == "week": - allowed = sched.on_days or [_js_weekday(ref_local)] + allowed = sched.on_days for _ in range(0, 14): if _js_weekday(candidate) in allowed and candidate > ref_local: return candidate.astimezone(timezone.utc) @@ -216,6 +222,9 @@ async def _tick() -> None: for wf in storage.list_workflows(): if not wf.schedule.enabled: continue + if not is_schedule_configured(wf.schedule): + _disable_schedule(wf) + continue if _end_condition_hit(wf, now_utc): _disable_schedule(wf) continue @@ -304,6 +313,10 @@ def reconcile_on_startup() -> None: storage.save_workflow(wf) continue + if not is_schedule_configured(wf.schedule): + _disable_schedule(wf) + continue + if _end_condition_hit(wf, now_utc): _disable_schedule(wf) continue diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 7d144373..43c29670 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -143,6 +143,12 @@ async def list_workflows(dashboard_id: Optional[str] = None): return {"workflows": [_enriched(w) for w in items]} +def _normalize_schedule_state(wf: Workflow) -> None: + if wf.schedule.enabled and not scheduler.is_schedule_configured(wf.schedule): + wf.schedule.enabled = False + wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None + + @workflows.router.post("/create") async def create_workflow(body: WorkflowCreate): actions = body.actions @@ -151,7 +157,7 @@ async def create_workflow(body: WorkflowCreate): # Source-session creates inherit the chat's tool choices so we leave # them alone there (the source session itself already vetted the # blast radius). - if body.schedule.enabled and not actions.freeze and not body.source_session_id: + if body.schedule.enabled and scheduler.is_schedule_configured(body.schedule) and not actions.freeze and not body.source_session_id: actions = actions.model_copy(update={"freeze": True}) wf = Workflow( title=body.title, @@ -175,8 +181,7 @@ async def create_workflow(body: WorkflowCreate): wf.remembered_approvals = p_source_session_approvals(body.source_session_id) if not wf.icon: wf.icon = _derive_icon(wf) - if wf.schedule.enabled: - wf.next_run_at = scheduler.compute_next_fire(wf) + _normalize_schedule_state(wf) # Force-generate title + description + per-step labels from the steps # in a single aux call. Previously we only filled missing description, # leaving stale session names ("Inbox check") as titles. Step labels @@ -560,6 +565,7 @@ async def update_workflow( if k != "steps": setattr(wf, k, v) wf.updated_at = datetime.now() + _normalize_schedule_state(wf) storage.save_workflow(wf) enriched = _enriched(wf) try: @@ -579,7 +585,7 @@ async def update_workflow( wf.updated_at = datetime.now() if not wf.icon: wf.icon = _derive_icon(wf) - wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None + _normalize_schedule_state(wf) storage.save_workflow(wf) audit.log_change(wf.id, "user", before, wf.model_dump(mode="json")) scheduler.kick() @@ -748,7 +754,7 @@ async def commit_draft(workflow_id: str): wf.updated_at = datetime.now() if not wf.icon: wf.icon = _derive_icon(wf) - wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None + _normalize_schedule_state(wf) await p_end_edit_session(wf) storage.save_workflow(wf) audit.log_change(wf.id, "user", before, wf.model_dump(mode="json")) diff --git a/backend/tests/test_workflows_semantics.py b/backend/tests/test_workflows_semantics.py index 79a887e7..13df556e 100644 --- a/backend/tests/test_workflows_semantics.py +++ b/backend/tests/test_workflows_semantics.py @@ -102,6 +102,44 @@ def test_dst_fall_back_no_double_fire(): assert after.astimezone(tz).date() == datetime(2025, 11, 3).date() +def test_unconfigured_weekly_schedule_has_no_next_fire(): + from backend.apps.workflows.models import ScheduleConfig + from backend.apps.workflows.scheduler import _next_fire_after + sched = ScheduleConfig( + enabled=True, + repeat_unit="week", + repeat_every=1, + on_days=[], + hour=9, + minute=0, + timezone="America/Los_Angeles", + ) + ref = datetime(2026, 6, 17, 8, 0, tzinfo=timezone.utc) + assert _next_fire_after(sched, ref) is None + + +def test_reconcile_disables_enabled_weekly_without_days(): + from backend.apps.workflows import storage, scheduler + from backend.apps.workflows.models import ScheduleConfig + wf = _make_wf( + schedule=ScheduleConfig( + enabled=True, + repeat_unit="week", + repeat_every=1, + on_days=[], + hour=9, + minute=0, + timezone="America/Los_Angeles", + ) + ) + wf.next_run_at = datetime.now(timezone.utc) + timedelta(days=1) + storage.save_workflow(wf) + scheduler.reconcile_on_startup() + after = storage.get_workflow(wf.id) + assert after.schedule.enabled is False + assert after.next_run_at is None + + # --- End condition tests ----------------------------------------------------- def test_max_runs_disables_schedule(): diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 079ad6f3..5b17583a 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -63,6 +63,7 @@ import { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeCard } from '@/shared/state/dashboardLayoutSlice'; import { setCardSidecar, commitDraft, updateWorkflowCard } from '@/shared/state/workflowsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { parseMcpToolName, getMcpInputSummary } from '@/shared/mcpToolMeta'; const CONTEXT_WINDOWS: Record = { 'opus-4-8': 1_000_000, @@ -1285,7 +1286,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const c = p.call.content; const tool = typeof c === 'object' ? c.tool || '' : ''; const input = typeof c === 'object' ? c.input : ''; - const summary = typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120); + const mcp = parseMcpToolName(tool); + const friendly = mcp.isMcp ? getMcpInputSummary(input, mcp.action, mcp.serverSlug) : ''; + const summary = friendly || (typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120)); return { tool, input_summary: summary }; }); dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls })); @@ -1297,7 +1300,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const c = p.call.content; const tool = typeof c === 'object' ? c.tool || '' : ''; const input = typeof c === 'object' ? c.input : ''; - const summary = typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120); + const mcp = parseMcpToolName(tool); + const friendly = mcp.isMcp ? getMcpInputSummary(input, mcp.action, mcp.serverSlug) : ''; + const summary = friendly || (typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120)); return { tool, input_summary: summary }; }); const resultsSummary = group.pairs diff --git a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts index 8413c5cf..02d53675 100644 --- a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts +++ b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts @@ -1,6 +1,6 @@ import { AgentMessage } from '@/shared/state/agentsSlice'; import { prettyPath, prettyUrl, quoteQuery, bashCommandDetail } from './toolLabels'; -import { parseMcpToolName, getMcpInputSummary, getGmailHeader } from '@/shared/mcpToolMeta'; +import { parseMcpToolName, getMcpInputSummary, getGmailHeader, getWorkflowToolInputDisplay } from '@/shared/mcpToolMeta'; export function getToolData(call: AgentMessage) { const content = typeof call.content === 'object' ? call.content : {}; @@ -19,7 +19,7 @@ export function isBashTool(name: string) { export function getInputSummary(toolName: string, input: any): string { try { const mcp = parseMcpToolName(toolName); - if (mcp.isMcp) return getMcpInputSummary(input); + if (mcp.isMcp) return getMcpInputSummary(input, mcp.action, mcp.serverSlug); const n = toolName.toLowerCase(); if (isBashTool(toolName)) { @@ -61,7 +61,10 @@ function formatMcpInputDisplay(input: any): string { export function formatInputDisplay(toolName: string, input: any): string { try { const mcp = parseMcpToolName(toolName); - if (mcp.isMcp) return formatMcpInputDisplay(input); + if (mcp.isMcp) { + const workflowDisplay = getWorkflowToolInputDisplay(input, mcp.action, mcp.serverSlug); + return workflowDisplay || formatMcpInputDisplay(input); + } const n = toolName.toLowerCase(); if (isBashTool(toolName)) return input.command || ''; diff --git a/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx b/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx index 09daf483..911cb8ea 100644 --- a/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx +++ b/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx @@ -25,6 +25,7 @@ import { ApprovalRequest } from '@/shared/state/agentsSlice'; import { useAppSelector } from '@/shared/hooks'; import { ToolDefinition } from '@/shared/state/toolsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { getMcpInputSummary as getSharedMcpInputSummary, getWorkflowToolInputDisplay, getWorkflowToolLabel } from '@/shared/mcpToolMeta'; interface IntegrationMeta { label: string; @@ -96,6 +97,9 @@ export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta { ); if (!toolDef) { + if (parsed.serverSlug === 'openswarm-schedule') { + return { integration: null, description: '', serverLabel: 'Workflows' }; + } return { integration: null, description: '', serverLabel: parsed.serverSlug }; } @@ -110,6 +114,10 @@ export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta { function getMcpInputSummary(actionName: string, toolInput: Record): string { const lower = actionName.toLowerCase(); + if (getWorkflowToolLabel(actionName)) { + return getSharedMcpInputSummary(toolInput, actionName, 'openswarm-schedule'); + } + if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) { const query = toolInput.query || toolInput.search_query || toolInput.q || ''; const to = toolInput.to || toolInput.recipient || ''; @@ -569,7 +577,12 @@ const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => const meta = useMcpToolMeta(parsed); const accentColor = meta.integration?.color || c.status.warning; + const displayName = getWorkflowToolLabel(parsed.actionName) || parsed.displayName; const summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : ''; + const workflowDetails = parsed.isMcp + ? getWorkflowToolInputDisplay(request.tool_input, parsed.actionName, parsed.serverSlug) + : ''; + const detailText = workflowDetails || JSON.stringify(request.tool_input, null, 2); const isSensitive = !!request.sensitive_pattern; if (!parsed.isMcp) { @@ -744,7 +757,7 @@ const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => - {parsed.displayName} + {displayName} = ({ request, onApprove, onDeny }) => sx={{ color: c.text.secondary, fontSize: '0.82rem', - fontFamily: c.font.mono, flex: 1, minWidth: 0, overflow: 'hidden', @@ -812,7 +824,7 @@ const GenericApprovalBar: React.FC = ({ request, onApprove, onDeny }) => - {JSON.stringify(request.tool_input, null, 2)} + {detailText} diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx index fb1c6bb5..700fd138 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx @@ -15,7 +15,7 @@ import { GoogleServiceIcon } from '../mcp-cards/GoogleServiceIcon'; import { ElapsedTimer, formatElapsed } from '../parsing/toolBubbleChrome'; import { useTermColors } from '../parsing/toolColorize'; import { ParsedResult } from '../parsing/toolResultParsing'; -import { McpToolInfo, getMcpShortAction } from '@/shared/mcpToolMeta'; +import { McpToolInfo, getMcpShortAction, getMcpInputSummary, getWorkflowToolLabel } from '@/shared/mcpToolMeta'; import { McpResultCard } from '../mcp-cards/McpResultCard'; interface CompactMcpBubbleProps { @@ -47,12 +47,17 @@ export const CompactMcpBubble: React.FC = ({ const c = useClaudeTokens(); const tc = useTermColors(); - const shortAction = mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName; + const workflowLabel = mcpInfo.isMcp ? getWorkflowToolLabel(mcpInfo.action) : null; + const shortAction = workflowLabel || (mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName); const mcpVerbLabel = (() => { + if (workflowLabel) return workflowLabel; const lbl = getToolLabel(toolName, call.id); return result && !isDenied ? lbl.past : lbl.present; })(); const serviceLabel = mcpInfo.isMcp ? mcpVerbLabel : shortAction; + const inputSummary = mcpInfo.isMcp ? getMcpInputSummary(input, mcpInfo.action, mcpInfo.serverSlug) : ''; + const visibleSummary = resultSummary || inputSummary; + const canToggleDetails = !!visibleSummary; const ServiceIcon = mcpInfo.isMcp && mcpInfo.service ? : null; @@ -60,16 +65,16 @@ export const CompactMcpBubble: React.FC = ({ return ( {ServiceIcon} @@ -83,22 +88,22 @@ export const CompactMcpBubble: React.FC = ({ > {serviceLabel} - {resultSummary && !isError && ( + {visibleSummary && !isError && ( - {resultSummary} + {visibleSummary} )} - {!resultSummary && !showTimer && } + {!visibleSummary && !showTimer && } {showTimer && ( <> @@ -123,12 +128,14 @@ export const CompactMcpBubble: React.FC = ({ )} )} + {canToggleDetails && ( {showBody ? : } + )} - + = ({ // already on screen. mcpCompact rows opt out (the group's row-fade handles them). const reveal = useMountReveal(); const enterStyle = (!mcpCompact && !suppressReveal) ? reveal : {}; + const canToggleDetails = !!inputSummary && !isStreaming; return ( = ({ } as any} > {mcpInfo.isMcp && mcpInfo.service @@ -186,7 +187,7 @@ export const DefaultToolBubble: React.FC = ({ )} {showTimer && } - {!isStreaming && ( + {canToggleDetails && ( {showBody ? ( @@ -197,7 +198,7 @@ export const DefaultToolBubble: React.FC = ({ )} - + = React.memo(({ group, isSessionRunning = ).length; const allDone = pendingCount === 0 || !isSessionRunning; - const displayName = meta?.name || group.label; - const hasSvg = !!meta?.svg; - const toolNames = group.pairs.map((p) => { const c2 = typeof p.call.content === 'object' ? p.call.content : {}; return c2.tool || 'unknown'; }); + const workflowGroupLabel = (() => { + if (group.mcpServer !== 'openswarm-schedule') return null; + const parsedLabels = Array.from(new Set(toolNames.map((name) => { + const parsed = parseMcpToolName(name); + return parsed.isMcp ? getWorkflowToolLabel(parsed.action) : null; + }).filter(Boolean))) as string[]; + return parsedLabels.length === 1 ? parsedLabels[0] : 'Workflow actions'; + })(); + const displayName = workflowGroupLabel || meta?.name || group.label; + const hasSvg = !!meta?.svg && !workflowGroupLabel; + const canToggleGroup = group.pairs.length > 1; return ( = React.memo(({ group, isSessionRunning = }} > setExpanded(!expanded)} + onClick={canToggleGroup ? () => setExpanded(!expanded) : undefined} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: 0.7, - cursor: 'pointer', - '&:hover': { bgcolor: 'rgba(0,0,0,0.02)' }, + cursor: canToggleGroup ? 'pointer' : 'default', + '&:hover': canToggleGroup ? { bgcolor: 'rgba(0,0,0,0.02)' } : undefined, }} > {!meta ? ( @@ -171,9 +180,11 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = {completedCount}/{group.callCount} )} + {canToggleGroup && ( {expanded ? : } + )} diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 7778c34a..f8604867 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -41,7 +41,7 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough'; import { useStreamingMessage } from '@/shared/state/streamingSlice'; import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState'; -import { createWorkflow, openWorkflowCard, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice'; +import { openWorkflowCard, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice'; import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice'; import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined'; import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime'; @@ -911,42 +911,34 @@ const AgentCard: React.FC = ({ { + onClick={(e) => { e.stopPropagation(); if (converting) return; const steps = extractStepsFromSession(session); if (steps.length === 0) return; setConverting(true); - // Persist up front while the chat card stays put, then - // swap this card's slot to the saved workflow. No preview - // interstitial: the steps already exist, so the saved card - // (with its one-time schedule nudge) is all we need. On a - // failed create the chat card stays so the user can retry. - const result = await dispatch(createWorkflow({ - 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)); - if (!createWorkflow.fulfilled.match(result)) { - setConverting(false); - return; - } - const wf = result.payload; - // The OG chat card BECOMES the workflow card: same slot, - // same size, no tether arrow (Image #61 / #62). The chat - // session stays accessible via History. - dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: null, expandedSessionIds })); - dispatch(setWorkflowCardPosition({ workflowId: wf.id, x: cardX, y: cardY })); - dispatch(setWorkflowCardSize({ workflowId: wf.id, width: cardWidth, height: cardHeight })); + 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)); - // showScheduleNudge: the one-shot "Schedule this workflow?" - // prompt. "Not now" on it reopens this very chat (the - // session lives on via workflow.source_session_id). - dispatch(openWorkflowCard({ workflowId: wf.id, sourceSessionId: null, view: 'saved', draft: null, showScheduleNudge: true })); + 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={{ diff --git a/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx b/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx index 7e1e576d..8cc8a7d2 100644 --- a/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx +++ b/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx @@ -3,12 +3,10 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Popover from '@mui/material/Popover'; import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded'; -import TuneRoundedIcon from '@mui/icons-material/TuneRounded'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch } from '@/shared/hooks'; import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; -import { openWorkflowCard, updateWorkflow, type Workflow } from '@/shared/state/workflowsSlice'; -import { describeSchedule } from './scheduleUtils'; +import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; interface Props { anchorEl: HTMLElement | null; @@ -16,29 +14,13 @@ interface Props { onClose: () => void; } -// Opens off an Un-scheduled workflow's "+" icon. Two paths: keep the cadence -// the workflow already carries (just flip enabled on) or open the scheduler -// to change it. Enabling moves the row into "Scheduled workflows" since -// isSchedulable keys off schedule.enabled. +// Opens off an Unscheduled workflow's "+" icon. These workflows have no +// real cadence yet, so the only safe action is to create one. export default function AddToSchedulePopover({ anchorEl, workflow, onClose }: Props) { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - // describeSchedule returns "Not scheduled" while disabled; preview the - // cadence as if it were on so "Keep" shows what it would commit to. - const summary = workflow ? describeSchedule({ ...workflow.schedule, enabled: true }) : ''; - - const keep = useCallback(() => { - if (!workflow) return; - dispatch(updateWorkflow({ - id: workflow.id, - patch: { schedule: { ...workflow.schedule, enabled: true } as any }, - ifMatch: workflow.updated_at || null, - })); - onClose(); - }, [dispatch, workflow, onClose]); - - const change = useCallback(() => { + const makeSchedule = useCallback(() => { if (!workflow) return; dispatch(addWorkflowCard({ workflowId: workflow.id })); dispatch(openWorkflowCard({ workflowId: workflow.id, view: 'scheduling' })); @@ -66,20 +48,13 @@ export default function AddToSchedulePopover({ anchorEl, workflow, onClose }: Pr slotProps={{ paper: { sx: { width: 272, p: 1, ml: 0.75 } } }} > - ADD TO SCHEDULE + NEEDS SCHEDULE - + - Keep this schedule - {summary} - - - - - - Change schedule… - Pick a different time + Make a schedule + This workflow does not have a schedule yet. Choose when it should run. diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 348b8b1f..0ac97031 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -10,7 +10,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import type { Workflow } from '@/shared/state/workflowsSlice'; import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice'; import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; -import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, fireTimesWithin, formatTime, formatHourLabel } from './scheduleUtils'; +import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, fireTimesWithin, formatTime, formatHourLabel, isScheduleActive } from './scheduleUtils'; interface Props { view: 'Week' | 'Month' | 'List'; @@ -88,7 +88,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD const end = addDays(start, range - 1); const map = new Map(); for (const wf of workflows) { - if (!wf.schedule.enabled) continue; + if (!isScheduleActive(wf.schedule)) continue; const fires = fireTimesWithin(wf, start, end, 60); for (const d of fires) { const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; @@ -268,7 +268,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD return ( {upcoming.length === 0 && ( - No scheduled workflows + No scheduled )} {upcoming.map(({ date, events, isToday }, rowIdx) => ( = ({ const title = workflow?.title || card?.draft?.title || 'Workflow'; const isDraft = card?.view === 'preview' && !workflow; const steps = (workflow?.steps || card?.draft?.steps || []) as Workflow['steps']; + const [draftCloseNonce, setDraftCloseNonce] = useState(0); // Edit-agent chrome lives in the card header: the model/time subtitle. // EditAgentView owns the live session and reports its id up here so the @@ -363,10 +364,30 @@ const WorkflowCard: React.FC = ({ (e.target as HTMLElement).releasePointerCapture(e.pointerId); }, [computeResize, dispatch, workflowId]); - // X just hides the card. Schedule keeps firing in the background; the - // user can re-open from the Workflows hub. A confirm dialog here was - // more friction than value (users clicked through it without reading). + const discardDraft = useCallback(() => { + const sourceId = card?.sourceSessionId || (card?.draft?.source_session_id as string | undefined) || null; + dispatch(closeWorkflowCard(workflowId)); + dispatch(removeWorkflowCard(workflowId)); + if (sourceId) { + dispatch(placeCard({ + sessionId: sourceId, + x: cardX, + y: cardY, + width: cardWidth, + height: cardHeight, + expandedSessionIds, + })); + dispatch(setPendingFocusAgentId(sourceId)); + } + }, [card?.sourceSessionId, card?.draft, dispatch, workflowId, cardX, cardY, cardWidth, cardHeight, expandedSessionIds]); + + // X hides saved cards, but temporary converted workflow drafts need an + // explicit save/discard choice because they do not exist server-side yet. const onClose = useCallback(() => { + if (isDraft) { + setDraftCloseNonce((n) => n + 1); + return; + } // A 0-step workflow can't run or be scheduled, so a "+ New" card the user // opened and abandoned (without the build agent adding any steps) would // just litter the hub. Delete it on close rather than orphan it. @@ -375,7 +396,7 @@ const WorkflowCard: React.FC = ({ } dispatch(closeWorkflowCard(workflowId)); dispatch(removeWorkflowCard(workflowId)); - }, [dispatch, workflowId, workflow]); + }, [dispatch, workflowId, workflow, isDraft]); // ---- Display calculations ---- const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; @@ -619,15 +640,22 @@ const WorkflowCard: React.FC = ({ steps={steps} sourceSessionId={card.sourceSessionId || null} initialDraft={card.draft || null} - onSaved={(wf) => { + closeRequestNonce={draftCloseNonce} + onDiscardDraft={discardDraft} + onSaved={(wf, options) => { // Migrate transient view state AND layout entry to the // real workflow id so the card stays put visually. dispatch(rekeyOpenCard({ oldId: workflowId, newId: wf.id })); dispatch(rekeyWorkflowCard({ oldId: workflowId, newId: wf.id })); + if (options?.close) { + dispatch(closeWorkflowCard(wf.id)); + dispatch(removeWorkflowCard(wf.id)); + return; + } dispatch(openWorkflowCardAction({ workflowId: wf.id, sourceSessionId: card.sourceSessionId, - view: 'saved', + view: options?.view || 'saved', draft: null, })); }} diff --git a/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx index 9ff9543a..b2c4b362 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx @@ -24,11 +24,23 @@ import { type Workflow, type WorkflowRun, } from '@/shared/state/workflowsSlice'; -import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard } from '@/shared/state/dashboardLayoutSlice'; +import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard, removeCard } from '@/shared/state/dashboardLayoutSlice'; import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; -import { fetchSession } from '@/shared/state/agentsSlice'; +import { fetchSession, closeSession, collapseSession } from '@/shared/state/agentsSlice'; +import type { AppDispatch } from '@/shared/state/store'; import StepList, { type StepStatus } from './StepList'; +// Unlink the sidecar AND close the chat card it opened. closeSession is +// what makes removal stick: a bare removeCard gets re-added by +// reconcileSessions since the run session shares the dashboard. +function stopViewingSidecar(dispatch: AppDispatch, workflowId: string, sessionId: string | null | undefined) { + dispatch(setCardSidecar({ workflowId, sessionId: null, kind: null })); + if (!sessionId) return; + dispatch(collapseSession(sessionId)); + dispatch(removeCard(sessionId)); + void dispatch(closeSession({ sessionId })); +} + // Helper: open a session next to the workflow card AND mark the card as // sidecar-linked so the footer flips to Stop Watching/Viewing and the // dashboard draws an arrow chip between the two cards. @@ -287,8 +299,8 @@ export function CompletedView({ workflow, steps, runs, mode = 'card' }: { if (run?.session_id) void openSidecar(run.session_id, 'viewing-completed'); }, [openSidecar, run?.session_id]); const onStopViewing = useCallback(() => { - dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null })); - }, [dispatch, workflow.id]); + stopViewingSidecar(dispatch, workflow.id, card?.sidecarSessionId); + }, [dispatch, workflow.id, card?.sidecarSessionId]); return ( @@ -391,8 +403,8 @@ export function FailedView({ workflow, steps, runs, mode = 'card' }: { if (run?.session_id) void openSidecar(run.session_id, 'viewing-error'); }, [openSidecar, run?.session_id]); const onStopViewing = useCallback(() => { - dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null })); - }, [dispatch, workflow.id]); + stopViewingSidecar(dispatch, workflow.id, card?.sidecarSessionId); + }, [dispatch, workflow.id, card?.sidecarSessionId]); const onFixWithAgent = useCallback(() => { if (!run) return; const stepLabel = steps[failedIdx]?.label || steps[failedIdx]?.text?.slice(0, 60) || `Step ${failedIdx + 1}`; diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 74f9df0a..8bb86a4a 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -1,9 +1,13 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Popover from '@mui/material/Popover'; import Tooltip from '@mui/material/Tooltip'; import InputBase from '@mui/material/InputBase'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; import HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded'; import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded'; import EditOutlined from '@mui/icons-material/EditOutlined'; @@ -22,6 +26,7 @@ import { placeCard, removeWorkflowCard } from '@/shared/state/dashboardLayoutSli import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals'; import StepList from './StepList'; +import { isScheduleConfigured } from './scheduleUtils'; export function statusColor(s: string, c: ReturnType): string { if (s === 'success') return c.status.success; @@ -96,16 +101,19 @@ export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: str ); } -export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, onSaved }: { +export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, onSaved, onDiscardDraft, closeRequestNonce }: { workflowId: string; steps: Workflow['steps']; sourceSessionId: string | null; initialDraft: Partial | null; - onSaved: (w: Workflow) => void; + onSaved: (w: Workflow, options?: { view?: 'saved' | 'scheduling'; close?: boolean }) => void; + onDiscardDraft?: () => void; + closeRequestNonce?: number; }) { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const [busy, setBusy] = useState(false); + const [savePromptOpen, setSavePromptOpen] = useState(false); // Title + description live in the openCard draft so the parent header // (which renders the inline-editable title) and PreviewView body (which // renders the inline-editable description + steps) stay in sync. On @@ -148,10 +156,10 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...liveDraft, description: value } } })); }, [dispatch, workflowId, liveDraft]); - // Both buttons persist the workflow; the only difference is where they land. - // "Not now" = save and show the saved card. Schedule = save then open the - // natural-language scheduling composer. (The schedule prompt is optional, - // the workflow isn't, so neither button discards anything.) + useEffect(() => { + if (closeRequestNonce) setSavePromptOpen(true); + }, [closeRequestNonce]); + const saveWorkflow = useCallback(async (): Promise => { const result = await dispatch(createWorkflow({ title, @@ -165,26 +173,42 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, mode: defaultMode || (liveDraft.mode as string), } as Partial)); const wf = (result as unknown as { payload: Workflow }).payload; - if (wf?.id) { onSaved(wf); return wf; } + if (wf?.id) return wf; return null; - }, [dispatch, title, description, steps, sourceSessionId, onSaved, liveDraft, defaultModel, defaultMode]); + }, [dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]); const onIgnore = useCallback(async () => { if (busy) return; - setBusy(true); - try { await saveWorkflow(); } finally { setBusy(false); } - }, [busy, saveWorkflow]); + setSavePromptOpen(true); + }, [busy]); const onSaveThenSchedule = useCallback(async () => { if (busy) return; setBusy(true); try { const wf = await saveWorkflow(); - if (wf?.id) dispatch(updateWorkflowCard({ workflowId: wf.id, patch: { view: 'scheduling' } })); + if (wf?.id) onSaved(wf, { view: 'scheduling' }); } finally { setBusy(false); } - }, [busy, saveWorkflow, dispatch]); + }, [busy, saveWorkflow, onSaved]); + + const onSaveDraft = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + const wf = await saveWorkflow(); + if (wf?.id) onSaved(wf, { view: 'saved', close: true }); + } finally { + setBusy(false); + setSavePromptOpen(false); + } + }, [busy, saveWorkflow, onSaved]); + + const onDontSave = useCallback(() => { + setSavePromptOpen(false); + onDiscardDraft?.(); + }, [onDiscardDraft]); void onChangeDescription; return ( @@ -242,6 +266,34 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, Schedule Workflow + setSavePromptOpen(false)} maxWidth="xs" fullWidth> + Save workflow? + + + Save this workflow under Unscheduled. It will not run until you choose a schedule. + + + + + Don't Save + + setSavePromptOpen(false)} + sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.secondary, cursor: busy ? 'wait' : 'pointer', px: 1, py: 0.5, opacity: busy ? 0.6 : 1 }}> + Cancel + + + Save + + + ); } @@ -331,11 +383,12 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo } }, [dispatch, sourceId, sourceExists, wfCardPos, expandedSessionIds, workflow.id]); - const scheduleLine = workflow.schedule.enabled ? describeSchedule(workflow) : 'Schedule this workflow'; - const scheduleClickable = !workflow.schedule.enabled; + const scheduleConfigured = isScheduleConfigured(workflow.schedule); + const scheduleLine = workflow.schedule.enabled && scheduleConfigured ? describeSchedule(workflow) : 'Schedule this workflow'; + const scheduleClickable = !scheduleConfigured; // One-shot prompt right after a convert; hub-opened cards never set the flag, // so they fall straight to the quiet schedule line below. - const showNudge = !!card?.showScheduleNudge && !workflow.schedule.enabled; + const showNudge = !!card?.showScheduleNudge && !scheduleConfigured; return ( diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index b6e13274..996dfbb6 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -28,7 +28,7 @@ import Tooltip from '@mui/material/Tooltip'; import { useEffect } from 'react'; import ScheduleCalendar from './ScheduleCalendar'; import AddToSchedulePopover from './AddToSchedulePopover'; -import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid } from './scheduleUtils'; +import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid, isWorkflowSchedulable } from './scheduleUtils'; type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; @@ -149,8 +149,7 @@ const WorkflowsHubCard: React.FC = ({ // consistent. closeMenu wipes both state + DOM-focus. const [sidebarCtxMenu, setSidebarCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null); const closeSidebarCtxMenu = useCallback(() => setSidebarCtxMenu(null), []); - // Anchored off an Un-scheduled row's "+" icon: offers keep-this-cadence vs - // open-the-scheduler, then enabling moves the row into Scheduled. + // Anchored off an Unscheduled row's "+" icon: opens the scheduler. const [schedulePopover, setSchedulePopover] = useState<{ anchorEl: HTMLElement; workflow: Workflow } | null>(null); const closeSchedulePopover = useCallback(() => setSchedulePopover(null), []); @@ -163,8 +162,8 @@ const WorkflowsHubCard: React.FC = ({ // Hide brand-new "+ New" workflows that the user is still building and // hasn't saved yet; commit (Save) clears `unsaved` and they appear. const saved = useMemo(() => Object.values(workflows).filter((w) => !w.unsaved), [workflows]); - const scheduled = useMemo(() => saved.filter((w) => isSchedulable(w)), [saved]); - const unscheduled = useMemo(() => saved.filter((w) => !isSchedulable(w)), [saved]); + const scheduled = useMemo(() => saved.filter((w) => isWorkflowSchedulable(w)), [saved]); + const unscheduled = useMemo(() => saved.filter((w) => !isWorkflowSchedulable(w)), [saved]); const monthLabel = refDate.toLocaleString('en', { month: 'long', year: 'numeric' }); @@ -501,8 +500,8 @@ 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 })} /> + 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 })} /> )} @@ -524,6 +523,7 @@ const WorkflowsHubCard: React.FC = ({ dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id)); closeSidebarCtxMenu(); }}>Run now + {sidebarCtxMenu && isWorkflowSchedulable(sidebarCtxMenu.workflow) && ( { if (!sidebarCtxMenu) return; const wf = sidebarCtxMenu.workflow; @@ -533,7 +533,8 @@ const WorkflowsHubCard: React.FC = ({ ifMatch: wf.updated_at || null, })); closeSidebarCtxMenu(); - }}>{sidebarCtxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'} + }}>{sidebarCtxMenu.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'} + )} { if (!sidebarCtxMenu) return; dispatch(addWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id })); @@ -552,7 +553,7 @@ const WorkflowsHubCard: React.FC = ({ - {/* "+" on an Un-scheduled row -> keep cadence or open the scheduler */} + {/* "+" on an Unscheduled row -> open the scheduler */} void; scheduled: boolean; onContext: (workflow: Workflow, e: React.MouseEvent) => void; - // Only the Un-scheduled section wires this: clicking the "+" opens the - // add-to-schedule popover anchored to the icon. + // Only the Unscheduled section wires this: clicking the "+" opens the + // schedule creation popover anchored to the icon. onSchedule?: (workflow: Workflow, anchorEl: HTMLElement) => void; }) { const c = useClaudeTokens(); @@ -679,22 +680,16 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule )} - {w.title} + {w.title} + {scheduled && !w.schedule.enabled && ( + Paused + )} ))} ); } -function isSchedulable(w: Workflow): boolean { - if (w.schedule.enabled) return true; - // Heuristic: any prior config means the user already opened the - // Schedule facet and committed something. Pure defaults stay in - // "Un-scheduled" so brand-new workflows don't pollute the list. - const s = w.schedule; - return Boolean(s.on_days?.length || s.ends_at || s.max_runs || s.runs_count); -} - function match(title: string, query: string): boolean { if (!query.trim()) return true; return title.toLowerCase().includes(query.trim().toLowerCase()); diff --git a/frontend/src/app/pages/Workflows/scheduleUtils.ts b/frontend/src/app/pages/Workflows/scheduleUtils.ts index 2bb9d598..51ecea0c 100644 --- a/frontend/src/app/pages/Workflows/scheduleUtils.ts +++ b/frontend/src/app/pages/Workflows/scheduleUtils.ts @@ -25,6 +25,20 @@ export function defaultSchedule(): ScheduleConfig { }; } +export function isScheduleConfigured(sched: ScheduleConfig | null | undefined): boolean { + if (!sched) return false; + if (sched.repeat_unit === 'week') return sched.on_days.length > 0; + return true; +} + +export function isScheduleActive(sched: ScheduleConfig | null | undefined): boolean { + return !!sched?.enabled && isScheduleConfigured(sched); +} + +export function isWorkflowSchedulable(workflow: Workflow): boolean { + return isScheduleConfigured(workflow.schedule); +} + export function formatTime(hour: number, minute: number): string { const h12 = ((hour + 11) % 12) + 1; const suffix = hour < 12 ? 'am' : 'pm'; @@ -41,7 +55,7 @@ export function formatHourLabel(hour: number): string { } export function describeSchedule(sched: ScheduleConfig): string { - if (!sched.enabled) return 'Not scheduled'; + if (!sched.enabled || !isScheduleConfigured(sched)) return 'Not scheduled'; const time = formatTime(sched.hour, sched.minute); if (sched.repeat_unit === 'minute') { return `Every ${sched.repeat_every} minutes`; @@ -109,7 +123,7 @@ function lastDayOfMonth(year: number, monthZeroBased: number): number { export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = 40): Date[] { const sched = workflow.schedule; - if (!sched.enabled) return []; + if (!isScheduleActive(sched)) return []; // Honor end conditions on the FE preview too, so the calendar doesn't // paint pills for fires the backend will refuse to run. ends_at is an // ISO string in workflow state; max_runs/runs_count are numbers. @@ -185,7 +199,8 @@ export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = return out; } - const allowed = sched.on_days.length ? sched.on_days : [from.getDay()]; + const allowed = sched.on_days; + if (allowed.length === 0) return []; for (let i = 0; i < 60 && out.length < effectiveCap; i += 1) { const day = new Date(cursor); day.setDate(day.getDate() + i); diff --git a/frontend/src/app/pages/Workflows/workflowVisuals.tsx b/frontend/src/app/pages/Workflows/workflowVisuals.tsx index 07f933d5..c2e1d808 100644 --- a/frontend/src/app/pages/Workflows/workflowVisuals.tsx +++ b/frontend/src/app/pages/Workflows/workflowVisuals.tsx @@ -29,7 +29,7 @@ import CodeIcon from '@mui/icons-material/CodeRounded'; import SearchIcon from '@mui/icons-material/SearchRounded'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import type { Workflow, WorkflowRun, ScheduleConfig, PermissionTier } from '@/shared/state/workflowsSlice'; -import { formatTime, WEEKDAY_LABEL } from './scheduleUtils'; +import { formatTime, WEEKDAY_LABEL, isScheduleConfigured } from './scheduleUtils'; // ---------- Status colors ---------- @@ -82,7 +82,7 @@ export function StatusDot({ status }: { status: LastRunStatus | null | undefined // ---------- Pill chips ---------- function scheduleShort(sched: ScheduleConfig): string { - if (!sched.enabled) return 'Not scheduled'; + if (!sched.enabled || !isScheduleConfigured(sched)) return 'Not scheduled'; const time = formatTime(sched.hour, sched.minute); if (sched.repeat_unit === 'minute') return `Every ${sched.repeat_every}m`; if (sched.repeat_unit === 'hour') return sched.repeat_every === 1 ? 'Hourly' : `Every ${sched.repeat_every}h`; @@ -98,7 +98,6 @@ function scheduleShort(sched: ScheduleConfig): string { const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; return `${labels[sched.on_days[0]]} ${time}`; } - if (sched.on_days.length === 0) return `Weekly ${time}`; return `${sched.on_days.length}×/wk ${time}`; } @@ -170,7 +169,7 @@ export function PermissionChip({ workflow }: { workflow: Workflow }) { export function ScheduleChip({ workflow }: { workflow: Workflow }) { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const enabled = workflow.schedule.enabled; + const enabled = workflow.schedule.enabled && isScheduleConfigured(workflow.schedule); const [anchor, setAnchor] = useState(null); // Inline edit: time + AM/PM only. Anything richer should open the // full editor. Saves on change with optimistic updated_at If-Match. diff --git a/frontend/src/shared/mcpToolMeta.ts b/frontend/src/shared/mcpToolMeta.ts index 0bb911d8..89b6f122 100644 --- a/frontend/src/shared/mcpToolMeta.ts +++ b/frontend/src/shared/mcpToolMeta.ts @@ -26,8 +26,149 @@ export function parseMcpToolName(rawName: string): McpToolInfo { return { isMcp: true, serverSlug, action, service, displayName: display }; } -export function getMcpInputSummary(input: any): string { +function formatTime(hour: unknown, minute: unknown): string { + const h = typeof hour === 'number' ? hour : Number(hour); + const m = typeof minute === 'number' ? minute : Number(minute || 0); + if (!Number.isFinite(h)) return ''; + const h12 = ((h + 11) % 12) + 1; + const suffix = h < 12 ? 'am' : 'pm'; + return Number.isFinite(m) && m > 0 ? `${h12}:${String(m).padStart(2, '0')}${suffix}` : `${h12}${suffix}`; +} + +function weekdayLabel(day: number): string { + return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][day] || ''; +} + +function compactWorkflowSchedule(input: any): string { if (!input || typeof input !== 'object') return ''; + const enabled = input.schedule_enabled ?? input.enabled; + if (enabled === false) return 'Turn schedule off'; + + const unit = input.repeat_unit || input.unit; + const every = Math.max(1, Number(input.repeat_every || 1)); + const time = formatTime(input.hour, input.minute); + const days = Array.isArray(input.on_days) ? input.on_days.filter((d: any) => Number.isInteger(d) && d >= 0 && d <= 6) : []; + + if (unit === 'minute') return `Run every ${Math.max(15, Number(input.repeat_every || 15))} minutes`; + if (unit === 'hour') { + const minute = Number(input.minute || 0); + const suffix = minute > 0 ? ` at :${String(minute).padStart(2, '0')}` : ''; + return every === 1 ? `Run every hour${suffix}` : `Run every ${every} hours${suffix}`; + } + if (unit === 'day') return `Run ${every === 1 ? 'daily' : `every ${every} days`}${time ? ` at ${time}` : ''}`; + if (unit === 'month') return `Run ${every === 1 ? 'monthly' : `every ${every} months`}${time ? ` at ${time}` : ''}`; + if (unit === 'week') { + const dayText = days.length === 1 + ? weekdayLabel(days[0]) + : days.length === 5 && [1, 2, 3, 4, 5].every((d) => days.includes(d)) + ? 'weekdays' + : days.length > 1 + ? days.map(weekdayLabel).filter(Boolean).join(', ') + : ''; + if (!dayText) return time ? `Choose weekly days at ${time}` : 'Choose weekly days'; + return `Run ${every === 1 ? `every ${dayText}` : `every ${every} weeks on ${dayText}`}${time ? ` at ${time}` : ''}`; + } + if (input.title) return `Update "${input.title}"`; + return ''; +} + +function firstText(input: any, keys: string[]): string { + for (const key of keys) { + const value = input?.[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +function stepNumber(input: any): string { + const raw = input?.step_idx ?? input?.step_index ?? input?.index; + const idx = typeof raw === 'number' ? raw : Number(raw); + return Number.isInteger(idx) && idx >= 0 ? String(idx + 1) : ''; +} + +function compactWorkflowAction(input: any, action: string): string { + const lower = action.toLowerCase(); + const step = stepNumber(input); + const text = firstText(input, ['new_text', 'text', 'prompt', 'description']); + const label = firstText(input, ['new_label', 'label', 'title', 'name']); + const preview = text || label; + const shortPreview = preview.length > 80 ? preview.slice(0, 77) + '...' : preview; + + if (lower === 'addworkflowstep') return shortPreview ? `Add step: ${shortPreview}` : 'Add a workflow step'; + if (lower === 'editworkflowstep') return step ? `Edit step ${step}` : 'Edit workflow step'; + if (lower === 'deleteworkflowstep') return step ? `Delete step ${step}` : 'Delete workflow step'; + if (lower === 'runworkflow') return 'Run this workflow now'; + if (lower === 'testworkflow') return 'Test this workflow'; + if (lower === 'readtesttranscript') return 'Read latest test results'; + if (lower === 'deletescheduledworkflow') return 'Delete this workflow'; + if (lower === 'pauseallworkflows') return 'Pause all scheduled workflows'; + if (lower === 'resumeallworkflows') return 'Resume scheduled workflows'; + if (lower === 'listworkflows') return 'Show workflows'; + return ''; +} + +export function getWorkflowToolLabel(action: string): string | null { + const lower = action.toLowerCase(); + if (lower === 'scheduleworkflow') return 'Schedule workflow'; + if (lower === 'updatescheduledworkflow') return 'Update schedule'; + if (lower === 'deletescheduledworkflow') return 'Delete workflow'; + if (lower === 'pauseallworkflows') return 'Pause workflows'; + if (lower === 'resumeallworkflows') return 'Resume workflows'; + if (lower === 'runworkflow') return 'Run workflow'; + if (lower === 'editworkflowstep') return 'Edit workflow step'; + if (lower === 'addworkflowstep') return 'Add workflow step'; + if (lower === 'deleteworkflowstep') return 'Delete workflow step'; + if (lower === 'testworkflow') return 'Test workflow'; + if (lower === 'readtesttranscript') return 'Read test results'; + if (lower === 'listworkflows') return 'List workflows'; + return null; +} + +export function getWorkflowToolInputDisplay(input: any, action?: string, serverSlug?: string): string { + if (!input || typeof input !== 'object') return ''; + const isWorkflowTool = serverSlug === 'openswarm-schedule' || (action && getWorkflowToolLabel(action)); + if (!isWorkflowTool || !action) return ''; + + const lower = action.toLowerCase(); + const lines: string[] = []; + const schedule = compactWorkflowSchedule(input); + const step = stepNumber(input); + const text = firstText(input, ['new_text', 'text', 'prompt', 'description']); + const label = firstText(input, ['new_label', 'label', 'title', 'name']); + + if (lower === 'scheduleworkflow' || lower === 'updatescheduledworkflow') { + if (schedule) lines.push(`Schedule: ${schedule}`); + if (label) lines.push(`Name: ${label}`); + return lines.join('\n') || getWorkflowToolLabel(action) || 'Workflow action'; + } + + if (lower === 'addworkflowstep') { + if (text) lines.push(`Step: ${text}`); + if (label && label !== text) lines.push(`Label: ${label}`); + return lines.join('\n') || compactWorkflowAction(input, action); + } + + if (lower === 'editworkflowstep') { + if (step) lines.push(`Step: ${step}`); + if (text) lines.push(`Prompt: ${text}`); + if (label && label !== text) lines.push(`Label: ${label}`); + return lines.join('\n') || compactWorkflowAction(input, action); + } + + return compactWorkflowAction(input, action) || getWorkflowToolLabel(action) || 'Workflow action'; +} + +export function getMcpInputSummary(input: any, action?: string, serverSlug?: string): string { + if (!input || typeof input !== 'object') return ''; + if (serverSlug === 'openswarm-schedule' || (action && getWorkflowToolLabel(action))) { + const workflowSummary = compactWorkflowSchedule(input); + if (workflowSummary) return workflowSummary; + if (action) { + const actionSummary = compactWorkflowAction(input, action); + if (actionSummary) return actionSummary; + } + return ''; + } const keys = Object.keys(input); if (keys.length === 0) return ''; if (keys.length === 1) {