diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py
index 0060711e..125ec2c8 100644
--- a/backend/apps/agents/agent_manager.py
+++ b/backend/apps/agents/agent_manager.py
@@ -660,6 +660,13 @@ class AgentManager:
"`mcp__*__authenticate` helpers; those are legacy shims; always go "
"through MCPActivate."
)
+ sections.append(
+ "1a. NEVER call any tool whose name begins with `mcp__claude_ai_` "
+ "(claude.ai-connected partner shims). They bypass the OpenSwarm "
+ "gate and don't share auth with this app. If the user wants Gmail/"
+ "Calendar/Drive, the equivalent OpenSwarm server is listed below; "
+ "activate that one via MCPActivate instead."
+ )
sections.append(
"2. After MCPActivate returns, end the turn; a follow-up turn fires "
"automatically with the new tools available."
@@ -1629,6 +1636,27 @@ class AgentManager:
)
composed_prompt = (composed_prompt + "\n\n" + schedule_ctx) if composed_prompt else schedule_ctx
+ # Pin the agent's notion of "now" to the host wall clock + zone
+ # so it can answer day-of-week questions, choose sensible
+ # cadences ("every Friday afternoon"), and avoid hallucinated
+ # dates. Location stays out of scope; only timezone is shared.
+ try:
+ from zoneinfo import ZoneInfo
+ from backend.apps.workflows.storage import _resolve_host_tz_name
+ tz_name = _resolve_host_tz_name()
+ now_local = datetime.now(ZoneInfo(tz_name))
+ tz_abbr = now_local.strftime("%Z") or tz_name
+ time_ctx = (
+ "\n"
+ f"Today is {now_local.strftime('%A, %B %-d, %Y')}.\n"
+ f"Local time: {now_local.strftime('%-I:%M %p')} {tz_abbr} ({tz_name}).\n"
+ "Use this as ground truth for any date/time/day-of-week question.\n"
+ ""
+ )
+ composed_prompt = (composed_prompt + "\n\n" + time_ctx) if composed_prompt else time_ctx
+ except Exception:
+ pass
+
if session.mode == "view-builder":
# Read the LIVE skill content rather than a frozen-at-import
# constant. The skill is registered as a built-in skill at
@@ -2270,6 +2298,17 @@ class AgentManager:
if session.max_turns:
options_kwargs["max_turns"] = session.max_turns
+ # The claude_code preset auto-attaches the user's claude.ai-
+ # connected partner MCPs (`mcp__claude_ai_*`). Those bypass our
+ # MCPActivate gate, don't share OAuth state with the OpenSwarm
+ # Gmail/Calendar/Drive connectors the user actually configured
+ # here, and confuse the model into picking the partner shim
+ # instead of our vetted server. Hard-block them at the SDK
+ # layer so the model can't even attempt the call.
+ options_kwargs["disallowed_tools"] = [
+ "mcp__claude_ai_*",
+ ]
+
if session.cwd:
# Pre-existing sessions may have workspaces that predate
# the git-init block in launch_agent, leaving them
diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py
index e20c98ad..43d9b1bd 100644
--- a/backend/apps/workflows/workflows.py
+++ b/backend/apps/workflows/workflows.py
@@ -138,11 +138,65 @@ async def create_workflow(body: WorkflowCreate):
wf.icon = _derive_icon(wf)
if wf.schedule.enabled:
wf.next_run_at = scheduler.compute_next_fire(wf)
+ # AI-generated description when the caller didn't supply one. Best-
+ # effort via the user's configured aux model; on failure we leave
+ # description empty so the UI just hides the row rather than showing
+ # a fake placeholder. Doesn't block create — caller gets the workflow
+ # back, and a background task fills the description in seconds.
+ if not (wf.description or "").strip():
+ try:
+ wf.description = await _generate_description(wf)
+ except Exception:
+ pass
storage.save_workflow(wf)
scheduler.kick()
return _enriched(wf)
+async def _generate_description(wf: Workflow) -> str:
+ """One aux-model call: summarize steps into a one-paragraph blurb.
+
+ Returns "" on any failure so the caller can write the result back
+ unconditionally. Never raises.
+ """
+ if not wf.steps:
+ return ""
+ try:
+ from backend.apps.agents.providers.registry import resolve_aux_model
+ from backend.apps.agents.providers.registry import get_anthropic_client_for_model
+ from backend.apps.settings.settings import load_settings as _ls
+ except Exception:
+ return ""
+ settings = _ls()
+ try:
+ aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
+ client = get_anthropic_client_for_model(settings, aux_model)
+ except Exception:
+ return ""
+ steps_lines = "\n".join(f"{i+1}. {s.text}" for i, s in enumerate(wf.steps) if s.text)
+ prompt = (
+ "Write one short paragraph (2-3 sentences, under 50 words) that "
+ "describes what this workflow does, in plain English. No bullet "
+ "points, no preamble like 'This workflow...'. Just the description.\n\n"
+ f"Title: {wf.title}\n\n"
+ f"Steps:\n{steps_lines}"
+ )
+ try:
+ resp = await client.messages.create(
+ model=aux_model,
+ max_tokens=160,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ text = ""
+ if isinstance(resp.content, list):
+ for block in resp.content:
+ if getattr(block, "type", None) == "text":
+ text += getattr(block, "text", "")
+ return text.strip()[:500]
+ except Exception:
+ return ""
+
+
def _last_run_cost(wid: str) -> float:
for r in storage.list_runs(wid, limit=10):
if r.status in ("success", "ran_late") and r.cost_usd:
diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx
index bc70333e..fb5c1a1d 100644
--- a/frontend/src/app/pages/AgentChat/AgentChat.tsx
+++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx
@@ -204,20 +204,25 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
const [suggestDismissedFor, setSuggestDismissedFor] = useState(null);
const scheduleSuggestion = useMemo(() => {
if (!session?.messages || session.messages.length === 0) return null;
- // Find the most recent terminal assistant message.
- let lastAssistant: typeof session.messages[number] | null = null;
+ // The asker is the user, not the agent. Parsing the assistant reply
+ // means hour-shaped numbers in any list ("3 new messages", "May 16",
+ // "$50 offer") get misread as the schedule hour. Use the most recent
+ // user prompt as the source of truth, fall back to the assistant
+ // only if no user message has time-words.
+ let chosen: typeof session.messages[number] | null = null;
for (let i = session.messages.length - 1; i >= 0; i--) {
const m = session.messages[i];
- if (m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()) {
- lastAssistant = m; break;
+ if (m.role === 'user' && typeof m.content === 'string' && m.content.trim()) {
+ const det = detectSchedule(m.content);
+ if (det) { chosen = m; break; }
}
}
- if (!lastAssistant) return null;
- if (suggestDismissedFor === lastAssistant.id) return null;
- const text = typeof lastAssistant.content === 'string' ? lastAssistant.content : '';
+ if (!chosen) return null;
+ if (suggestDismissedFor === chosen.id) return null;
+ const text = typeof chosen.content === 'string' ? chosen.content : '';
const detected = detectSchedule(text);
if (!detected) return null;
- return { messageId: lastAssistant.id, ...detected };
+ return { messageId: chosen.id, ...detected };
}, [session?.messages, suggestDismissedFor]);
const [showScrollButton, setShowScrollButton] = useState(false);
const [showResumeBubble, setShowResumeBubble] = useState(false);
diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx
index ded22497..18bc70d4 100644
--- a/frontend/src/app/pages/Dashboard/AgentCard.tsx
+++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx
@@ -933,7 +933,7 @@ const AgentCard: React.FC = ({
if (steps.length === 0) return;
const draft: Partial = {
title: session.name || 'New workflow',
- description: 'Auto-generated from this chat. Edit anytime in Workflows.',
+ description: '',
steps,
source_session_id: session.id,
dashboard_id: session.dashboard_id || null,
diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx
index 509c2eff..3a9071f6 100644
--- a/frontend/src/app/pages/Dashboard/Dashboard.tsx
+++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx
@@ -1694,6 +1694,13 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true
const hasReal = wc.workflow_id in workflowItems;
const hasDraft = wc.workflow_id in workflowOpenCards;
if (!hasReal && !hasDraft) continue;
+ // The "Make workflow" tether is a draft-time affordance: it shows
+ // the user which chat the new workflow card came out of. Once the
+ // workflow is saved (openCard transitions to 'saved' view), the
+ // user has committed and the visual link can retire. Per user
+ // feedback on image #70.
+ const openCard = workflowOpenCards[wc.workflow_id];
+ if (openCard && openCard.view !== 'preview') continue;
let srcX = src.x, srcY = src.y;
let dstX = wc.x, dstY = wc.y;
@@ -1838,7 +1845,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true
}}
/>
- {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
+ {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub ? (
= ({ dashboardId, isActive = true
markerHeight="10"
orient="auto"
>
-
+
{tethers.map((t) => (
@@ -1937,7 +1944,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
- opacity={0.8}
markerEnd="url(#tether-arrow)"
/>
{t.label && (
diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx
index 1ee58878..55aaa01f 100644
--- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx
+++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx
@@ -10,7 +10,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
-import { WEEKDAY_LABEL, 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 } from './scheduleUtils';
interface Props {
view: 'Week' | 'Month' | 'List';
@@ -99,52 +99,59 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflows, view, dayKey]);
- const SLOT_H = compact ? 28 : 36;
- const ROW_LABEL = compact ? '0.72rem' : '0.78rem';
- const DAY_NUM = compact ? '0.85rem' : '0.95rem';
- const DAY_LABEL = compact ? '0.7rem' : '0.78rem';
- const EVENT_FS = compact ? '0.72rem' : '0.82rem';
+ const SLOT_H = compact ? 32 : 44;
+ const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
+ const DAY_NUM = compact ? '0.95rem' : '1.15rem';
+ const DAY_LABEL = compact ? '0.66rem' : '0.72rem';
+ const EVENT_FS = compact ? '0.7rem' : '0.78rem';
if (view === 'Week') {
const start = startOfWeek(today);
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
const HOURS = HOURS_24;
+ // Prefer the short zone name ("PDT", "EST", "JST") so the label
+ // reads in plain English instead of "GMT-7". formatToParts is wide-
+ // supported; if it ever fails we degrade silently rather than show
+ // a confusing fallback.
const TZ_LABEL = (() => {
try {
- const offset = -new Date().getTimezoneOffset() / 60;
- return `GMT${offset >= 0 ? '+' : ''}${offset.toString().padStart(2, '0').replace('.', ':')}`;
+ const parts = new Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(new Date());
+ return parts.find((p) => p.type === 'timeZoneName')?.value || '';
} catch { return ''; }
})();
return (
- {/* Day headers — full names in roomy, single letter in compact */}
-
+ {/* Day headers: muted weekday caps; today's date gets the filled circle */}
+
{!compact && (
- {TZ_LABEL}
+ {TZ_LABEL}
)}
{days.map((d) => {
const isToday = sameDay(d, today);
return (
-
+
{WEEKDAY_LABEL_SHORT[d.getDay()]}
- {d.getDate()}
+ {d.getDate()}
);
})}
- {HOURS.map((hour) => (
+ {HOURS.map((hour, hourIdx) => (
+ {/* Hour label sits inside its row (top-aligned) rather than
+ straddling the line above it; that way the first row
+ doesn't clip "12 AM" and the labels never drift when the
+ body scrolls. Apple Calendar does the same. */}
{formatHourLabel(hour)}
@@ -173,7 +180,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
ifMatch: wf.updated_at || null,
}));
}}
- sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: `1px solid ${c.border.subtle}`, position: 'relative' }}>
+ sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`, position: 'relative' }}>
addDays(start, i));
+ const accent = c.accent.primary;
return (
-
+ {/* Sticky weekday header so it stays visible even when the
+ calendar body scrolls. Slightly bigger + tinted bg so it
+ reads cleanly in both light and dark themes. */}
+
{WEEKDAY_LABEL_SHORT.map((l, i) => (
- {l}
+ {l}
))}
-
+
{cells.map((d) => {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const evs = eventsByDay.map.get(key) || [];
const isToday = sameDay(d, today);
const inMonth = d.getMonth() === today.getMonth();
return (
-
+
- {d.getDate()}
+ {/* Out-of-month dates still need to be legible (Apple
+ Calendar shows them in a muted shade, not invisible).
+ Color tweak instead of opacity so dark themes stay
+ readable. */}
+ {d.getDate()}
- {evs.slice(0, compact ? 3 : 5).map((e, idx) => (
+ {evs.slice(0, compact ? 3 : 4).map((e, idx) => (
onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
- sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.4, fontSize: EVENT_FS, color: c.text.secondary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: c.accent.primary } }}>
-
-
- {formatTime(e.date.getHours(), e.date.getMinutes())} {e.workflow.title}
-
+ sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}>
+
+ {formatTime(e.date.getHours(), e.date.getMinutes())}
+ {e.workflow.title}
))}
- {evs.length > (compact ? 3 : 5) && (
- +{evs.length - (compact ? 3 : 5)} more
+ {evs.length > (compact ? 3 : 4) && (
+ +{evs.length - (compact ? 3 : 4)} more
)}
);
@@ -236,34 +250,63 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
);
}
- const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[] }[] = [];
+ // Apple-Calendar-style list: big day number + weekday on the left, a
+ // vertical colored bar separating it from events on the right. Today
+ // renders even with no events (shows a "No events today" placeholder)
+ // so the list doesn't feel empty for new users.
+ const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
for (let i = 0; i < 14; i += 1) {
const day = addDays(today, i);
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
const arr = eventsByDay.map.get(key) || [];
- if (arr.length) upcoming.push({ date: day, events: arr });
+ const isToday = sameDay(day, today);
+ if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday });
}
+ const accent = c.accent.primary;
return (
-
+
{upcoming.length === 0 && (
- No scheduled workflows
+ No scheduled workflows
)}
- {upcoming.map(({ date, events }) => (
-
-
- {date.getDate()}
- {date.toLocaleString('en', { month: 'short' })}
- {WEEKDAY_LABEL[date.getDay()]}
+ {upcoming.map(({ date, events, isToday }, rowIdx) => (
+
+
+
+ {date.getDate()}
+
+
+
+ {date.toLocaleString('en', { month: 'short' })}
+
+ {WEEKDAY_FULL[date.getDay()]}
+
-
+
+ {events.length === 0 && (
+ No events today
+ )}
{events.map((e, idx) => (
} placement="right" arrow>
onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
- sx={{ fontSize: '0.85rem', color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}>
- {e.workflow.title}
- {formatTime(e.date.getHours(), e.date.getMinutes())}
+ sx={{
+ display: 'flex', alignItems: 'center', gap: 1.25,
+ py: 0.4,
+ fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
+ '&:hover .ev-title': { color: accent },
+ }}>
+
+
+ {e.workflow.title}
+ {formatTime(e.date.getHours(), e.date.getMinutes())}
+
))}
@@ -275,10 +318,9 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
);
}
-// Renders the events for a single calendar cell. Up to one pill is shown
-// inline; everything else collapses into a "+N" chip that opens a popover
-// with the full list, so the calendar stays readable at high schedule
-// density without truncating workflow titles.
+// Apple Calendar style event chip: 3px colored left-bar + faintly-tinted
+// background + readable text. One chip per cell with a "+N" badge for
+// overflow; clicking it opens a popover listing all events that hour.
function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow }: {
events: { workflow: Workflow; date: Date }[];
onSelectWorkflow?: (id: string) => void;
@@ -290,7 +332,12 @@ function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow
if (events.length === 0) return null;
const first = events[0];
const rest = events.slice(1);
+ const accent = c.accent.primary;
+ // Time string is part of the chip so a glance tells you both what and
+ // when, matching Apple's "Title, 1pm" pattern. Chip is slim (height ~22)
+ // not slot-stretching, since OpenSwarm events fire at a single instant.
+ const timeLabel = formatTime(first.date.getHours(), first.date.getMinutes());
return (
<>
} placement="top" arrow>
@@ -304,18 +351,20 @@ function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow
onContextMenu={(e) => onContextWorkflow?.(first.workflow, e)}
sx={{
position: 'absolute',
- left: 3, right: rest.length > 0 ? 28 : 3, top: 3, bottom: 3,
- bgcolor: c.accent.primary + '1f',
- color: c.accent.primary,
- border: `1px solid ${c.accent.primary}`,
- borderRadius: 999,
- px: 1.1, py: 0,
- fontSize: eventFontSize, fontWeight: 600,
+ left: 2, right: rest.length > 0 ? 24 : 2, top: 2,
+ height: 22,
+ bgcolor: accent + '14',
+ color: c.text.primary,
+ borderLeft: `3px solid ${accent}`,
+ borderRadius: '4px',
+ px: 0.65, py: 0,
+ fontSize: eventFontSize, fontWeight: 500,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
- cursor: 'pointer', display: 'flex', alignItems: 'center',
- '&:hover': { bgcolor: c.accent.primary + '33' },
+ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 0.5,
+ '&:hover': { bgcolor: accent + '22' },
}}>
- {first.workflow.title}
+ {first.workflow.title}
+ {timeLabel}
{rest.length > 0 && (
@@ -324,14 +373,15 @@ function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow
role="button"
sx={{
position: 'absolute',
- right: 3, top: 3, bottom: 3,
- width: 22,
- bgcolor: c.accent.primary,
- color: '#fff',
- borderRadius: 999,
+ right: 2, top: 2,
+ height: 22,
+ minWidth: 20, px: 0.4,
+ bgcolor: accent + '22',
+ color: accent,
+ borderRadius: '4px',
fontSize: eventFontSize, fontWeight: 700,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
- '&:hover': { filter: 'brightness(1.1)' },
+ '&:hover': { bgcolor: accent + '33' },
}}>
+{rest.length}
diff --git a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx
index a1cf5608..779e8900 100644
--- a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx
+++ b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx
@@ -6,6 +6,11 @@ import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Switch from '@mui/material/Switch';
import Tooltip from '@mui/material/Tooltip';
+import RepeatIcon from '@mui/icons-material/RepeatRounded';
+import HourglassEmptyIcon from '@mui/icons-material/HourglassEmptyRounded';
+import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
+import BedtimeIcon from '@mui/icons-material/BedtimeOutlined';
+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';
@@ -195,10 +200,12 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
)}
- {/* Row 3: repeat + timezone. */}
- When should this workflow run?
-
- Repeat every
+ {/* Row 3: repeat + timezone. Icon replaces the "When should this
+ workflow run?" prose; the inputs read self-evidently. */}
+
+
+
+
{s.repeat_unit === 'week' && (
-
- on
+
{WEEKDAY_LABEL.map((label, idx) => {
const active = s.on_days.includes(idx);
return (
@@ -230,8 +236,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
})}
)}
-
- at
+
{/* 12-hour picker; backend stores 0..23 but the UI uses 1..12+AM/PM
so users can't accidentally schedule "3" thinking it's 3pm and
get a 3am run. */}
@@ -282,8 +287,10 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
)}
{/* Row 4: end condition. */}
- For how long?
-
+
+
+
+