Merge remote-tracking branch 'origin/aidan/feat/scheduled-tasks' into aidan/feat/workflow-auto-naming

# Conflicts:
#	frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
This commit is contained in:
abccodes
2026-06-17 19:29:47 -07:00
20 changed files with 472 additions and 166 deletions
+15 -2
View File
@@ -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
+11 -5
View File
@@ -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"))
+38
View File
@@ -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():
@@ -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<string, number> = {
'opus-4-8': 1_000_000,
@@ -1285,7 +1286,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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
@@ -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 || '';
@@ -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, any>): 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<Props> = ({ 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<Props> = ({ request, onApprove, onDeny }) =>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.9rem' }}>
{parsed.displayName}
{displayName}
</Typography>
<Chip
label={meta.serverLabel || parsed.serverSlug}
@@ -794,7 +807,6 @@ const GenericApprovalBar: React.FC<Props> = ({ 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<Props> = ({ request, onApprove, onDeny }) =>
<Collapse in={detailsExpanded || !summary}>
<Box sx={{ mt: summary ? 0.75 : 0 }}>
<CodeBlock tokens={c}>
{JSON.stringify(request.tool_input, null, 2)}
{detailText}
</CodeBlock>
</Box>
</Collapse>
@@ -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<CompactMcpBubbleProps> = ({
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
? <GoogleServiceIcon service={mcpInfo.service} size={14} />
: null;
@@ -60,16 +65,16 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
return (
<Box {...selectAttrs} sx={{ my: 0 }}>
<Box
onClick={toggle}
onClick={canToggleDetails ? toggle : undefined}
sx={{
display: 'flex',
alignItems: showBody ? 'flex-start' : 'center',
alignItems: showBody && canToggleDetails ? 'flex-start' : 'center',
gap: 0.75,
px: 1.5,
py: 0.6,
cursor: 'pointer',
borderBottom: showBody ? `1px solid ${c.border.subtle}` : 'none',
'&:hover': { bgcolor: 'rgba(0,0,0,0.02)' },
cursor: canToggleDetails ? 'pointer' : 'default',
borderBottom: showBody && canToggleDetails ? `1px solid ${c.border.subtle}` : 'none',
'&:hover': canToggleDetails ? { bgcolor: 'rgba(0,0,0,0.02)' } : undefined,
}}
>
{ServiceIcon}
@@ -83,22 +88,22 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
>
{serviceLabel}
</Typography>
{resultSummary && !isError && (
{visibleSummary && !isError && (
<Typography
sx={{
color: c.text.secondary,
fontSize: '0.74rem',
flex: 1,
minWidth: 0,
...(showBody
...(showBody && canToggleDetails
? { whiteSpace: 'normal', wordBreak: 'break-word' }
: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
}}
>
{resultSummary}
{visibleSummary}
</Typography>
)}
{!resultSummary && !showTimer && <Box sx={{ flex: 1 }} />}
{!visibleSummary && !showTimer && <Box sx={{ flex: 1 }} />}
{showTimer && (
<>
<Box sx={{ flex: 1 }} />
@@ -123,12 +128,14 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
)}
</Box>
)}
{canToggleDetails && (
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15, flexShrink: 0 }}>
{showBody ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</IconButton>
)}
</Box>
<Collapse in={showBody}>
<Collapse in={showBody && canToggleDetails}>
<Box
sx={{
bgcolor: tc.TERM_BG,
@@ -61,6 +61,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
// 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 (
<Box
@@ -89,16 +90,16 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
} as any}
>
<Box
onClick={toggle}
onClick={canToggleDetails ? toggle : undefined}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: mcpCompact ? 0.6 : 0.75,
cursor: isStreaming ? 'default' : 'pointer',
borderBottom: mcpCompact && showBody ? `1px solid ${c.border.subtle}` : 'none',
'&:hover': isStreaming ? {} : { bgcolor: 'rgba(0,0,0,0.02)' },
cursor: canToggleDetails ? 'pointer' : 'default',
borderBottom: mcpCompact && showBody && canToggleDetails ? `1px solid ${c.border.subtle}` : 'none',
'&:hover': canToggleDetails ? { bgcolor: 'rgba(0,0,0,0.02)' } : {},
}}
>
{mcpInfo.isMcp && mcpInfo.service
@@ -186,7 +187,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
)}
{showTimer && <ElapsedTimer startTime={call.timestamp} />}
{!isStreaming && (
{canToggleDetails && (
<IconButton size="small" sx={{ color: c.text.tertiary, p: mcpCompact ? 0.15 : 0.25, flexShrink: 0 }}>
{showBody ? (
<ExpandLessIcon sx={{ fontSize: mcpCompact ? 16 : 18 }} />
@@ -197,7 +198,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
)}
</Box>
<Collapse in={showBody}>
<Collapse in={showBody && canToggleDetails}>
<Box
sx={{
bgcolor: tc.TERM_BG,
@@ -10,6 +10,7 @@ import { AgentMessage, ToolGroupMeta } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useMountReveal } from './useMountReveal';
import { sanitizeSvgString } from '@/shared/sanitizeSvg';
import { parseMcpToolName, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
import ToolCallBubble, { ToolPair } from './ToolCallBubble';
export interface ToolGroup {
@@ -83,13 +84,21 @@ const ToolGroupBubble: React.FC<Props> = 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 (
<Box
@@ -116,15 +125,15 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
}}
>
<Box
onClick={() => 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<Props> = React.memo(({ group, isSessionRunning =
{completedCount}/{group.callCount}
</Typography>
)}
{canToggleGroup && (
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15 }}>
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</IconButton>
)}
</Box>
<Collapse in={expanded}>
@@ -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 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 } } }}
>
<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()}`;
@@ -268,7 +268,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
return (
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
{upcoming.length === 0 && (
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled workflows</Typography>
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled</Typography>
)}
{upcoming.map(({ date, events, isToday }, rowIdx) => (
<Box
@@ -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);
@@ -203,6 +203,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.
// EditAgentView owns the live session and reports its id up here so the
@@ -363,10 +364,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.
@@ -375,7 +396,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;
@@ -619,15 +640,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,
}));
}}
@@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
@@ -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}`;
@@ -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 Unscheduled. 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 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<Props> = ({
// 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<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="Unscheduled" 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>
)}
@@ -524,6 +523,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;
@@ -533,7 +533,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 }));
@@ -552,7 +553,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
</MenuItem>
</Menu>
{/* "+" on an Un-scheduled row -> keep cadence or open the scheduler */}
{/* "+" on an Unscheduled row -> open the scheduler */}
<AddToSchedulePopover
anchorEl={schedulePopover?.anchorEl ?? null}
workflow={schedulePopover?.workflow ?? null}
@@ -612,8 +613,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 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
</Box>
</Tooltip>
)}
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: scheduled && !w.schedule.enabled ? 'line-through' : 'none', opacity: scheduled && !w.schedule.enabled ? 0.6 : 1 }}>{w.title}</Typography>
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: scheduled && !w.schedule.enabled ? 0.7 : 1 }}>{w.title}</Typography>
{scheduled && !w.schedule.enabled && (
<Box sx={{ flexShrink: 0, px: 0.6, py: 0.1, borderRadius: '3px', bgcolor: c.bg.elevated, color: c.text.muted, fontSize: '0.62rem', fontWeight: 600, lineHeight: 1.5, letterSpacing: '0.02em' }}>Paused</Box>
)}
</Box>
))}
</Box>
);
}
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.
+142 -1
View File
@@ -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) {