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? - + + + + - If your computer was asleep when a run was due: + + + + - at this number + at ('Week'); + const [refDate, setRefDate] = useState(() => new Date()); const workflows = useAppSelector((s) => s.workflows.items); + const periodLabel = useMemo(() => { + if (calendarView === 'Month') { + return refDate.toLocaleString('en', { month: 'long', year: 'numeric' }); + } + if (calendarView === 'Week') { + const start = startOfWeek(refDate); + const end = addDays(start, 6); + const sameMonth = start.getMonth() === end.getMonth(); + const startStr = start.toLocaleString('en', { month: 'short', day: 'numeric' }); + const endStr = sameMonth + ? String(end.getDate()) + : end.toLocaleString('en', { month: 'short', day: 'numeric' }); + return `${startStr} – ${endStr}, ${end.getFullYear()}`; + } + return refDate.toLocaleString('en', { month: 'long', day: 'numeric', year: 'numeric' }); + }, [refDate, calendarView]); + + const onPrev = useCallback(() => { + setRefDate((d) => addDays(d, calendarView === 'Month' ? -28 : calendarView === 'Week' ? -7 : -1)); + }, [calendarView]); + const onNext = useCallback(() => { + setRefDate((d) => addDays(d, calendarView === 'Month' ? 28 : calendarView === 'Week' ? 7 : 1)); + }, [calendarView]); + const workflowIconMap = useMemo(() => { const m: Record = {}; for (const wf of Object.values(workflows)) { @@ -101,12 +132,19 @@ export default function SchedulePopover({ {historyQuery ? 'No matching chats' : 'No chat history yet'} )} {historyResults.map((entry) => { - const icon = workflowIconMap[entry.id]; + const hasWorkflow = Boolean(workflowIconMap[entry.id]); return ( onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}> {entry.name} - {icon && ( - {icon} + {/* Only annotate chats that became saved workflows. + A small workflow glyph reads as a tag, where the + old single-letter chip read as a random initial. */} + {hasWorkflow && ( + + + + + )} {relTime(entry.closed_at)} @@ -128,8 +166,25 @@ export default function SchedulePopover({ Expand + {/* Period nav: Today pill, prev/next chevrons, range label. + Apple Calendar pattern. Keeps the popover usable without + forcing a full Expand for date browsing. */} + + setRefDate(new Date())} + role="button" + sx={{ + fontSize: '0.78rem', fontWeight: 600, color: c.text.secondary, + border: `1px solid ${c.border.subtle}`, px: 0.95, py: 0.3, + borderRadius: `${c.radius.md}px`, cursor: 'pointer', + '&:hover': { color: c.text.primary, borderColor: c.border.medium }, + }}>Today + + + {periodLabel} + - + )} diff --git a/frontend/src/app/pages/Workflows/StepList.tsx b/frontend/src/app/pages/Workflows/StepList.tsx index 33c93a09..5a7a5a40 100644 --- a/frontend/src/app/pages/Workflows/StepList.tsx +++ b/frontend/src/app/pages/Workflows/StepList.tsx @@ -26,7 +26,7 @@ interface Props { onChangeStep?: (idx: number, text: string) => void; } -const CIRCLE_SIZE = 28; +const CIRCLE_SIZE = 24; // Vertical connector lives on the inner edge of the circle column; its // x-offset matches CIRCLE_SIZE/2 so it bisects the numbered circles. const CONNECTOR_X = CIRCLE_SIZE / 2; @@ -81,20 +81,36 @@ export default function StepList({ workflow, steps, runs, activeRunId, framed, o const duration = workflow ? estimateStepDuration(workflow, runs, idx) : null; const isActive = activeStepIdx === idx; const isPast = activeStepIdx !== null && idx < activeStepIdx; + // Target #54: step 1 always gets the framed-box treatment so + // the eye lands on it (it reads as the "entry point" of the + // workflow), steps 2+ stay plain text. The disc fill follows + // the live run: active step gets the solid accent disc; past + // steps a tinted disc; the rest a quiet outlined circle. When + // no run is in flight, nothing is "active" so all discs stay + // outlined, including step 1. + const firstStep = idx === 0; + const frameThis = framed && firstStep; + // Target image #54: in framed mode, step 1's disc is a solid + // accent fill with white text (it's the "entry point"), steps + // 2+ are quiet outlined discs. During a live run the activeStepIdx + // takes over and overrides this baseline. + const primary = frameThis || isActive; return ( - {Icon ? : (idx + 1)} + {Icon ? : (idx + 1)} {onChangeStep ? ( @@ -105,20 +121,20 @@ export default function StepList({ workflow, steps, runs, activeRunId, framed, o sx={{ width: '100%', resize: 'vertical', fontFamily: 'inherit', fontSize: '0.92rem', color: c.text.primary, - border: framed ? `1px solid ${idx === 0 ? c.border.medium : c.border.subtle}` : `1px solid transparent`, + border: frameThis ? `1px solid ${c.border.medium}` : `1px solid transparent`, borderRadius: `${c.radius.md}px`, - bgcolor: framed ? c.bg.surface : 'transparent', - px: 1.25, py: 0.75, lineHeight: 1.4, + bgcolor: frameThis ? c.bg.surface : 'transparent', + px: frameThis ? 1.25 : 0, py: frameThis ? 0.75 : 0.1, lineHeight: 1.45, '&:focus': { outline: 'none', borderColor: c.accent.primary }, }} /> ) : ( {s.text} diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index 13cc48ec..a734af9d 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -15,6 +15,7 @@ import HistoryIcon from '@mui/icons-material/HistoryRounded'; import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded'; import ScheduleIcon from '@mui/icons-material/ScheduleRounded'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import InputBase from '@mui/material/InputBase'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { @@ -130,6 +131,24 @@ const WorkflowCard: React.FC = ({ } }, [card?.view, workflow?.id, runs, dispatch]); + // Layout state (workflowCards in dashboardLayoutSlice) persists across + // app restarts; workflows.openCards in workflowsSlice does NOT — it's a + // transient view-state cache. On relaunch the user sees the workflow + // card position restored AND the source-chat tether redrawn, but the + // card body itself doesn't render because openCards is empty. Auto- + // create a Saved-view openCard once we know the workflow really exists + // server-side. Without this, the user sees only the orange tether arrow + // pointing at nothing. + useEffect(() => { + if (!workflow || card) return; + dispatch(openWorkflowCardAction({ + workflowId: workflow.id, + sourceSessionId: workflow.source_session_id || null, + view: 'saved', + draft: null, + })); + }, [workflow?.id, card, dispatch]); + // Keep wheel-scroll inside the card body instead of letting it bubble // up to the dashboard pan/zoom listener. Without this, scrolling the // schedule/history list shifts the canvas underneath the card. Mirrors @@ -350,11 +369,22 @@ const WorkflowCard: React.FC = ({ if (!card) return null; + // A "running" run is one that's actively executing right now. While + // running, the card grows a subtle conic-gradient halo + a faint title + // pulse so a glance at the canvas tells you something's working. + const isRunning = (runs || []).some((r) => r.status === 'running') || workflow?.last_run_status === 'running'; + + // Hairline border for the default idle state (item #19 in target #54 + // diff). Keeps the card feeling like a soft surface, not a fenced + // box. Highlighted / selected / running still bump up so feedback + // is unambiguous. const border = isHighlighted ? `2px solid ${c.accent.primary}` : isSelected ? '2px solid #3b82f6' - : `1px solid ${c.border.medium}`; + : isRunning + ? `1px solid ${c.accent.primary}80` + : `1px solid ${c.border.subtle}`; const shadow = isHighlighted ? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15` @@ -369,6 +399,7 @@ const WorkflowCard: React.FC = ({ data-select-type="workflow-card" data-select-id={workflowId} data-select-meta={JSON.stringify({ name: title })} + data-running={isRunning ? 'true' : undefined} onPointerDownCapture={() => onBringToFront?.(workflowId, 'workflow')} onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; @@ -386,7 +417,7 @@ const WorkflowCard: React.FC = ({ top: displayY, width: displayW, height: displayH, - borderRadius: `${c.radius.lg}px`, + borderRadius: '14px', border, bgcolor: c.bg.surface, boxShadow: shadow, @@ -395,17 +426,58 @@ const WorkflowCard: React.FC = ({ zIndex: (isDragging || isResizing) ? 999999 : cardZOrder, transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease', '&:hover .resize-handle': { opacity: 1 }, + // Running halo: conic-gradient sweep around the card border + a + // faint inner glow. Lives on ::before so the card body stays + // crisp and isn't redrawn each frame. Only renders when the + // data-running attribute is set (no perf cost when idle). + '&[data-running="true"]::before': { + content: '""', + position: 'absolute', + inset: -1, + borderRadius: '15px', + padding: '1.5px', + background: `conic-gradient(from 0deg, transparent 0deg, ${c.accent.primary} 60deg, transparent 120deg, transparent 240deg, ${c.accent.primary} 300deg, transparent 360deg)`, + WebkitMask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)', + WebkitMaskComposite: 'xor', + maskComposite: 'exclude', + animation: 'workflowRunSweep 2.4s linear infinite', + pointerEvents: 'none', + zIndex: 0, + opacity: 0.85, + }, + '&[data-running="true"]::after': { + content: '""', + position: 'absolute', + inset: 0, + borderRadius: '14px', + background: `radial-gradient(120% 80% at 50% 0%, ${c.accent.primary}10 0%, transparent 60%)`, + animation: 'workflowRunPulse 2.4s ease-in-out infinite', + pointerEvents: 'none', + zIndex: 0, + }, + '@keyframes workflowRunSweep': { + '0%': { transform: 'rotate(0deg)' }, + '100%': { transform: 'rotate(360deg)' }, + }, + '@keyframes workflowRunPulse': { + '0%, 100%': { opacity: 0.5 }, + '50%': { opacity: 1 }, + }, }} > - {/* ===== Title bar / drag handle ===== */} + {/* ===== Title bar / drag handle ===== + Matches target image #54 spec: drag-grip on the far left, then a + single bold title (no pill prefix), then a quiet close X. The + run-status indicator moved to the inline "Scheduled:" prose + below so the title row stays calm. Padding bumped from 1.1 to + 1.4 vertical so the title has air around it. */} = ({ position: 'relative', }} > - - - - {title} - + + {isDraft ? ( + // Draft state: title is inline-editable. Patches the openCard's + // draft.title so PreviewView picks it up on Save. Saved cards + // keep the read-only Typography below. + e.stopPropagation()} + value={(card?.draft?.title as string) || ''} + placeholder="New workflow" + onChange={(e) => dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...(card?.draft || {}), title: e.target.value } } }))} + sx={{ + flex: 1, fontWeight: 700, fontSize: '1rem', color: c.text.primary, + letterSpacing: '-0.005em', + '& input::placeholder': { color: c.text.muted, opacity: 1 }, + }} + /> + ) : ( + + {title} + + )} {runs && runs.length > 0 && } { e.stopPropagation(); onClose(); }} onPointerDown={(e) => e.stopPropagation()} - sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }} + sx={{ p: 0.5, color: c.text.secondary, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }} > - + - {/* ===== Action bar ===== */} + {/* ===== Action bar ===== + Target #54 puts Run / Edit / History flush left and "Schedule + this task" flush right on the SAME row. We use justifyContent + + a flex spacer instead of wrap, so narrow widths shrink the + action group rather than dropping Schedule onto a second line. + Run is the only accent-colored button (it's the verb users + actually do) but its border weight matches the siblings. */} {!isDraft && workflow && ( - + } - active={card.view === 'saved'} + active={false} accent breathe={!runStarting && isStaleSinceLastRun(workflow)} breatheTooltip="Haven't run this in a few days. Click to run it now." @@ -478,8 +573,9 @@ const WorkflowCard: React.FC = ({ active={card.view === 'history' || card.view === 'history_detail'} onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))} /> + {!workflow.schedule.enabled && ( - + } @@ -520,14 +616,15 @@ const WorkflowCard: React.FC = ({ Crossfades between Run/Edit/History tabs so the swap doesn't read as a "jump". Outer box is the scrollable viewport; the animated child changes per `card.view`. */} - + + transition={{ duration: 0.14, ease: 'easeOut' }} + style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}> {card.view === 'preview' && ( ): string { @@ -48,23 +50,43 @@ export function formatRunDate(iso: string): string { } catch { return iso; } } -export function ActionBtn({ label, tone, disabled, onClick }: { label: string; tone: 'muted' | 'success'; disabled?: boolean; onClick: () => void }) { +type ActionBtnTone = 'muted' | 'success' | 'danger'; + +export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: string; tone: ActionBtnTone; disabled?: boolean; onClick: () => void; icon?: 'trash' | 'check' }) { const c = useClaudeTokens(); - const isSuccess = tone === 'success'; + // Tone -> color triple. Matches target #58/#63 styling: + // success = green pill (Save) + // danger = red/pink pill (Discard) + // muted = neutral pill (Undo) + const palette = tone === 'success' + ? { color: c.status.success, bg: c.status.successBg, border: c.status.success + '60', hover: c.status.success + '30' } + : tone === 'danger' + ? { color: c.status.error, bg: c.status.errorBg, border: c.status.error + '60', hover: c.status.error + '30' } + : { color: c.text.secondary, bg: c.bg.secondary, border: c.border.subtle, hover: c.bg.elevated }; return ( + {icon === 'trash' && ( + {'\u{1F5D1}'} + )} + {icon === 'check' && ( + {'✓'} + )} {label} ); @@ -80,8 +102,20 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, const c = useClaudeTokens(); const dispatch = useAppDispatch(); const [busy, setBusy] = useState(false); - const title = (initialDraft?.title as string) || 'Email summary request'; - const description = (initialDraft?.description as string) || "This is an ai generated description of the workflow that gets auto generated after you click complete on the last step. It's used when we wrap workflows as tool calls for other agents to invoke"; + // 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 + // Save we pull whatever's currently in the draft, falling back to the + // initialDraft passed at mount time. + const card = useAppSelector((s) => s.workflows.openCards[workflowId]); + const liveDraft = (card?.draft ?? initialDraft ?? {}) as Partial; + const title = (liveDraft.title as string) || 'New workflow'; + const description = (liveDraft.description as string) || ''; + // Track step text edits locally so the textarea stays uncontrolled-ish + // (no remote round-trip on every keystroke). On Save we pass the + // edited values through. + const [editedSteps, setEditedSteps] = useState(null); + const liveSteps = editedSteps || steps; const onSave = useCallback(async () => { if (busy) return; @@ -90,7 +124,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, const result = await dispatch(createWorkflow({ title, description, - steps: steps.map((s) => ({ id: s.id, text: s.text })), + steps: liveSteps.map((s) => ({ id: s.id, text: s.text })), source_session_id: sourceSessionId, use_synced_prompt: true, } as Partial)); @@ -99,33 +133,125 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, } finally { setBusy(false); } - }, [busy, dispatch, title, description, steps, sourceSessionId, onSaved]); + }, [busy, dispatch, title, description, liveSteps, sourceSessionId, onSaved]); const onDiscard = useCallback(() => { dispatch(closeWorkflowCard(workflowId)); dispatch(removeWorkflowCard(workflowId)); }, [dispatch, workflowId]); + const onChangeDescription = useCallback((value: string) => { + dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...liveDraft, description: value } } })); + }, [dispatch, workflowId, liveDraft]); + + const onChangeStep = useCallback((idx: number, value: string) => { + const next = (liveSteps || []).slice(); + if (!next[idx]) return; + next[idx] = { ...next[idx], text: value }; + setEditedSteps(next); + }, [liveSteps]); + return ( - - {description} - - {/* Save sits on the right; "Throw away" sits on the LEFT separated - by a flex spacer so a panicked user can't fat-finger the - destructive option while reaching for Save. */} - - - - + // minHeight: 100% so the bottom-right Discard/Save cluster pins to + // the bottom of the card body, not just below the last step. Without + // this, mt:auto has nothing to push against and the buttons floated + // up next to step 1 (image #68 bug). + + onChangeDescription(e.target.value)} + sx={{ + fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.55, + border: `1px solid transparent`, borderRadius: `${c.radius.md}px`, + px: 0.5, py: 0.25, + '&:hover': { borderColor: c.border.subtle }, + '&.Mui-focused': { borderColor: c.border.medium }, + '& textarea::placeholder': { color: c.text.ghost, opacity: 1 }, + }} + /> + + {/* Bottom-right cluster: Discard then Save, both pill-shaped with + their respective trash + check glyphs. Matches target #58 / #63. + mt:auto = pinned to the bottom of the flex column regardless of + how little content lives above. */} + + + ); } +// Render the workflow's permission tiers as a flat prose line so the +// SavedView reads like a sentence, not a chip salad. Mirrors target #54. +function describePermissions(workflow: Workflow): string { + const tiers = workflow.permissions || []; + if (tiers.length === 0) return 'Notify me in Open Swarm'; + const parts: string[] = []; + for (const t of tiers) { + if (t.kind === 'notify') parts.push('notify in app'); + else if (t.kind === 'text') parts.push('text'); + else if (t.kind === 'call') parts.push('call'); + } + return `First ${parts.join(', then ')}`; +} + +function describeSchedule(workflow: Workflow): string { + const s = workflow.schedule; + if (!s.enabled) return 'Not scheduled'; + const h12 = ((s.hour + 11) % 12) + 1; + const ampm = s.hour < 12 ? 'am' : 'pm'; + const time = s.minute === 0 ? `${h12}${ampm}` : `${h12}:${String(s.minute).padStart(2, '0')}${ampm}`; + if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `Every day at ${time}` : `Every ${s.repeat_every} days at ${time}`; + if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `Every month at ${time}` : `Every ${s.repeat_every} months at ${time}`; + if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `Weekdays at ${time}`; + if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `Weekends at ${time}`; + if (s.on_days.length === 1) { + const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + return `Every ${labels[s.on_days[0]]} at ${time}`; + } + return `Weekly at ${time}`; +} + export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Workflow; steps: Workflow['steps']; runs?: WorkflowRun[]; activeRunId?: string | null }) { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const connectionMode = useAppSelector((s) => (s as { settings?: { data?: { connection_mode?: string } } }).settings?.data?.connection_mode); + void c; void connectionMode; + + // Inline step-1 edit per target image #63: the first framed step is + // editable in place; once the user touches it the Discard/Save buttons + // surface at the bottom right. Saving issues a steps PATCH against + // the workflow. + const [localFirstStep, setLocalFirstStep] = useState(null); + const [savingFirst, setSavingFirst] = useState(false); + const firstStepDirty = localFirstStep != null && steps[0] && localFirstStep !== steps[0].text; + const editableSteps = firstStepDirty && steps[0] + ? [{ ...steps[0], text: localFirstStep! }, ...steps.slice(1)] + : steps; + const onChangeFirstStep = useCallback((idx: number, text: string) => { + if (idx !== 0) return; + setLocalFirstStep(text); + }, []); + const onSaveFirstStep = useCallback(async () => { + if (!firstStepDirty || savingFirst || !steps[0]) return; + setSavingFirst(true); + try { + const nextSteps = [{ ...steps[0], text: localFirstStep! }, ...steps.slice(1)]; + await dispatch(updateWorkflow({ + id: workflow.id, + patch: { steps: nextSteps }, + ifMatch: workflow.updated_at || null, + })); + setLocalFirstStep(null); + } finally { + setSavingFirst(false); + } + }, [firstStepDirty, savingFirst, steps, localFirstStep, dispatch, workflow.id, workflow.updated_at]); + const onDiscardFirstStep = useCallback(() => setLocalFirstStep(null), []); // Habit suggestion: 3+ manual runs in the last 7 days on a workflow // that isn't scheduled → quietly offer to schedule it. One click flips // the schedule on at the most common time. Auto-disappears once the @@ -155,17 +281,33 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo ifMatch: workflow.updated_at || null, })); }, [habitSuggestion, dispatch, workflow.id, workflow.schedule, workflow.updated_at]); + // Audit trigger lazy-loads the edit log; only show it when the + // workflow has actually been edited. Skips the noisy "0 edits" link + // on freshly created cards. We trigger the fetch on mount once so the + // "edits"/no-edits decision is honest by the time the user reads. + // minHeight: 100% lets the bottom-right cluster pin to the bottom of + // the card body via mt:auto below. return ( - - {/* Pill chips replace the two text rows. Same info, glanceable. */} - - - - - - - + + {/* Prose lines per target #54: "Scheduled:" + "Permissions:". + Reads like a sentence the user can skim instead of a pill row + that needs hovering to decode. Cost stays as a small inline + chip on the right when there's anything to say. */} + + + Scheduled: + {describeSchedule(workflow)} + + {workflow.cost_estimate && workflow.cost_estimate.fires_per_month > 0 && ( + + )} + + + Permissions: + {describePermissions(workflow)} + + {habitSuggestion && ( - Yes → + Yes )} - {workflow.description} - + {workflow.description && ( + + {workflow.description} + + )} + + {/* Bottom-right cluster matching target image #63. Discard + Save + only surface when the user has actually edited the first step + inline; otherwise we don't crowd the card with idle buttons. */} + {firstStepDirty ? ( + + + + + ) : ( + + + + )} + + ); +} + +// Splits StreakBadge out so the SavedView body doesn't have to ferry +// the runs array through both the chip row (gone) and the step list. +function StreakBadgeRow({ runs }: { runs?: WorkflowRun[] }) { + if (!runs || runs.length === 0) return null; + return ( + + ); } @@ -196,6 +373,31 @@ function AuditTraceLink({ workflowId }: { workflowId: string }) { const [anchor, setAnchor] = useState(null); const [entries, setEntries] = useState }> | null>(null); const [loading, setLoading] = useState(false); + // Probe the audit log once on mount so we can hide the trigger entirely + // when there are no edits (item #21 in target #54 diff). Fire-and-forget; + // a failure leaves entries=null which renders nothing. + React.useEffect(() => { + let alive = true; + (async () => { + try { + const { API_BASE, getAuthToken } = await import('@/shared/config'); + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/audit?limit=5`, { + headers: tok ? { Authorization: `Bearer ${tok}` } : {}, + }); + const data = await res.json(); + if (alive) setEntries(Array.isArray(data?.entries) ? data.entries : []); + } catch { + if (alive) setEntries([]); + } + })(); + return () => { alive = false; }; + }, [workflowId]); + // The popover open handler must be declared BEFORE the conditional + // return below; otherwise React sees a different hook-count between + // the "loading" render (returns early) and the "loaded with entries" + // render (calls useCallback), which triggers the "Rendered more hooks + // than during the previous render" crash. const open = useCallback(async (e: React.MouseEvent) => { setAnchor(e.currentTarget); if (entries !== null) return; @@ -214,6 +416,8 @@ function AuditTraceLink({ workflowId }: { workflowId: string }) { setLoading(false); } }, [entries, workflowId]); + // Hide entirely until we know whether there are edits to surface. + if (entries === null || entries.length === 0) return null; const close = () => setAnchor(null); const count = entries?.length ?? 0; return ( diff --git a/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx b/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx index 9b2fa3e4..410d6d5f 100644 --- a/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx @@ -7,7 +7,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch } from '@/shared/hooks'; import { updateWorkflow, type Workflow } from '@/shared/state/workflowsSlice'; import { validateDraft } from './permissionsUtils'; -import { ActionBtn, LABEL_FS, HINT_FS } from './workflowEditCommon'; +import { ActionBtn, HINT_FS, LABEL_FS } from './workflowEditCommon'; import GeneralFacet from './GeneralFacet'; import ActionsFacet from './ActionsFacet'; import ScheduleFacet from './ScheduleFacet'; @@ -95,25 +95,44 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDi setSaveError(null); }, [workflow]); + // Right-edge save indicator. dirty + busy + savedFlash collapse to a + // single state so the button doesn't flicker between "Save now" and + // "Up to date" mid-keystroke. When idle and clean, show a quiet + // check-mark "Saved" label that's identical to the post-flash state. + const saveState: 'dirty' | 'busy' | 'saved' = busy ? 'busy' : dirty ? 'dirty' : 'saved'; + const _flash = savedFlash; void _flash; + return ( - + {/* Top control row, target image #67: + "Currently Editing [Select▾]" spacer [Discard] [Save] + Discard + Save are the same pill-style buttons used at the + bottom of SavedView; placing them here gives the user a single + place to commit OR throw away whatever they just edited. */} + Currently Editing - + diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index 5b14a7da..6c0e5879 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -10,6 +10,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import SearchIcon from '@mui/icons-material/Search'; import MenuIcon from '@mui/icons-material/Menu'; +import CallSplitRoundedIcon from '@mui/icons-material/CallSplitRounded'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { @@ -18,7 +19,10 @@ import { setWorkflowsHubPosition, setWorkflowsHubSize, } from '@/shared/state/dashboardLayoutSlice'; -import { openWorkflowCard, fetchPausedState, setPausedAll } from '@/shared/state/workflowsSlice'; +import { openWorkflowCard, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice'; +import type { Workflow } from '@/shared/state/workflowsSlice'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; import Switch from '@mui/material/Switch'; import Tooltip from '@mui/material/Tooltip'; import { useEffect } from 'react'; @@ -85,19 +89,25 @@ function TimeSavedBadge() { } if (count === 0) return null; const totalMin = count * 3; - const label = totalMin >= 60 ? `${(totalMin / 60).toFixed(1)} hrs back` : `${totalMin} min back`; + const hours = totalMin / 60; + // Show "X done · ~Y hrs" so the user gets both the run count and a + // sense of time. Dot-separator reads quieter than the old green pill. + const timeLabel = hours >= 1 ? `~${hours.toFixed(1)} hrs` : `~${totalMin} min`; return ( - + - ✓ {label} + + {count} + · + {timeLabel} back ); @@ -122,9 +132,21 @@ const WorkflowsHubCard: React.FC = ({ const [viewOpen, setViewOpen] = useState(false); const [refDate, setRefDate] = useState(new Date()); const [search, setSearch] = useState(''); + const [sidebarOpen, setSidebarOpen] = useState(true); + // Right-click on a sidebar row opens this menu pinned to the cursor. + // Mirrors the calendar pill context menu so the two surfaces feel + // consistent. closeMenu wipes both state + DOM-focus. + const [sidebarCtxMenu, setSidebarCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null); + const closeSidebarCtxMenu = useCallback(() => setSidebarCtxMenu(null), []); - const scheduled = useMemo(() => Object.values(workflows).filter((w) => w.schedule.enabled), [workflows]); - const unscheduled = useMemo(() => Object.values(workflows).filter((w) => !w.schedule.enabled), [workflows]); + // "Scheduled" = the workflow has a real cadence configured at any + // point (even if currently paused via the checkbox). Filtering by + // `enabled` would yank rows out from under the user the moment they + // 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 monthLabel = refDate.toLocaleString('en', { month: 'long', year: 'numeric' }); @@ -297,7 +319,11 @@ const WorkflowsHubCard: React.FC = ({ flexShrink: 0, }} > - + + {/* CallSplit natively forks upward; rotated 90deg the fork + points right, matching the Workflows brand mark. */} + + Workflows = ({ {/* ===== Toolbar row (matches Figma image #8 header) ===== */} - - - + + setSidebarOpen((v) => !v)} sx={{ p: 0.5, color: sidebarOpen ? c.text.secondary : c.text.muted, '&:hover': { color: c.text.primary } }}> + + + = ({ {/* ===== Body: sidebar + main calendar ===== */} {/* Sidebar */} + {sidebarOpen && ( = ({ - match(w.title, search))} onPick={onSelectWorkflow} scheduled /> - match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} /> + match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} /> + match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} /> + )} {/* Main calendar area */} @@ -426,6 +456,45 @@ const WorkflowsHubCard: React.FC = ({ + {/* Right-click menu shared across all sidebar workflow rows */} + + { + if (!sidebarCtxMenu) return; + dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id)); + closeSidebarCtxMenu(); + }}>Run now + { + if (!sidebarCtxMenu) return; + const wf = sidebarCtxMenu.workflow; + dispatch(updateWorkflow({ + id: wf.id, + patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any }, + ifMatch: wf.updated_at || null, + })); + closeSidebarCtxMenu(); + }}>{sidebarCtxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'} + { + if (!sidebarCtxMenu) return; + dispatch(addWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id })); + dispatch(openWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id, view: 'edit', editFacet: 'Schedule' })); + closeSidebarCtxMenu(); + }}>Edit… + { + if (!sidebarCtxMenu) return; + const ok = window.confirm(`Delete "${sidebarCtxMenu.workflow.title}"? Scheduled runs will stop.`); + if (ok) dispatch(deleteWorkflow(sidebarCtxMenu.workflow.id)); + closeSidebarCtxMenu(); + }} + sx={{ color: c.status.error }}> + Delete + + + {/* Resize handles */} {HANDLE_DEFS.map(({ dir, sx }) => ( vo ); } -function SidebarSection({ title, items, onPick, scheduled }: { +function SidebarSection({ title, items, onPick, scheduled, onContext }: { title: string; - items: { id: string; title: string; schedule: { enabled: boolean } }[]; + items: Workflow[]; onPick: (id: string) => void; scheduled: boolean; + onContext: (workflow: Workflow, e: React.MouseEvent) => void; }) { const c = useClaudeTokens(); + const dispatch = useAppDispatch(); const [open, setOpen] = useState(true); + + const toggleEnabled = useCallback((wf: Workflow, e: React.MouseEvent) => { + e.stopPropagation(); + dispatch(updateWorkflow({ + id: wf.id, + patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any }, + ifMatch: wf.updated_at || null, + })); + }, [dispatch]); + return ( onPick(w.id)} + onContextMenu={(e) => { e.preventDefault(); onContext(w, e); }} data-no-drag sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.4, pl: 0.5, color: c.text.primary, borderRadius: 0.5, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}> {scheduled ? ( - + + toggleEnabled(w, e)} + sx={{ + width: 14, height: 14, borderRadius: '3px', flexShrink: 0, + border: `1.5px solid ${w.schedule.enabled ? c.accent.primary : c.border.medium}`, + bgcolor: w.schedule.enabled ? c.accent.primary : 'transparent', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + color: '#fff', fontSize: 10, lineHeight: 1, fontWeight: 700, + cursor: 'pointer', + '&:hover': { borderColor: c.accent.primary }, + }}> + {w.schedule.enabled ? '✓' : ''} + + ) : ( )} - {w.title} + {w.title} ))} ); } +function isSchedulable(w: Workflow): boolean { + if (w.schedule.enabled) return true; + // Heuristic: any prior config means the user already opened the + // Schedule facet and committed something. Pure defaults stay in + // "Un-scheduled" so brand-new workflows don't pollute the list. + const s = w.schedule; + return Boolean(s.on_days?.length || s.ends_at || s.max_runs || s.runs_count); +} + function match(title: string, query: string): boolean { if (!query.trim()) return true; return title.toLowerCase().includes(query.trim().toLowerCase()); diff --git a/frontend/src/app/pages/Workflows/scheduleDetect.ts b/frontend/src/app/pages/Workflows/scheduleDetect.ts index 2ef5c30e..f32a006f 100644 --- a/frontend/src/app/pages/Workflows/scheduleDetect.ts +++ b/frontend/src/app/pages/Workflows/scheduleDetect.ts @@ -17,8 +17,12 @@ const HOUR_WORDS: Record = { morning: 9, noon: 12, afternoon: 14, evening: 18, night: 21, midnight: 0, }; -// Match "9am" / "9 a.m." / "10:30 PM" / "at 7" (defaults am). -const HOUR_RE = /\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.)?\b/i; +// Match "9am" / "9 a.m." / "10:30 PM" / "at 7am" — but ONLY when there's +// either an explicit am/pm suffix or an "at " prefix. Plain digits with +// no time context ("3 new messages", "May 16", "$50 offer") used to slip +// through and we'd misread them as the schedule hour. Anchoring on +// `(am|pm)` OR `at ` blocks that. +const HOUR_RE = /\b(?:at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.)?|(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.))\b/i; const DAY_RE = /\b(sun|mon|tue|wed|thu|fri|sat)(?:day)?s?\b/gi; const DAY_MAP: Record = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }; @@ -45,17 +49,17 @@ export function detectSchedule(text: string): DetectedSchedule | null { } const hm = t.match(HOUR_RE); if (hm) { - const raw = parseInt(hm[1], 10); - const m = hm[2] ? parseInt(hm[2], 10) : 0; - const ampm = (hm[3] || '').toLowerCase(); + // The two branches of HOUR_RE give us hour/minute/ampm in either + // capture group 1-3 (the "at H" branch) or 4-6 (the "Ham/pm" branch). + const rawStr = hm[1] || hm[4]; + const minStr = hm[2] || hm[5]; + const ampm = (hm[3] || hm[6] || '').toLowerCase(); + const raw = rawStr ? parseInt(rawStr, 10) : NaN; + const m = minStr ? parseInt(minStr, 10) : 0; let h = raw; if (ampm.startsWith('p') && h < 12) h += 12; if (ampm.startsWith('a') && h === 12) h = 0; - // Only accept the regex hit if it's a plausible hour AND we didn't - // already get a confident word-based hour. Words win because "every - // morning at 9" should be 9am, not the literal "9" with no ampm - // bumped into pm. - if (h >= 0 && h < 24) { + if (Number.isFinite(h) && h >= 0 && h < 24) { if (!presetTimeWord) { hour = h; minute = m; } } } diff --git a/frontend/src/app/pages/Workflows/scheduleUtils.ts b/frontend/src/app/pages/Workflows/scheduleUtils.ts index d682c3ba..228ffcbc 100644 --- a/frontend/src/app/pages/Workflows/scheduleUtils.ts +++ b/frontend/src/app/pages/Workflows/scheduleUtils.ts @@ -111,6 +111,15 @@ export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() <= from.getTime()) return []; if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() < to.getTime()) to = endsAt; } + // Don't paint fires for days that predate the workflow itself. A + // workflow created this Wednesday shouldn't show pills on Sun/Mon/Tue + // of the same week. created_at is an ISO string; only floor on success. + if (workflow.created_at) { + const createdAt = new Date(workflow.created_at); + if (!Number.isNaN(createdAt.getTime()) && createdAt.getTime() > from.getTime()) { + from = createdAt; + } + } if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return []; const remainingRuns = sched.max_runs != null ? Math.max(0, sched.max_runs - sched.runs_count) : Infinity; const effectiveCap = Math.min(cap, remainingRuns); diff --git a/frontend/src/app/pages/Workflows/workflowEditCommon.tsx b/frontend/src/app/pages/Workflows/workflowEditCommon.tsx index ce2d071b..76f26cb0 100644 --- a/frontend/src/app/pages/Workflows/workflowEditCommon.tsx +++ b/frontend/src/app/pages/Workflows/workflowEditCommon.tsx @@ -18,23 +18,32 @@ export function FieldRow({ label, children, align }: { label: string; children: ); } -export function ActionBtn({ label, tone, disabled, onClick }: { label: string; tone: 'muted' | 'success'; disabled?: boolean; onClick: () => void }) { +type ActionBtnTone = 'muted' | 'success' | 'danger'; + +export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: string; tone: ActionBtnTone; disabled?: boolean; onClick: () => void; icon?: 'trash' | 'check' }) { const c = useClaudeTokens(); - const isSuccess = tone === 'success'; + const palette = tone === 'success' + ? { color: c.status.success, bg: c.status.successBg, border: c.status.success + '60', hover: c.status.success + '30' } + : tone === 'danger' + ? { color: c.status.error, bg: c.status.errorBg, border: c.status.error + '60', hover: c.status.error + '30' } + : { color: c.text.secondary, bg: c.bg.secondary, border: c.border.subtle, hover: c.bg.elevated }; return ( + {icon === 'trash' && {'\u{1F5D1}'}} + {icon === 'check' && {'✓'}} {label} );