From 0bbc5c894b2c9019cfb19008df04ce87c2c434d8 Mon Sep 17 00:00:00 2001 From: abccodes Date: Wed, 17 Jun 2026 17:39:07 -0700 Subject: [PATCH] [aidan] fix/workflow-scheduling: save unscheduled workflows as drafts --- backend/apps/workflows/scheduler.py | 17 +++- backend/apps/workflows/workflows.py | 16 ++-- backend/tests/test_workflows_semantics.py | 38 ++++++++ .../app/pages/Dashboard/cards/AgentCard.tsx | 54 +++++------- .../pages/Workflows/AddToSchedulePopover.tsx | 41 ++------- .../app/pages/Workflows/ScheduleCalendar.tsx | 4 +- .../src/app/pages/Workflows/ScheduleFacet.tsx | 4 +- .../src/app/pages/Workflows/WorkflowCard.tsx | 40 +++++++-- .../pages/Workflows/WorkflowCardSubviews.tsx | 87 +++++++++++++++---- .../app/pages/Workflows/WorkflowsHubCard.tsx | 32 +++---- .../src/app/pages/Workflows/scheduleUtils.ts | 21 ++++- .../app/pages/Workflows/workflowVisuals.tsx | 7 +- 12 files changed, 236 insertions(+), 125 deletions(-) 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 2ec39d92..06219754 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, @@ -173,8 +179,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 @@ -505,6 +510,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: @@ -524,7 +530,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() @@ -689,7 +695,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/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..e9b343be 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 a Needs Schedule 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..418063b5 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()}`; diff --git a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx index a1668c70..1068b058 100644 --- a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx @@ -14,7 +14,7 @@ import NotificationsIcon from '@mui/icons-material/NotificationsNoneRounded'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchCloudSmsStatus, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice'; -import { WEEKDAY_LABEL, formatTime } from './scheduleUtils'; +import { WEEKDAY_LABEL, formatTime, isScheduleActive } from './scheduleUtils'; import { nextTierAfter } from './permissionsUtils'; import { BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon'; @@ -51,7 +51,7 @@ function lastDayOfMonthFE(year: number, monthZeroBased: number): number { // clock. Honors ends_at + max_runs so the "Next run" line doesn't lie // after the schedule has expired. function previewNextRun(sched: ScheduleConfig): Date | null { - if (!sched.enabled) return null; + if (!isScheduleActive(sched)) return null; const now = new Date(); if (sched.ends_at) { const ends = new Date(sched.ends_at); diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index d099f0b2..3ec65f6b 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -195,6 +195,7 @@ const WorkflowCard: React.FC = ({ 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 and // the Save Workflow button. EditAgentView owns the live session and reports @@ -367,10 +368,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. @@ -379,7 +400,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; @@ -652,15 +673,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/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 74f9df0a..efa418a7 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 Needs Schedule. 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 33199f8b..26d05be3 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 a Needs Schedule row's "+" icon: opens the scheduler. const [schedulePopover, setSchedulePopover] = useState<{ anchorEl: HTMLElement; workflow: Workflow } | null>(null); const closeSchedulePopover = useCallback(() => setSchedulePopover(null), []); @@ -160,8 +159,8 @@ const WorkflowsHubCard: React.FC = ({ // unticked the box, which feels wrong. on_days/hour/minute being set // is a good proxy for "user already configured this." Falls back to // enabled flag for legacy records. - const scheduled = useMemo(() => Object.values(workflows).filter((w) => isSchedulable(w)), [workflows]); - const unscheduled = useMemo(() => Object.values(workflows).filter((w) => !isSchedulable(w)), [workflows]); + const scheduled = useMemo(() => Object.values(workflows).filter((w) => isWorkflowSchedulable(w)), [workflows]); + const unscheduled = useMemo(() => Object.values(workflows).filter((w) => !isWorkflowSchedulable(w)), [workflows]); const monthLabel = refDate.toLocaleString('en', { month: 'long', year: 'numeric' }); @@ -498,8 +497,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 })} /> )} @@ -521,6 +520,7 @@ const WorkflowsHubCard: React.FC = ({ dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id)); closeSidebarCtxMenu(); }}>Run now + {sidebarCtxMenu && isWorkflowSchedulable(sidebarCtxMenu.workflow) && ( { if (!sidebarCtxMenu) return; const wf = sidebarCtxMenu.workflow; @@ -530,7 +530,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 })); @@ -549,7 +550,7 @@ const WorkflowsHubCard: React.FC = ({ - {/* "+" on an Un-scheduled row -> keep cadence or open the scheduler */} + {/* "+" on a Needs Schedule 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 Needs Schedule section wires this: clicking the "+" opens the + // schedule creation popover anchored to the icon. onSchedule?: (workflow: Workflow, anchorEl: HTMLElement) => void; }) { const c = useClaudeTokens(); @@ -683,15 +684,6 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule ); } -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.