mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 20:57:42 +02:00
[aidan] fix/workflow-scheduling: save unscheduled workflows as drafts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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<Props> = ({
|
||||
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
|
||||
<Box
|
||||
role="button"
|
||||
onClick={async (e) => {
|
||||
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<Workflow>));
|
||||
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<Workflow>,
|
||||
}));
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
|
||||
@@ -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 } } }}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', px: 0.75, mb: 0.5 }}>
|
||||
ADD TO SCHEDULE
|
||||
NEEDS SCHEDULE
|
||||
</Typography>
|
||||
<Box role="button" onClick={keep} sx={rowSx}>
|
||||
<Box role="button" onClick={makeSchedule} sx={rowSx}>
|
||||
<Box sx={iconSx}><CalendarMonthRounded sx={{ fontSize: 16 }} /></Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary }}>Keep this schedule</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{summary}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box role="button" onClick={change} sx={rowSx}>
|
||||
<Box sx={iconSx}><TuneRoundedIcon sx={{ fontSize: 16 }} /></Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary }}>Change schedule…</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Pick a different time</Typography>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary }}>Make a schedule</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>This workflow does not have a schedule yet. Choose when it should run.</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
|
||||
@@ -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<string, { workflow: Workflow; date: Date }[]>();
|
||||
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()}`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -195,6 +195,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
(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<Props> = ({
|
||||
}
|
||||
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<Props> = ({
|
||||
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,
|
||||
}));
|
||||
}}
|
||||
|
||||
@@ -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<typeof useClaudeTokens>): 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<Workflow> | 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<Workflow | null> => {
|
||||
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<Workflow>));
|
||||
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
|
||||
</Box>
|
||||
</Box>
|
||||
<Dialog open={savePromptOpen} onClose={() => setSavePromptOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700 }}>Save workflow?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ fontSize: '0.86rem', color: c.text.secondary }}>
|
||||
Save this workflow under Needs Schedule. It will not run until you choose a schedule.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onDontSave}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.status.error, cursor: busy ? 'wait' : 'pointer', px: 1, py: 0.5, opacity: busy ? 0.6 : 1 }}>
|
||||
Don't Save
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={() => 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
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onSaveDraft}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: busy ? 'wait' : 'pointer', px: 1.5, py: 0.6, opacity: busy ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
Save
|
||||
</Box>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
|
||||
@@ -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<Props> = ({
|
||||
// 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<Props> = ({
|
||||
// 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<Props> = ({
|
||||
</Box>
|
||||
<MiniMonth refDate={refDate} onPick={setRefDate} />
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, pb: 1.5 }}>
|
||||
<SidebarSection title="Scheduled workflows" items={scheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
|
||||
<SidebarSection title="Un-scheduled workflows" items={unscheduled.filter((w) => 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 })} />
|
||||
<SidebarSection title="Scheduled" items={scheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
|
||||
<SidebarSection title="Needs Schedule" items={unscheduled.filter((w) => 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 })} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
@@ -521,6 +520,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id));
|
||||
closeSidebarCtxMenu();
|
||||
}}>Run now</MenuItem>
|
||||
{sidebarCtxMenu && isWorkflowSchedulable(sidebarCtxMenu.workflow) && (
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
const wf = sidebarCtxMenu.workflow;
|
||||
@@ -530,7 +530,8 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
closeSidebarCtxMenu();
|
||||
}}>{sidebarCtxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
|
||||
}}>{sidebarCtxMenu.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
dispatch(addWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id }));
|
||||
@@ -549,7 +550,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* "+" on an Un-scheduled row -> keep cadence or open the scheduler */}
|
||||
{/* "+" on a Needs Schedule row -> open the scheduler */}
|
||||
<AddToSchedulePopover
|
||||
anchorEl={schedulePopover?.anchorEl ?? null}
|
||||
workflow={schedulePopover?.workflow ?? null}
|
||||
@@ -609,8 +610,8 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule
|
||||
onPick: (id: string) => 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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
// Inline edit: time + AM/PM only. Anything richer should open the
|
||||
// full editor. Saves on change with optimistic updated_at If-Match.
|
||||
|
||||
Reference in New Issue
Block a user