import React, { useCallback, 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 HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded'; import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded'; import EditOutlined from '@mui/icons-material/EditOutlined'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { createWorkflow, toggleExpandedStep, updateWorkflow, updateWorkflowCard, type Workflow, type WorkflowRun, } from '@/shared/state/workflowsSlice'; import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals'; import StepList from './StepList'; export function statusColor(s: string, c: ReturnType): string { if (s === 'success') return c.status.success; if (s === 'failure') return c.status.error; if (s === 'ran_late') return c.status.warning; if (s === 'running') return c.accent.primary; return c.text.muted; } export function statusBg(s: string, c: ReturnType): string { if (s === 'success') return c.status.successBg; if (s === 'failure') return c.status.errorBg; if (s === 'ran_late') return c.status.warningBg; return c.bg.secondary; } export function labelForStatus(s: string): string { if (s === 'success') return 'Success'; if (s === 'failure') return 'Failure'; if (s === 'ran_late') return 'Ran late'; if (s === 'running') return 'Running'; if (s === 'skipped') return 'Skipped'; return s; } export function formatRunDate(iso: string): string { try { const d = new Date(iso); return d.toLocaleString('en', { weekday: 'short', month: 'short', day: 'numeric' }); } catch { return iso; } } 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(); // 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} ); } export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, onSaved }: { workflowId: string; steps: Workflow['steps']; sourceSessionId: string | null; initialDraft: Partial | null; onSaved: (w: Workflow) => void; }) { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const [busy, setBusy] = 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 // 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) || ''; // The new workflow runs with the user's configured default model/mode (their // subscription, etc.), falling back to whatever the source chat used. Without // this the backend picks its own default, which surprised users who'd set a // subscription default but saw the workflow created on an API-key model. const defaultModel = useAppSelector((s) => s.settings.data.default_model); const defaultMode = useAppSelector((s) => s.settings.data.default_mode); // Steps render compact (label + chevron, capped + "... N more"), same as // the saved card. The raw prompt drills down on click. Keeping them short // is what leaves room for the schedule prompt + buttons to stay on-card. const expandedIds = card?.expandedStepIds || []; const onToggleStep = useCallback((stepId: string) => { dispatch(toggleExpandedStep({ workflowId, stepId })); }, [dispatch, workflowId]); const onDeleteStep = useCallback((idx: number, stepId: string) => { if (steps.length <= 1) return; const nextSteps = steps.filter((_, i) => i !== idx); dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...liveDraft, steps: nextSteps, }, expandedStepIds: (card?.expandedStepIds || []).filter((id) => id !== stepId), }, })); }, [card?.expandedStepIds, dispatch, liveDraft, steps, workflowId]); const onChangeDescription = useCallback((value: string) => { dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...liveDraft, description: value } } })); }, [dispatch, workflowId, liveDraft]); // Both buttons persist the workflow; the only difference is where they land. // Ignore = save and show the saved card. Schedule = save then open the // natural-language scheduling composer. (Ignore used to delete the card, // which surprised people; the schedule prompt is optional, the workflow isn't.) const saveWorkflow = useCallback(async (): Promise => { const result = await dispatch(createWorkflow({ title, description, steps: steps.map((s) => ({ id: s.id, text: s.text })), source_session_id: sourceSessionId, use_synced_prompt: true, // The user's configured default wins over whatever model the source chat // happened to run on, so a converted workflow behaves like a fresh chat. model: defaultModel || (liveDraft.model as string), mode: defaultMode || (liveDraft.mode as string), } as Partial)); const wf = (result as unknown as { payload: Workflow }).payload; if (wf?.id) { onSaved(wf); return wf; } return null; }, [dispatch, title, description, steps, sourceSessionId, onSaved, liveDraft, defaultModel, defaultMode]); const onIgnore = useCallback(async () => { if (busy) return; setBusy(true); try { await saveWorkflow(); } finally { setBusy(false); } }, [busy, saveWorkflow]); 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' } })); } finally { setBusy(false); } }, [busy, saveWorkflow, dispatch]); void onChangeDescription; return ( {/* Schedule prompt card. Soft accent tint + calendar icon. Accent is the same color the human-intervention (AskUserQuestion) popup uses. */} Schedule this workflow? You can have workflows run on a recurring basis, automatically. Ignore Schedule Workflow ); } // 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 === 'minute') return `Every ${s.repeat_every} minutes`; if (s.repeat_unit === 'hour') return s.repeat_every === 1 ? `Hourly at :${String(s.minute).padStart(2, '0')}` : `Every ${s.repeat_every} hours`; if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `Daily at ${time}` : `Every ${s.repeat_every} days at ${time}`; if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `Monthly 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) { // Image #50: "Mondays at 3pm" (plural day, no "Every" prefix). Reads // more naturally than "Every Mon at 3pm". const plurals = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays']; return `${plurals[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(); void runs; void activeRunId; const card = useAppSelector((s) => s.workflows.openCards[workflow.id]); const expandedIds = card?.expandedStepIds || []; const [deletingStepId, setDeletingStepId] = useState(null); const openEditAgent = useCallback(() => { dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } })); }, [dispatch, workflow.id]); const openScheduling = useCallback(() => { dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling' } })); }, [dispatch, workflow.id]); const onToggleStep = useCallback((stepId: string) => { dispatch(toggleExpandedStep({ workflowId: workflow.id, stepId })); }, [dispatch, workflow.id]); const onDeleteStep = useCallback(async (idx: number, stepId: string) => { if (workflow.steps.length <= 1 || deletingStepId) return; setDeletingStepId(stepId); try { await dispatch(updateWorkflow({ id: workflow.id, patch: { steps: workflow.steps.filter((_, i) => i !== idx) }, ifMatch: workflow.updated_at || null, })); } finally { setDeletingStepId(null); } }, [deletingStepId, dispatch, workflow.id, workflow.steps, workflow.updated_at]); const scheduleLine = workflow.schedule.enabled ? describeSchedule(workflow) : 'Schedule this workflow'; const scheduleClickable = !workflow.schedule.enabled; return ( {scheduleLine} Edit ); } // kept on file for legacy uses; once the audit popover migrates, this and // the StreakBadge / habit-suggestion blocks above can be deleted entirely. void StreakBadgeRow; // 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 ( ); } // Audit-trace popover. Lazy-fetches the last N edits from /workflows/{id}/audit // on open, renders a compact list. The trigger sits inline with the chip // row so power users can spot it without cluttering the title. function AuditTraceLink({ workflowId }: { workflowId: string }) { const c = useClaudeTokens(); 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; setLoading(true); 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(); setEntries(Array.isArray(data?.entries) ? data.entries : []); } catch { setEntries([]); } finally { 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 ( <> {entries === null ? 'edits' : `${count} edit${count === 1 ? '' : 's'}`} RECENT EDITS {loading && Loading…} {!loading && (entries === null || entries.length === 0) && ( No edits yet. )} {!loading && entries && entries.map((e, idx) => { const fields = Object.keys(e.diff || {}).filter((k) => k !== 'updated_at'); const summary = fields.length === 0 ? 'no field changes' : fields.slice(0, 3).join(', ') + (fields.length > 3 ? `, +${fields.length - 3} more` : ''); return ( {e.who || 'user'} {relTimeShort(e.ts)} {summary} ); })} ); } function relTimeShort(iso: string): string { try { const ms = Date.now() - new Date(iso).getTime(); if (ms < 60000) return 'just now'; const m = Math.floor(ms / 60000); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); return `${d}d ago`; } catch { return ''; } } function runDuration(r: WorkflowRun): string | null { if (!r.finished_at) return null; try { const ms = new Date(r.finished_at).getTime() - new Date(r.started_at).getTime(); if (ms <= 0) return null; return humanDuration(ms); } catch { return null; } } // Groups runs into "This week / Last week / Month YYYY" buckets so a // long history list reads as eras rather than 50 same-looking dates. function groupKey(iso: string): string { try { const d = new Date(iso); const now = new Date(); const day = 24 * 3600 * 1000; const startOfWeek = (x: Date) => { const y = new Date(x); y.setHours(0, 0, 0, 0); y.setDate(y.getDate() - y.getDay()); return y; }; const thisWeekStart = startOfWeek(now).getTime(); const lastWeekStart = thisWeekStart - 7 * day; if (d.getTime() >= thisWeekStart) return 'This week'; if (d.getTime() >= lastWeekStart) return 'Last week'; return d.toLocaleString('en', { month: 'long', year: 'numeric' }); } catch { return 'Earlier'; } } export function HistoryList({ runs, onOpen }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void }) { const c = useClaudeTokens(); const [expandedId, setExpandedId] = useState(null); // Filter chips: all / failures / late. Power-users debugging a flaky // workflow shouldn't have to scroll past successes. const [filter, setFilter] = useState<'all' | 'failure' | 'ran_late'>('all'); const filtered = useMemo(() => { if (filter === 'all') return runs; return (runs || []).filter((r) => r.status === filter); }, [runs, filter]); const groups = useMemo(() => { const out: Array<{ key: string; runs: WorkflowRun[] }> = []; for (const r of filtered || []) { const k = groupKey(r.started_at); const last = out[out.length - 1]; if (last && last.key === k) last.runs.push(r); else out.push({ key: k, runs: [r] }); } return out; }, [filtered]); // Header sparkline summarising recent successes/failures so users can // see "lately broken" before scrolling. const recent = (runs || []).slice(0, 30); if (!runs || runs.length === 0) { return No runs yet; } return ( {recent.map((r) => ( ))} {(['all', 'failure', 'ran_late'] as const).map((k) => ( setFilter(k)} role="button" sx={{ fontSize: '0.72rem', fontWeight: 600, color: filter === k ? c.accent.primary : c.text.muted, bgcolor: filter === k ? c.accent.primary + '14' : 'transparent', border: `1px solid ${filter === k ? c.accent.primary + '40' : c.border.subtle}`, px: 0.7, py: 0.2, borderRadius: 999, cursor: 'pointer', '&:hover': { color: c.accent.primary }, }}> {k === 'all' ? 'All' : k === 'failure' ? 'Failures only' : 'Ran late only'} ))} {groups.map(({ key, runs: gRuns }) => ( {key.toUpperCase()} {gRuns.map((r) => { const expanded = expandedId === r.id; const dur = runDuration(r); return ( setExpandedId(expanded ? null : r.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6, px: 0.5, cursor: 'pointer', borderRadius: 0.75, '&:hover': { bgcolor: c.bg.elevated } }}> {labelForStatus(r.status)} {formatRunDate(r.started_at)} {dur && {dur}} {r.cost_usd > 0 && ${r.cost_usd.toFixed(4)}} {/* Chevron makes the row read as expandable instead of static text. Rotates 180° while open so the affordance stays visible after click. */} {expanded && ( {r.error ? ( {r.error} ) : ( {r.session_id ? `Saved as session ${r.session_id.slice(0, 8)}.` : 'No session was recorded for this run.'} Click below to see the full conversation. )} { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}> See full conversation → )} ); })} ))} ); } export function HistoryDetail({ run, onBack }: { run: WorkflowRun | null; onBack: () => void }) { const c = useClaudeTokens(); if (!run) return Run not found; return ( ← back {labelForStatus(run.status)} {formatRunDate(run.started_at)} {run.error && ( {run.error} )} Started {formatRunDate(run.started_at)}, finished {run.finished_at ? formatRunDate(run.finished_at) : 'in progress'}. {run.session_id && ( Session: {run.session_id.slice(0, 8)} )} ); }