[eric] workflows: port frontend slice + Workflows pages + canvas card mounting from dev

This commit is contained in:
ciregenz
2026-05-21 22:29:29 -07:00
parent ea35db7b55
commit 2c554f4618
26 changed files with 6197 additions and 498 deletions
+91 -116
View File
@@ -34,10 +34,30 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
import { useOverlayScrollPassthrough } from './useOverlayScrollPassthrough';
import { useStreamingMessage } from '@/shared/state/streamingSlice';
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesomeOutlined';
// ---------------------------------------------------------------------------
// Helper components & functions (unchanged)
// ---------------------------------------------------------------------------
/** Extract up to 3 substantive user-prompt steps to seed a workflow. */
function extractStepsFromSession(session: { messages: Array<{ role: string; content: unknown; hidden?: boolean }> }): Array<{ id: string; text: string }> {
const out: Array<{ id: string; text: string }> = [];
for (const msg of session.messages || []) {
if (msg.role !== 'user' || msg.hidden) continue;
const text = typeof msg.content === 'string' ? msg.content : (Array.isArray(msg.content) ? msg.content.map((b: any) => (typeof b === 'string' ? b : b?.text || '')).join(' ') : '');
const trimmed = text.trim();
if (trimmed.length < 6) continue;
out.push({ id: `step-${out.length + 1}-${Date.now().toString(36)}`, text: trimmed.slice(0, 400) });
if (out.length === 3) break;
}
if (out.length === 0 && session.messages?.length) {
const fallback = session.messages.find((m) => m.role === 'user');
if (fallback) {
const text = typeof fallback.content === 'string' ? fallback.content : '';
out.push({ id: `step-1-${Date.now().toString(36)}`, text: text.slice(0, 400) || 'Run the original task' });
}
}
return out;
}
const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => {
if (service === 'gmail') {
@@ -82,10 +102,7 @@ function fmtSeconds(seconds: number): string {
return `${hours}h ${minutes % 60}m`;
}
// Self-ticking elapsed-time renderer. Owns its own 1Hz interval so only
// this leaf re-renders per second while a session is active; the rest
// of AgentCard stays put. Memoized on `status` + `messages` so it
// doesn't re-tick after the session goes terminal.
/** Self-ticking elapsed-time leaf; owns its 1Hz interval so AgentCard doesn't re-render every second. */
const ElapsedTimer: React.FC<{
messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>;
status: string;
@@ -103,26 +120,7 @@ function getAgentWorkTime(
messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>,
status: string,
): { total: number; last: number } {
// True wall-clock duration: how long the user actually waited, from
// their prompt to the LAST assistant/system message of that turn.
// Covers thinking + every tool call + assistant text generation +
// any subagent/MCP work — anything that consumed user attention.
//
// This is intentionally NOT the sum of `thinking.elapsed_ms` (which
// would cover only reasoning time and miss tool execution). The
// thinking pill in the chat already exposes reasoning-only as a
// distinct signal; the header timer's job is to answer "how long
// did this take?" which is a different question.
//
// For each user message we find the LAST adjacent assistant/system
// message before the next user message — that's the turn boundary.
// If the turn is still in flight (last user message has no assistant
// reply yet AND session is running/waiting), extrapolate to now so
// the timer ticks live.
//
// Hidden messages (auto-continuation prompts from MCPActivate, etc.)
// are skipped — they're system-internal turns the user didn't see
// and shouldn't be billed for.
// Wall-clock turn duration (user prompt to last assistant/system msg); not thinking time. Extrapolates to now while running.
const visible = messages.filter((m) => !m.hidden);
let totalMs = 0;
let lastMs = 0;
@@ -130,8 +128,6 @@ function getAgentWorkTime(
const msg = visible[i];
if (msg.role !== 'user') continue;
// Find the bounds of this turn: from this user message to just
// before the next user message (or end of array).
let nextUserIdx = visible.length;
for (let k = i + 1; k < visible.length; k++) {
if (visible[k].role === 'user') {
@@ -140,8 +136,6 @@ function getAgentWorkTime(
}
}
// Last assistant/system message before the next user message =
// turn end. Walk backwards from nextUserIdx to find it.
let turnEndMs: number | null = null;
for (let k = nextUserIdx - 1; k > i; k--) {
const r = visible[k].role;
@@ -152,9 +146,7 @@ function getAgentWorkTime(
}
if (turnEndMs == null) {
// No assistant reply yet for this turn. If the session is
// actively working, extrapolate to now so the header ticks.
// Otherwise (terminal session, no reply): contribute 0.
// No reply yet; extrapolate to now while running so the header ticks. Terminal sessions contribute 0.
if (status === 'running' || status === 'waiting_approval') {
turnEndMs = Date.now();
} else {
@@ -221,10 +213,6 @@ function getToolDisplayName(toolName: string): string {
return toolName;
}
// ---------------------------------------------------------------------------
// Resize handle definitions
// ---------------------------------------------------------------------------
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const EDGE_THICKNESS = 6;
@@ -252,19 +240,10 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
];
// ---------------------------------------------------------------------------
// AgentCard
// ---------------------------------------------------------------------------
interface OuterProps {
sessionId: string;
expanded: boolean;
// Stable getter — cards read pan/zoom on demand (drag math) instead of
// receiving them as props. Without this, every wheel/pan tick on the
// canvas re-rendered every card, even though the canvas root's CSS
// transform is what actually moves them visually. Cards only need the
// values inside drag callbacks; making it a ref-backed getter keeps
// pan/zoom out of memo equality entirely.
// Ref-backed getter so pan/zoom stay out of memo equality; props would re-render every card on every pan tick.
getCanvasState: () => { panX: number; panY: number; zoom: number };
spawnFrom?: { x: number; y: number; type?: 'branch' };
exitTarget?: { x: number; y: number };
@@ -316,7 +295,8 @@ const AgentCard: React.FC<Props> = ({
const isDashboardActive = useDashboardActive();
const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key);
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
// Stored value → curated picker label, with a tidy fallback for unknowns.
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
// Curated picker label with a tidy fallback for unknowns.
const friendlyModelLabel = useMemo(() => {
const value = session.model;
if (!value) return '';
@@ -333,27 +313,18 @@ const AgentCard: React.FC<Props> = ({
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const cardBoxRef = useRef<HTMLDivElement>(null);
// Capture isDashboardActive in a ref so the ResizeObserver callback always
// sees the latest value without forcing the observer to re-attach when the
// active state flips.
// Ref so ResizeObserver sees latest value without re-attaching when active flips.
const isDashboardActiveRef = useRef(isDashboardActive);
useEffect(() => { isDashboardActiveRef.current = isDashboardActive; }, [isDashboardActive]);
useEffect(() => {
const el = cardBoxRef.current;
if (!el || !onMeasuredHeight) return;
// Remember the most recent height seen during a suppressed window
// (pan/drag/zoom in progress). When the interaction ends, fire it
// through so the layout reconciles to the truth right then.
// Stash height during pan/drag/zoom; flush on gesture end so layout reconciles.
let suppressedHeight: number | null = null;
const ro = new ResizeObserver((entries) => {
// Short-circuit when dashboard is hidden observer stays attached so
// the next resize after returning to the dashboard fires correctly.
// Short-circuit when hidden; observer stays attached so the next resize on return fires correctly.
if (!isDashboardActiveRef.current) return;
// Short-circuit during active canvas interaction (pan/drag/wheel).
// During those gestures we don't care about millimeter-precise card
// heights; re-measuring on every streamed character was forcing
// Dashboard re-renders mid-pan via setMeasuredHeightsTick. Stash
// the latest height instead and flush on gesture end.
// Re-measuring per streamed character mid-pan was forcing Dashboard re-renders via setMeasuredHeightsTick.
if (isCanvasInteractionActive()) {
for (const entry of entries) suppressedHeight = entry.contentRect.height;
return;
@@ -372,7 +343,6 @@ const AgentCard: React.FC<Props> = ({
return () => { ro.disconnect(); unsub(); };
}, [session.id, onMeasuredHeight]);
// ---- Glow state (for branched cards) ----
const glowEntry = useAppSelector((s) => s.dashboardLayout.glowingAgentCards[session.id]);
const isGlowingRedux = !!glowEntry;
const glowFading = glowEntry?.fading ?? false;
@@ -404,7 +374,6 @@ const AgentCard: React.FC<Props> = ({
const isDraft = session.status === 'draft';
// ---- Drag via header (pointer events) ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
@@ -426,7 +395,6 @@ const AgentCard: React.FC<Props> = ({
onDragStart?.(session.id, 'agent');
}, [cardX, cardY, onDragStart, session.id, getCanvasState]);
// Recompute localDragPos from latest pointer + pan (shared by move handler and pan-change event)
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
if (!ds || !didDrag.current) return;
@@ -443,9 +411,7 @@ const AgentCard: React.FC<Props> = ({
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove, getCanvasState]);
// When pan changes during an active drag (edge-pan or wheel-zoom-while-
// dragging), Dashboard dispatches `openswarm:canvas-pan-changed`. Only
// active during a drag so non-dragging cards stay subscribed-to-nothing.
// Dashboard dispatches openswarm:canvas-pan-changed during edge-pan/wheel-zoom; only subscribed while dragging.
useEffect(() => {
if (!isDragging) return;
const onPanChange = () => {
@@ -482,7 +448,7 @@ const AgentCard: React.FC<Props> = ({
dispatch(setCardSize({ sessionId: session.id, width: snapColumn.width, height: cardHeight }));
}
// Snap to 24px grid (hold Shift to bypass)
// Snap to 24px grid (Shift bypasses).
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
@@ -500,7 +466,6 @@ const AgentCard: React.FC<Props> = ({
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, session.id, onDragEnd, snapColumn, cardHeight, getCanvasState]);
// ---- Unified edge / corner resize ----
const resizeRef = useRef<{
dir: ResizeDir;
startX: number;
@@ -594,14 +559,10 @@ const AgentCard: React.FC<Props> = ({
};
// Elapsed-time display owns its own 1Hz tick via <ElapsedTimer/> below;
// we don't force-re-render the whole 1000+ line AgentCard every second
// anymore (each card running × 1Hz = wasted reconciliation budget).
// ElapsedTimer owns its own 1Hz tick so AgentCard doesn't re-render every second.
const lastMessage = session.messages[session.messages.length - 1];
// Subscribe to this card's own streaming entry from the streaming
// slice. Per-character mutations no longer churn the sessions dict,
// so other cards stay stable while this one streams.
// Subscribe to this card's own streaming entry so per-character mutations don't churn other cards.
const streamingMessage = useStreamingMessage(session.id);
const isStreaming = !!streamingMessage;
const previewContent = isStreaming
@@ -665,14 +626,7 @@ const AgentCard: React.FC<Props> = ({
data-select-type="agent-card"
data-select-id={session.id}
data-select-meta={JSON.stringify({ name: session.name || session.id, status: session.status, model: session.model, mode: session.mode })}
// Onboarding tiebreaker: when the user has multiple agent cards open
// (e.g. step 5 leaves the YouTube-summary agent on canvas while
// step 6 spawns a new orchestrator), per-agent selectors like
// chat-input need a way to identify the NEWEST card. Object.values
// iteration order in Dashboard.tsx is keyed by session.id and not
// monotonic by creation time, so DOM order can't be trusted.
// ISO date parses cleanly to ms; missing values fall through to the
// last-DOM-node fallback in resolveSelector.
// Onboarding tiebreaker: ISO-date sorts the newest card for per-agent selectors; DOM order isn't creation order.
data-onboarding-spawn-ms={
session.created_at
? new Date(session.created_at).getTime() || undefined
@@ -688,20 +642,9 @@ const AgentCard: React.FC<Props> = ({
}}
sx={{
position: 'relative',
// contain: streaming chat updates inside don't reflow the dashboard.
// Skipping `paint` here because the highlighted/selected/glow
// boxShadows legitimately extend past the card border — `paint`
// containment would clip those visuals.
// contain: layout style; skipping `paint` because glow boxShadows extend past card border.
contain: 'layout style',
// Promote each card to its own compositor layer so paint
// invalidations (hover effects, streaming content updates,
// highlight pulses) stay contained to that one card's layer
// instead of forcing the canvas's GPU-promoted root layer to
// re-paint. The performance trace showed pointer hover events
// costing 100-200ms of pure presentation time before this,
// because every hover-cross re-painted the entire canvas
// composite. Costs ~card_area*4 bytes of GPU memory per card;
// trivial on modern hardware for the dashboard's card counts.
// Each card gets its own compositor layer; hover-cross used to cost 100-200ms PRESENTATION by re-painting the whole canvas.
willChange: 'transform',
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'),
@@ -796,18 +739,13 @@ const AgentCard: React.FC<Props> = ({
},
}),
...(!isHighlighted && !(isGlowingRedux && !glowFading) && !expanded && !isDragging && !isSelected && {
// Hover: borderColor only. Was previously also bumping boxShadow
// from .sm to .md, but the trace data showed pointer hover events
// costing 120-207ms PRESENTATION because every shadow change
// forced a full GPU re-blur of every card on the transformed
// canvas layer. Border color is layout-free and ~free to paint.
// Hover changes borderColor only; boxShadow changes used to cost 120-207ms PRESENTATION via GPU re-blur.
'&:hover': {
borderColor: hasPending ? c.status.warning : c.border.strong,
},
}),
}}
>
{/* Glow overlays for branched cards */}
{isGlowingRedux && (
<Box
className="agent-card-glow-overlays"
@@ -821,7 +759,6 @@ const AgentCard: React.FC<Props> = ({
transition: `opacity ${GLOW_FADE_MS}ms ease-out`,
}}
>
{/* Rotating conic gradient border */}
<Box
sx={{
position: 'absolute',
@@ -845,7 +782,6 @@ const AgentCard: React.FC<Props> = ({
},
}}
/>
{/* Top edge shimmer */}
<Box
sx={{
position: 'absolute',
@@ -862,7 +798,6 @@ const AgentCard: React.FC<Props> = ({
},
}}
/>
{/* Inner shadow overlay */}
<Box
sx={{
position: 'absolute',
@@ -883,7 +818,6 @@ const AgentCard: React.FC<Props> = ({
</Box>
)}
{/* Resize handles: 4 edges + 4 corners */}
{HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
@@ -902,7 +836,6 @@ const AgentCard: React.FC<Props> = ({
/>
))}
{/* Selection overlay blocks click interaction while selected, enabling drag from anywhere */}
{isSelected && (
<Box
ref={scrollOverlayRef}
@@ -923,7 +856,6 @@ const AgentCard: React.FC<Props> = ({
/>
)}
{/* Drag zone: header + metadata entire region above separator is draggable */}
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
@@ -992,6 +924,55 @@ const AgentCard: React.FC<Props> = ({
onPointerDown={(e) => e.stopPropagation()}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
>
{(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && (
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
<Box
role="button"
onClick={(e) => {
e.stopPropagation();
const steps = extractStepsFromSession(session);
if (steps.length === 0) return;
const draft: Partial<Workflow> = {
title: session.name || 'New workflow',
description: '',
steps,
source_session_id: session.id,
dashboard_id: session.dashboard_id || null,
model: session.model,
mode: session.mode,
provider: session.provider,
};
const tempId = `draft-${session.id}`;
dispatch(addWorkflowCard({
workflowId: tempId,
sourceSessionId: session.id,
expandedSessionIds,
}));
dispatch(openWorkflowCard({
workflowId: tempId,
sourceSessionId: session.id,
view: 'preview',
draft,
}));
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
color: c.accent.primary,
bgcolor: c.accent.primary + '12',
border: `1px solid ${c.accent.primary}40`,
fontSize: '0.78rem', fontWeight: 600,
px: 1, py: 0.45,
borderRadius: `${c.radius.md}px`,
cursor: 'pointer',
'&:hover': { bgcolor: c.accent.primary + '22' },
}}
>
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
Make workflow
</Box>
</Tooltip>
)}
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
@@ -1009,7 +990,6 @@ const AgentCard: React.FC<Props> = ({
</Box>
</Box>
{/* Metadata row */}
<Box sx={{
display: isDraft && !expanded ? 'none' : 'flex',
gap: 1.5,
@@ -1033,7 +1013,6 @@ const AgentCard: React.FC<Props> = ({
</Box>
</Box>
{/* Expanded: inline chat fills remaining space */}
{expanded && (
<Box
onClick={(e) => e.stopPropagation()}
@@ -1061,7 +1040,6 @@ const AgentCard: React.FC<Props> = ({
</Box>
)}
{/* Collapsed: preview + approval */}
{!expanded && (
<>
{previewContent && (
@@ -1250,10 +1228,7 @@ const AgentCard: React.FC<Props> = ({
const MemoAgentCard = React.memo(AgentCard);
// Self-subscribing outer: this is what Dashboard renders. Each card reads
// only its own session + card position from Redux, so a streamDelta to
// session A no longer disturbs B's props. Dashboard's iteration just hands
// down sessionId + cross-card UI state (selection, drag, glow).
/** Self-subscribing wrapper; each card reads only its own session+position so streaming to A doesn't disturb B. */
const AgentCardOuter: React.FC<OuterProps> = (props) => {
const session = useAppSelector((s) => s.agents.sessions[props.sessionId]);
const cardEntry = useAppSelector((s) => s.dashboardLayout.cards[props.sessionId]);
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,9 @@ import SearchIcon from '@mui/icons-material/Search';
import { motion } from 'framer-motion';
import ChatInput from '@/app/pages/AgentChat/ChatInput';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
import SchedulePopover from '@/app/pages/Workflows/SchedulePopover';
import { openWorkflowCard } from '@/shared/state/workflowsSlice';
import { addWorkflowCard, openWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { useElementSelection } from '@/app/components/ElementSelectionContext';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -103,11 +106,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const [mode, setMode] = useState(defaultMode || 'agent');
const [model, setModel] = useState(defaultModel || 'sonnet');
const [thinkingLevel, setThinkingLevel] = useState<'off' | 'low' | 'medium' | 'high' | 'auto'>(defaultThinkingLevel || 'auto');
// Snap to the persisted Settings defaults as soon as they arrive from the
// backend. Without the settingsLoaded guard, the effect fires against the
// Redux initialState ('sonnet') before the real default has loaded, and
// the settingsApplied flag then locks out the real default for the rest
// of the session — so new chats spawn under the stale value.
// Without settingsLoaded guard, effect fires against Redux initial 'sonnet' before real default loads, locking out the real default for the session.
const settingsApplied = useRef(false);
useEffect(() => {
if (settingsLoaded && !settingsApplied.current) {
@@ -117,9 +116,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
settingsApplied.current = true;
}
}, [settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]);
// Reset to the current Settings defaults each time the toolbar reopens
// for a new compose session, so the user's in-session model/mode picks
// don't leak into the next new-chat draft.
// Reset defaults on each new compose session so in-session picks don't leak into the next new-chat draft.
const prevInputOpen = useRef(false);
useEffect(() => {
if (settingsLoaded && inputOpen && !prevInputOpen.current) {
@@ -130,10 +127,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
prevInputOpen.current = inputOpen;
}, [inputOpen, settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]);
// Picking a model/mode/thinking-level in the toolbar writes through to
// the global default. Without this, the reopen-reset effect above
// would snap back to the old default the next time the user opens the
// toolbar, ignoring what they last picked.
// Writes toolbar picks through to global default; otherwise the reopen-reset effect would snap back next open.
const promoteToDefault = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
const current = store.getState().settings;
if (!current.loaded) return;
@@ -159,6 +153,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const [viewSearch, setViewSearch] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
const [historyQuery, setHistoryQuery] = useState('');
const [popoverMode, setPopoverMode] = useState<'search' | 'schedule'>('search');
const shortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut);
const outputs = useAppSelector((s) => s.outputs.items);
const historySearch = useAppSelector((s) => s.agents.historySearch);
@@ -390,6 +385,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const placeholderItems: Array<{ icon: typeof StickyNote2OutlinedIcon; label: string; sub: string }> = [];
return (
<>
<MotionBox
ref={containerRef}
layout
@@ -397,23 +393,20 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
style={{
display: 'flex',
flexDirection: 'column',
background: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
// Drop toolbar card chrome when popover is open so we don't double-card; popover supplies its own surface.
background: historyOpen ? 'transparent' : c.bg.surface,
border: historyOpen ? '1px solid transparent' : `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.xl}px`,
boxShadow: c.shadow.lg,
boxShadow: historyOpen ? 'none' : c.shadow.lg,
padding: isExpanded ? '6px' : '5px',
userSelect: 'none' as const,
overflow: inputOpen || newAgentBounce ? 'visible' : 'hidden',
width: viewPickerOpen ? 580 : isExpanded ? 540 : undefined,
overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden',
// historyOpen: width owned by SchedulePopover; leave undefined so framer-motion measures intrinsic size.
width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined,
}}
>
{inputOpen ? (
// data-onboarding-scope="dock" lets the AC's per-agent-selector
// resolver prefer this chat input (the new-agent dock that
// appears after clicking +) over any existing agent-card's
// chat input. Without this, AC would route to the most
// recently-spawned agent-card, which is usually the wrong
// target on step 5/6 (where the "new agent" is the dock draft).
// data-onboarding-scope="dock" makes AC's per-agent resolver prefer this dock chat input over existing agent cards.
<div
data-onboarding-scope="dock"
style={{ width: '100%', minHeight: 56, paddingBottom: 0, marginBottom: -4 }}
@@ -433,97 +426,31 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
</div>
) : historyOpen ? (
<div style={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1 }}>
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
<InputBase
inputRef={historyInputRef}
value={historyQuery}
onChange={(e) => setHistoryQuery(e.target.value)}
placeholder="Search past chats..."
sx={{
flex: 1,
fontSize: '0.85rem',
color: c.text.primary,
fontFamily: c.font.sans,
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
}}
/>
{historySearch.loading && historySearch.results.length === 0 && (
<CircularProgress size={16} sx={{ color: c.text.muted }} />
)}
</Box>
<Box
ref={historyListRef}
onScroll={handleHistoryScroll}
sx={{
maxHeight: 320,
overflow: 'auto',
borderTop: `1px solid ${c.border.subtle}`,
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
<SchedulePopover
mode={popoverMode}
onModeChange={setPopoverMode}
historyResults={historySearch.results.map((e) => ({ id: e.id, name: e.name, closed_at: e.closed_at }))}
historyLoading={historySearch.loading}
historyQuery={historyQuery}
onHistoryQueryChange={setHistoryQuery}
onHistorySelect={handleHistorySelect}
onNewChat={() => { handleCloseHistory(); onNewAgent(); }}
onWorkflowSelect={(wid) => {
dispatch(addWorkflowCard({ workflowId: wid }));
dispatch(openWorkflowCard({
workflowId: wid,
view: 'saved',
}));
handleCloseHistory();
}}
>
{historySearch.results.length === 0 && !historySearch.loading ? (
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
<Typography sx={{ fontSize: '0.82rem', color: c.text.muted }}>
{historyQuery ? 'No matching chats' : 'No chat history yet'}
</Typography>
</Box>
) : (
<>
{historySearch.results.map((entry) => (
<Box
key={entry.id}
onClick={() => handleHistorySelect(entry.id)}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
px: 1.5,
py: 0.9,
cursor: 'pointer',
transition: 'background-color 0.1s',
'&:hover': { bgcolor: c.bg.elevated },
}}
>
<Typography
sx={{
fontSize: '0.82rem',
fontWeight: 500,
color: c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{entry.name}
</Typography>
<Typography
sx={{
fontSize: '0.7rem',
color: c.text.ghost,
flexShrink: 0,
whiteSpace: 'nowrap',
}}
>
{formatRelativeTime(entry.closed_at)}
</Typography>
</Box>
))}
{historySearch.loading && historySearch.results.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
<CircularProgress size={16} sx={{ color: c.text.muted }} />
</Box>
)}
</>
)}
</Box>
onExpand={() => {
// Singleton per dashboard, second Expand brings the existing card forward.
dispatch(openWorkflowsHub({ expandedSessionIds: [] }));
handleCloseHistory();
}}
historyScrollRef={historyListRef as React.RefObject<HTMLDivElement>}
onHistoryScroll={handleHistoryScroll}
/>
</div>
) : viewPickerOpen ? (
<div style={{ width: '100%' }}>
@@ -859,6 +786,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
</div>
)}
</MotionBox>
</>
);
},
);
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice';
export type CardType = 'agent' | 'view' | 'browser' | 'note';
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow';
export interface SelectedCard {
id: string;
@@ -42,6 +42,7 @@ export function useDashboardSelection(
viewCards: Record<string, ViewCardPosition>,
browserCards: Record<string, BrowserCardPosition> = {},
notes: Record<string, NotePosition> = {},
workflowCards: Record<string, WorkflowCardPosition> = {},
) {
const [selectedIds, setSelectedIds] = useState<Map<string, CardType>>(new Map());
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
@@ -149,6 +150,19 @@ export function useDashboardSelection(
}
}
for (const wc of Object.values(workflowCards)) {
if (
rectsIntersect(rect, {
x: wc.x,
y: wc.y,
width: wc.width,
height: wc.height,
})
) {
intersecting.set(wc.workflow_id, 'workflow');
}
}
if (shiftKey) {
const base = selectionBeforeMarqueeRef.current;
const next = new Map(base);
@@ -164,7 +178,7 @@ export function useDashboardSelection(
return intersecting;
},
[cards, viewCards, browserCards, notes],
[cards, viewCards, browserCards, notes, workflowCards],
);
const handleCanvasMouseDown = useCallback(
@@ -191,8 +205,7 @@ export function useDashboardSelection(
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
isDraggingMarqueeRef.current = true;
document.body.style.userSelect = 'none';
// Disable pointer events on browser webviews/iframes for the
// duration of the drag so the cursor passes through them.
// Disable pointer events on webviews/iframes during drag so the cursor passes through.
document.body.classList.add('dashboard-marquee-active');
}
@@ -242,14 +255,7 @@ export function useDashboardSelection(
return () => window.removeEventListener('keydown', onKeyDown);
}, [deselectAll]);
// Inject (once) a global CSS rule that makes browser webviews and iframes
// transparent to mouse events while a marquee drag is active. Without this,
// the Electron <webview> hit-tests the cursor at the OS level — when the
// cursor lands on an interactable element inside the browser (button,
// link, text), the webview steals the cursor and the marquee drag visually
// freezes until the cursor escapes. Setting `pointer-events: none` makes
// the cursor pass straight through, so the dashboard's mousemove handler
// continues to fire and the marquee keeps growing smoothly.
// One-time CSS: pointer-events:none on webviews/iframes during marquee, so Electron's OS hit-test doesn't steal the cursor mid-drag.
useEffect(() => {
const id = 'dashboard-marquee-style';
if (document.getElementById(id)) return;
@@ -0,0 +1,80 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { openConfigurePanel, closeConfigurePanel } from '@/shared/state/dashboardLayoutSlice';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { BODY_FS, LABEL_FS } from './workflowEditCommon';
export default function ActionsFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
// Configure pops the Action Library out as a separate dashboard card
// tethered to this workflow (image #120). Lives in
// dashboardLayout.configurePanels keyed by workflow id; user can drag,
// resize, and X-close from there.
const configuring = useAppSelector((s) => Boolean(s.dashboardLayout.configurePanels[draft.id]));
const toggleConfigure = () => {
if (configuring) dispatch(closeConfigurePanel(draft.id));
else dispatch(openConfigurePanel({ workflowId: draft.id }));
};
// If the user flips Freeze off while the popout is open, close it so
// the orphaned card doesn't keep listening to a workflow that no
// longer wants a frozen action set.
React.useEffect(() => {
if (!draft.actions.freeze && configuring) {
dispatch(closeConfigurePanel(draft.id));
}
}, [draft.actions.freeze, draft.id, configuring, dispatch]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, color: c.text.secondary }}>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5 }}>
Do you want to prevent the agent from taking actions that weren&apos;t used in the original workflow?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Select
size="small"
value={draft.actions.prevent_unused ? 'prevent' : 'allow'}
onChange={(e) => setDraft({ ...draft, actions: { ...draft.actions, prevent_unused: e.target.value === 'prevent' } })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="prevent">Prevent all unwanted actions</MenuItem>
<MenuItem value="allow">Allow all actions</MenuItem>
</Select>
</Box>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5, mt: 0.5 }}>
Do you want to freeze the actions available to the Agent so this flow always works even if you change your settings?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Select
size="small"
value={draft.actions.freeze ? 'freeze' : 'dont'}
onChange={(e) => setDraft({ ...draft, actions: { ...draft.actions, freeze: e.target.value === 'freeze' } })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="freeze">Freeze actions</MenuItem>
<MenuItem value="dont">Don&apos;t freeze</MenuItem>
</Select>
</Box>
{/* Configure only makes sense when actions are frozen: the user
is explicitly picking a curated subset. With "Don't freeze",
the agent inherits global settings, so there's nothing to
configure here. Auto-close the panel on un-freeze so a stale
popout doesn't outlive the toggle. */}
{draft.actions.freeze && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
<Box
onClick={toggleConfigure}
role="button"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: LABEL_FS, color: configuring ? c.accent.primary : c.text.secondary, cursor: 'pointer', fontWeight: 500, '&:hover': { color: c.accent.primary } }}>
{configuring ? '⚙ Configuring…' : '⚙ Configure'}
</Box>
</Box>
)}
</Box>
);
}
@@ -0,0 +1,158 @@
import React, { useCallback, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import {
closeConfigurePanel,
setConfigurePanelPosition,
setConfigurePanelSize,
type ConfigurePanelPosition,
} from '@/shared/state/dashboardLayoutSlice';
import Tools from '@/app/pages/Tools/Tools';
const MIN_W = 420;
const MIN_H = 320;
const EDGE = 6;
export default function ConfigurePanelCard({ panel, zOrder }: { panel: ConfigurePanelPosition; zOrder: number }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
const [localPos, setLocalPos] = useState<{ x: number; y: number } | null>(null);
const [localSize, setLocalSize] = useState<{ w: number; h: number } | null>(null);
const onDragStart = useCallback((e: React.PointerEvent) => {
e.stopPropagation();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: panel.x, origY: panel.y };
setLocalPos({ x: panel.x, y: panel.y });
}, [panel.x, panel.y]);
const onDragMove = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
const nx = dragRef.current.origX + dx;
const ny = dragRef.current.origY + dy;
setLocalPos({ x: nx, y: ny });
// Push the live position into Redux so the dashboard tether stays
// glued to the panel during the drag instead of lagging until pointer
// up. setLocalPos is kept for sub-frame smoothness, but Redux is the
// tether's source of truth.
dispatch(setConfigurePanelPosition({ workflowId: panel.workflow_id, x: nx, y: ny }));
}, [dispatch, panel.workflow_id]);
const onDragEnd = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
dragRef.current = null;
setLocalPos(null);
}, []);
const onResizeStart = useCallback((e: React.PointerEvent) => {
e.stopPropagation();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: panel.width, origH: panel.height };
setLocalSize({ w: panel.width, h: panel.height });
}, [panel.width, panel.height]);
const onResizeMove = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const dw = e.clientX - resizeRef.current.startX;
const dh = e.clientY - resizeRef.current.startY;
setLocalSize({
w: Math.max(MIN_W, resizeRef.current.origW + dw),
h: Math.max(MIN_H, resizeRef.current.origH + dh),
});
}, []);
const onResizeEnd = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
if (localSize) {
dispatch(setConfigurePanelSize({ workflowId: panel.workflow_id, width: localSize.w, height: localSize.h }));
}
resizeRef.current = null;
setLocalSize(null);
}, [dispatch, localSize, panel.workflow_id]);
const displayX = localPos?.x ?? panel.x;
const displayY = localPos?.y ?? panel.y;
const displayW = localSize?.w ?? panel.width;
const displayH = localSize?.h ?? panel.height;
return (
<Box
data-select-type="configure-panel"
data-select-id={panel.workflow_id}
sx={{
position: 'absolute',
left: displayX,
top: displayY,
width: displayW,
height: displayH,
bgcolor: c.bg.surface,
border: `1px solid ${c.accent.primary}80`,
borderRadius: `${c.radius.lg}px`,
boxShadow: c.shadow.lg,
zIndex: zOrder,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Drag handle + close X strip across the top. Stays slim so the
full Action Library underneath gets the vertical space. */}
<Box
onPointerDown={onDragStart}
onPointerMove={onDragMove}
onPointerUp={onDragEnd}
onPointerCancel={onDragEnd}
sx={{
display: 'flex', alignItems: 'center', gap: 0.5,
px: 1, py: 0.5,
borderBottom: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
cursor: 'grab',
'&:active': { cursor: 'grabbing' },
flexShrink: 0,
userSelect: 'none',
}}>
<DragIndicatorIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Box sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.secondary, flex: 1 }}>Action Library</Box>
<IconButton
size="small"
onClick={() => dispatch(closeConfigurePanel(panel.workflow_id))}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.25, color: c.text.muted, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Body: the real Action Library, exact same component as /actions. */}
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<Tools />
</Box>
{/* SE resize handle. */}
<Box
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
sx={{
position: 'absolute',
right: 0, bottom: 0,
width: 14, height: 14,
cursor: 'nwse-resize',
opacity: 0.6,
'&:hover': { opacity: 1 },
// Diagonal stripes for the universal "drag-resize" hint.
background: `linear-gradient(135deg, transparent 50%, ${c.border.medium} 50%, ${c.border.medium} 60%, transparent 60%, transparent 75%, ${c.border.medium} 75%, ${c.border.medium} 85%, transparent 85%)`,
borderBottomRightRadius: `${EDGE}px`,
}}
/>
</Box>
);
}
@@ -0,0 +1,132 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import InputBase from '@mui/material/InputBase';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { fetchSession, resumeSession } from '@/shared/state/agentsSlice';
import {
DEFAULT_CARD_H,
DEFAULT_CARD_W,
placeCard,
} from '@/shared/state/dashboardLayoutSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { store } from '@/shared/state/store';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { FieldRow, BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
export default function GeneralFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const sourceSessionId = draft.source_session_id || null;
// Open the source chat: fetch if missing, fall through to resume if
// it was closed, place a card if there isn't one. That's it. No pan
// animation, no focus pin, no dashboard_id patching, no auto-clear
// timers. Match the way any other chat opens on the canvas; let the
// user scroll to it.
const openSourceChat = React.useCallback(async () => {
if (!sourceSessionId) return;
const sid = sourceSessionId;
if (!store.getState().agents.sessions[sid]) {
try {
await dispatch(fetchSession(sid)).unwrap();
} catch {
try {
await dispatch(resumeSession({ sessionId: sid })).unwrap();
} catch {
return;
}
}
}
if (!store.getState().dashboardLayout.cards[sid]) {
dispatch(placeCard({
sessionId: sid,
x: 400, y: 200,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
}));
}
// Pan the canvas to the chat card so the user can see it. Safe to
// do here because the active element is the Edit button, not a
// textarea: handleCardSelect's input-aware blur guard prevents the
// focus animation from killing typing focus in a separate flow.
dispatch(setPendingFocusAgentId(sid));
}, [sourceSessionId, dispatch]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<FieldRow label="Title">
<InputBase
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }}
/>
</FieldRow>
<FieldRow label="Description" align="top">
<InputBase
multiline
minRows={2}
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.secondary, lineHeight: 1.5, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }}
/>
</FieldRow>
<FieldRow label="System prompt">
<Select
size="small"
value={draft.use_synced_prompt ? 'synced' : 'custom'}
onChange={(e) => setDraft({ ...draft, use_synced_prompt: e.target.value === 'synced' })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="synced">Synced to settings</MenuItem>
<MenuItem value="custom">Custom</MenuItem>
</Select>
</FieldRow>
{!draft.use_synced_prompt && (
<InputBase
multiline
minRows={4}
placeholder="Custom system prompt..."
value={draft.system_prompt || ''}
onChange={(e) => setDraft({ ...draft, system_prompt: e.target.value })}
sx={{ fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, p: 1, lineHeight: 1.5 }}
/>
)}
<Box sx={{ display: 'flex', alignItems: 'center', mt: 0.5 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, flex: 1 }}>Workflow</Typography>
{sourceSessionId && (
<Box
role="button"
onClick={openSourceChat}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: LABEL_FS, fontWeight: 600,
color: c.text.muted, cursor: 'pointer',
'&:hover': { color: c.accent.primary },
}}>
<EditOutlinedIcon sx={{ fontSize: 14 }} />
Edit
</Box>
)}
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{draft.steps.map((s, idx) => (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: HINT_FS, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.4 }}>{idx + 1}</Box>
<InputBase
multiline
value={s.text}
onChange={(e) => {
const next = [...draft.steps];
next[idx] = { ...s, text: e.target.value };
setDraft({ ...draft, steps: next });
}}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1.25, py: 0.6, lineHeight: 1.4 }}
/>
</Box>
))}
</Box>
</Box>
);
}
@@ -0,0 +1,429 @@
import React, { useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import Popover from '@mui/material/Popover';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, fireTimesWithin, formatTime, formatHourLabel } from './scheduleUtils';
interface Props {
view: 'Week' | 'Month' | 'List';
density: 'compact' | 'roomy';
onSelectWorkflow?: (id: string) => void;
refDate?: Date;
}
// Both compact (popover) and roomy (hub) show the full 24 hours scrollable —
// the user explicitly wants midnight visible at the top, not "9am" as the
// starting hour. The scroll container caps the visible window.
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const workflows = useAppSelector((s) => Object.values(s.workflows.items));
// Right-click menu: pinned position + the workflow whose pill was
// clicked. Same anchor pattern as MUI's menu examples.
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
const closeMenu = () => setCtxMenu(null);
const onRunNow = () => {
if (!ctxMenu) return;
dispatch(runWorkflowNow(ctxMenu.workflow.id));
closeMenu();
};
const onPauseToggle = () => {
if (!ctxMenu) return;
const wf = ctxMenu.workflow;
dispatch(updateWorkflow({
id: wf.id,
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
ifMatch: wf.updated_at || null,
}));
closeMenu();
};
const onEdit = () => {
if (!ctxMenu) return;
dispatch(addWorkflowCard({ workflowId: ctxMenu.workflow.id }));
dispatch(openWorkflowCard({ workflowId: ctxMenu.workflow.id, view: 'edit', editFacet: 'Schedule' }));
closeMenu();
};
const onDelete = () => {
if (!ctxMenu) return;
const ok = window.confirm(`Delete "${ctxMenu.workflow.title}"? Scheduled runs will stop.`);
if (!ok) { closeMenu(); return; }
dispatch(deleteWorkflow(ctxMenu.workflow.id));
closeMenu();
};
const ctxMenuEl = (
<Menu
open={Boolean(ctxMenu)}
onClose={closeMenu}
anchorReference="anchorPosition"
anchorPosition={ctxMenu ? { top: ctxMenu.y, left: ctxMenu.x } : undefined}>
<MenuItem onClick={onRunNow}>Run now</MenuItem>
<MenuItem onClick={onPauseToggle}>{ctxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
<MenuItem onClick={onEdit}>Edit</MenuItem>
<MenuItem onClick={onDelete} sx={{ color: c.status.error }}>Delete</MenuItem>
</Menu>
);
// refDate is recreated on every render unless the caller memoizes it,
// which then trips the eventsByDay memo every paint. Pin the calendar
// to a day-precision key so the heavy fireTimesWithin loop only re-runs
// when the day or workflow set actually changed.
const today = refDate || new Date();
const dayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
const compact = density === 'compact';
const eventsByDay = useMemo(() => {
const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14;
const start = view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : today;
const end = addDays(start, range - 1);
const map = new Map<string, { workflow: Workflow; date: Date }[]>();
for (const wf of workflows) {
if (!wf.schedule.enabled) continue;
const fires = fireTimesWithin(wf, start, end, 60);
for (const d of fires) {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const arr = map.get(key) || [];
arr.push({ workflow: wf, date: d });
map.set(key, arr);
}
}
return { map, start, end };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflows, view, dayKey]);
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 parts = new Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(new Date());
return parts.find((p) => p.type === 'timeZoneName')?.value || '';
} catch { return ''; }
})();
return (
<Box sx={{ display: 'flex', flexDirection: 'column', color: c.text.secondary }}>
{/* Day headers: muted weekday caps; today's date gets the filled circle */}
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', gap: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, pb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end', pr: 1, pb: 0.5 }}>
{!compact && (
<Typography sx={{ fontSize: '0.62rem', color: c.text.ghost, fontWeight: 500 }}>{TZ_LABEL}</Typography>
)}
</Box>
{days.map((d) => {
const isToday = sameDay(d, today);
return (
<Box key={d.toISOString()} sx={{ textAlign: 'center', pb: 0.5 }}>
<Typography sx={{ fontSize: DAY_LABEL, color: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', lineHeight: 1.3, textTransform: 'uppercase' }}>
{WEEKDAY_LABEL_SHORT[d.getDay()]}
</Typography>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: compact ? 30 : 38, height: compact ? 30 : 38, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.primary, fontWeight: isToday ? 700 : 500, fontSize: DAY_NUM, mt: 0.25 }}>{d.getDate()}</Box>
</Box>
);
})}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', borderTop: `1px solid ${c.border.subtle}` }}>
{HOURS.map((hour, hourIdx) => (
<React.Fragment key={hour}>
{/* 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. */}
<Box sx={{
height: SLOT_H, fontSize: ROW_LABEL,
color: c.text.ghost, fontWeight: 500,
textAlign: 'right', pr: 1, pt: 0.25,
borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`,
}}>
{formatHourLabel(hour)}
</Box>
{days.map((d) => {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const evs = (eventsByDay.map.get(key) || []).filter((e) => e.date.getHours() === hour);
const targetWeekday = d.getDay();
return (
<Box
key={`${d.toISOString()}-${hour}`}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => {
e.preventDefault();
const wid = e.dataTransfer.getData('application/x-workflow-id');
if (!wid) return;
const wf = workflows.find((w) => w.id === wid);
if (!wf) return;
// Build the patched schedule: new hour, and for
// weekly schedules swap on_days to just the target
// weekday. Daily/monthly only get the new hour.
const sched = { ...wf.schedule, hour } as typeof wf.schedule;
if (sched.repeat_unit === 'week') sched.on_days = [targetWeekday];
dispatch(updateWorkflow({
id: wf.id,
patch: { schedule: sched as any },
ifMatch: wf.updated_at || null,
}));
}}
sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`, position: 'relative' }}>
<EventStack
events={evs}
onSelectWorkflow={onSelectWorkflow}
eventFontSize={EVENT_FS}
onContextWorkflow={(wf, ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: wf }); }}
/>
</Box>
);
})}
</React.Fragment>
))}
</Box>
{ctxMenuEl}
</Box>
);
}
if (view === 'Month') {
const start = startOfMonthGrid(today);
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
const accent = c.accent.primary;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* 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. */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, borderBottom: `1px solid ${c.border.subtle}`, py: 0.6 }}>
{WEEKDAY_LABEL_SHORT.map((l, i) => (
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.secondary, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
))}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0, borderLeft: `1px solid ${c.border.subtle}` }}>
{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 (
<Box key={d.toISOString()} sx={{ minHeight: compact ? 70 : 96, borderRight: `1px solid ${c.border.subtle}`, borderBottom: `1px solid ${c.border.subtle}`, p: 0.5, position: 'relative', overflow: 'hidden', bgcolor: inMonth ? 'transparent' : c.bg.elevated }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
{/* 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. */}
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? accent : 'transparent', color: isToday ? '#fff' : inMonth ? c.text.primary : c.text.ghost, fontWeight: isToday ? 700 : 500, fontSize: '0.82rem', px: 0.5 }}>{d.getDate()}</Box>
</Box>
{evs.slice(0, compact ? 3 : 4).map((e, idx) => (
<Box
key={`${e.workflow.id}-${idx}`}
onClick={() => 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.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: accent, flexShrink: 0 }} />
<span style={{ color: c.text.muted, flexShrink: 0 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, fontWeight: 500 }}>{e.workflow.title}</span>
</Box>
))}
{evs.length > (compact ? 3 : 4) && (
<Typography sx={{ fontSize: EVENT_FS, color: c.text.muted, mt: 0.3, pl: 1.4 }}>+{evs.length - (compact ? 3 : 4)} more</Typography>
)}
</Box>
);
})}
</Box>
{ctxMenuEl}
</Box>
);
}
// 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) || [];
const isToday = sameDay(day, today);
if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday });
}
const accent = c.accent.primary;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
{upcoming.length === 0 && (
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled workflows</Typography>
)}
{upcoming.map(({ date, events, isToday }, rowIdx) => (
<Box
key={date.toISOString()}
sx={{
display: 'flex', alignItems: 'stretch',
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
minHeight: 64,
}}>
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, pr: 1.25 }}>
<Typography sx={{ fontSize: '1.55rem', fontWeight: 600, color: isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
{date.getDate()}
</Typography>
<Box>
<Typography sx={{ fontSize: '0.78rem', color: isToday ? accent : c.text.secondary, fontWeight: 500, lineHeight: 1.2 }}>
{date.toLocaleString('en', { month: 'short' })}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.2 }}>{WEEKDAY_FULL[date.getDay()]}</Typography>
</Box>
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', py: 1, pr: 2 }}>
{events.length === 0 && (
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
)}
{events.map((e, idx) => (
<Tooltip key={`${e.workflow.id}-${idx}`} title={<EventTooltipBody event={e} />} placement="right" arrow>
<Box
onClick={() => onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
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 },
}}>
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: 1, flexShrink: 0 }} />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
</Box>
</Box>
</Tooltip>
))}
</Box>
</Box>
))}
{ctxMenuEl}
</Box>
);
}
// 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;
eventFontSize: string;
onContextWorkflow?: (workflow: Workflow, e: React.MouseEvent) => void;
}) {
const c = useClaudeTokens();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
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 (
<>
<Tooltip title={<EventTooltipBody event={first} />} placement="top" arrow>
<Box
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/x-workflow-id', first.workflow.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectWorkflow?.(first.workflow.id)}
onContextMenu={(e) => onContextWorkflow?.(first.workflow, e)}
sx={{
position: 'absolute',
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', gap: 0.5,
'&:hover': { bgcolor: accent + '22' },
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{first.workflow.title}</span>
<span style={{ color: 'inherit', opacity: 0.7, flexShrink: 0 }}>{timeLabel}</span>
</Box>
</Tooltip>
{rest.length > 0 && (
<Box
onClick={(e) => setAnchor(e.currentTarget)}
role="button"
sx={{
position: 'absolute',
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': { bgcolor: accent + '33' },
}}>
+{rest.length}
</Box>
)}
<Popover
open={Boolean(anchor)}
anchorEl={anchor}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
<Box sx={{ minWidth: 220, p: 1 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
{events.length} runs at this hour
</Typography>
{events.map((e, idx) => (
<Box
key={`${e.workflow.id}-${idx}`}
onClick={() => { setAnchor(null); onSelectWorkflow?.(e.workflow.id); }}
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: c.accent.primary }} />
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 600 }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
</Box>
))}
</Box>
</Popover>
</>
);
}
function EventTooltipBody({ event }: { event: { workflow: Workflow; date: Date } }) {
const wf = event.workflow;
const status = wf.last_run_status;
const cost = wf.cost_estimate?.last_run_usd;
const monthly = wf.cost_estimate?.monthly_usd;
return (
<Box sx={{ fontSize: '0.72rem', lineHeight: 1.5 }}>
<div style={{ fontWeight: 700 }}>{wf.title}</div>
<div>{`Fires at ${formatTime(event.date.getHours(), event.date.getMinutes())}`}</div>
{status && <div>{`Last run: ${status}`}</div>}
{typeof cost === 'number' && cost > 0 && <div>{`Last run cost: $${cost.toFixed(4)}`}</div>}
{typeof monthly === 'number' && monthly > 0 && <div>{`Est. monthly: $${monthly.toFixed(2)}`}</div>}
</Box>
);
}
@@ -0,0 +1,476 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import InputBase from '@mui/material/InputBase';
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';
import { WEEKDAY_LABEL, formatTime } from './scheduleUtils';
import { nextTierAfter } from './permissionsUtils';
import { BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
function jsWeekday(d: Date): number { return d.getDay(); }
// Turn an IANA zone string into something a non-dev can parse. "local"
// (legacy) or the host's own zone collapse to "your time"; otherwise
// show "Pacific Time" / "Eastern Time" / etc. when we can resolve a
// short name via Intl, falling back to the raw IANA name if not.
function friendlyTzLabel(tz: string): string {
if (!tz || tz === 'local') return 'your time';
try {
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (tz === host) {
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date());
const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
return name ? `your time (${name.replace(' Standard Time', '').replace(' Daylight Time', '')})` : 'your time';
}
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date());
const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
return name || tz;
} catch {
return tz;
}
}
function lastDayOfMonthFE(year: number, monthZeroBased: number): number {
return new Date(year, monthZeroBased + 1, 0).getDate();
}
// Compute the next fire time from a ScheduleConfig. Mirrors the backend
// math in scheduler.py:_next_fire_after using browser-local time so the
// preview lines up with what the user will actually see on their system
// clock. Honors ends_at + max_runs so the "Next run" line doesn't lie
// after the schedule has expired.
function previewNextRun(sched: ScheduleConfig): Date | null {
if (!sched.enabled) return null;
const now = new Date();
if (sched.ends_at) {
const ends = new Date(sched.ends_at);
if (!Number.isNaN(ends.getTime()) && ends.getTime() <= now.getTime()) return null;
}
if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return null;
let candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), sched.hour, sched.minute, 0, 0);
if (candidate <= now) candidate = new Date(candidate.getTime() + 86400000);
if (sched.repeat_unit === 'day') {
const step = Math.max(1, sched.repeat_every);
while (candidate <= now) candidate = new Date(candidate.getTime() + step * 86400000);
return candidate;
}
if (sched.repeat_unit === 'week') {
const allowed = sched.on_days.length ? sched.on_days : [jsWeekday(now)];
for (let i = 0; i < 14; i += 1) {
if (allowed.includes(jsWeekday(candidate)) && candidate > now) return candidate;
candidate = new Date(candidate.getTime() + 86400000);
}
return candidate;
}
if (sched.repeat_unit === 'month') {
const step = Math.max(1, sched.repeat_every);
const startDay = now.getDate();
let year = now.getFullYear();
let month = now.getMonth();
let guard = 0;
while (guard < 60) {
const day = Math.min(startDay, lastDayOfMonthFE(year, month));
const c = new Date(year, month, day, sched.hour, sched.minute, 0, 0);
if (c > now) return c;
month += step;
year += Math.floor(month / 12);
month = ((month % 12) + 12) % 12;
guard += 1;
}
return null;
}
return null;
}
function formatNextRun(d: Date): string {
const wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()];
const mo = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()];
return `${wd} ${mo} ${d.getDate()} at ${formatTime(d.getHours(), d.getMinutes())}`;
}
type EndKind = 'forever' | 'on_date' | 'after_n';
function endKindFromSched(s: ScheduleConfig): EndKind {
if (s.ends_at) return 'on_date';
if (s.max_runs != null) return 'after_n';
return 'forever';
}
interface AppOpenInfo {
alwaysOn: boolean; // tray + login both configured
loginAtLaunch: boolean;
trayEnabled: boolean;
}
function useAppOpenInfo(): { info: AppOpenInfo; fix: () => Promise<void> } {
const [info, setInfo] = useState<AppOpenInfo>({ alwaysOn: false, loginAtLaunch: false, trayEnabled: false });
useEffect(() => {
let alive = true;
const w: any = (window as any).openswarm;
if (!w?.getAppOpenInfo) return;
w.getAppOpenInfo().then((res: AppOpenInfo) => { if (alive) setInfo(res); }).catch(() => {});
return () => { alive = false; };
}, []);
const fix = useCallback(async () => {
const w: any = (window as any).openswarm;
if (!w?.setLoginItem || !w?.enableTray) return;
await w.setLoginItem(true);
await w.enableTray(true);
if (w.getAppOpenInfo) {
const next = await w.getAppOpenInfo();
setInfo(next);
}
}, []);
return { info, fix };
}
export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const s = draft.schedule;
const cloudSms = useAppSelector((st) => (st as any).workflows?.cloudSmsEnabled);
useEffect(() => { dispatch(fetchCloudSmsStatus()); }, [dispatch]);
// No silent enable-on-edit. The master Switch is now the single source
// of truth for whether this schedule is armed.
const setSched = useCallback((patch: Partial<ScheduleConfig>) => {
setDraft({ ...draft, schedule: { ...s, ...patch } });
}, [draft, s, setDraft]);
const addBackup = useCallback(() => {
const tiers = [...(draft.permissions || [])];
const next = nextTierAfter(tiers);
if (!next) return;
tiers.push(next);
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const removeTier = useCallback((idx: number) => {
// Drop the removed tier AND all following tiers so the chain stays
// contiguous (no "call" without "text" before it).
const tiers = (draft.permissions || []).slice(0, idx);
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const setTier = useCallback((idx: number, patch: Partial<PermissionTier>) => {
const tiers = [...(draft.permissions || [])];
tiers[idx] = { ...tiers[idx], ...patch };
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const canAddBackup = ((draft.permissions || [])[ (draft.permissions || []).length - 1 ]?.kind || 'notify') !== 'call';
const endKind = endKindFromSched(s);
const nextPreview = useMemo(() => previewNextRun(s), [s]);
const { info: appOpen, fix: fixAppOpen } = useAppOpenInfo();
const setEndKind = (k: EndKind) => {
if (k === 'forever') setSched({ ends_at: null, max_runs: null });
else if (k === 'on_date') setSched({ ends_at: new Date(Date.now() + 7 * 86400000).toISOString(), max_runs: null });
else setSched({ ends_at: null, max_runs: 10 });
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Master on/off. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Switch size="small" checked={s.enabled} onChange={(e) => setSched({ enabled: e.target.checked })} />
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary }}>
{s.enabled ? 'Schedule is on' : 'Schedule is off'}
</Typography>
</Box>
{s.enabled && (
<AppOpenStatusBadge info={appOpen} hour={s.hour} minute={s.minute} onFix={fixAppOpen} />
)}
{/* Section: When should this workflow run? */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
When should this workflow run?
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>Repeat every</Typography>
<InputBase
type="number"
value={s.repeat_every}
onChange={(e) => setSched({ repeat_every: Math.max(1, Number(e.target.value) || 1) })}
sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Select
size="small"
value={s.repeat_unit}
onChange={(e) => setSched({ repeat_unit: e.target.value as ScheduleConfig['repeat_unit'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="day">day</MenuItem>
<MenuItem value="week">week</MenuItem>
<MenuItem value="month">month</MenuItem>
</Select>
</Box>
{s.repeat_unit === 'week' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 12, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.muted }}> on</Typography>
{WEEKDAY_LABEL.map((label, idx) => {
const active = s.on_days.includes(idx);
return (
<Box
key={idx}
onClick={() => setSched({ on_days: active ? s.on_days.filter((d) => d !== idx) : [...s.on_days, idx] })}
role="button"
sx={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: LABEL_FS, fontWeight: 700, cursor: 'pointer', color: active ? '#fff' : c.text.muted, bgcolor: active ? c.accent.primary : 'transparent', border: `1px solid ${active ? c.accent.primary : c.border.subtle}` }}>{label}</Box>
);
})}
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>At</Typography>
<Select
size="small"
value={((s.hour + 11) % 12) + 1}
onChange={(e) => {
const h12 = Number(e.target.value);
const isPm = s.hour >= 12;
const next = (h12 % 12) + (isPm ? 12 : 0);
setSched({ hour: next });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<MenuItem key={h} value={h}>{h}</MenuItem>
))}
</Select>
<Typography sx={{ fontSize: INPUT_FS, color: c.text.muted }}>:</Typography>
<Select
size="small"
value={s.minute}
onChange={(e) => setSched({ minute: Number(e.target.value) })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{[0, 15, 30, 45].map((m) => (
<MenuItem key={m} value={m}>{String(m).padStart(2, '0')}</MenuItem>
))}
</Select>
<Select
size="small"
value={s.hour < 12 ? 'AM' : 'PM'}
onChange={(e) => {
const wasPm = s.hour >= 12;
const willBePm = e.target.value === 'PM';
if (wasPm === willBePm) return;
setSched({ hour: willBePm ? s.hour + 12 : s.hour - 12 });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="AM">AM</MenuItem>
<MenuItem value="PM">PM</MenuItem>
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 0.5 }}>{friendlyTzLabel(s.timezone)}</Typography>
</Box>
{nextPreview && s.enabled && (
<Typography sx={{ fontSize: HINT_FS, color: c.accent.primary, pl: 12, fontWeight: 500 }}>
Next run: {formatNextRun(nextPreview)}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 0.5 }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>Runs</Typography>
<Select
size="small"
value={endKind}
onChange={(e) => setEndKind(e.target.value as EndKind)}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="forever">Until I turn it off</MenuItem>
<MenuItem value="on_date">Until a date</MenuItem>
<MenuItem value="after_n">After a number of runs</MenuItem>
</Select>
{endKind === 'on_date' && (
<InputBase
type="date"
value={s.ends_at ? s.ends_at.slice(0, 10) : ''}
onChange={(e) => {
const v = e.target.value;
setSched({ ends_at: v ? new Date(v + 'T23:59:59').toISOString() : null });
}}
sx={{ fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
)}
{endKind === 'after_n' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<InputBase
type="number"
value={s.max_runs ?? 10}
onChange={(e) => setSched({ max_runs: Math.max(1, Number(e.target.value) || 1) })}
sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>runs ({s.runs_count} so far)</Typography>
</Box>
)}
</Box>
{(() => {
if (endKind === 'on_date' && s.ends_at) {
const ends = new Date(s.ends_at).getTime();
if (!Number.isNaN(ends) && ends <= Date.now()) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
This date is in the past. The schedule will turn itself off.
</Typography>
);
}
}
if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm.
</Typography>
);
}
return null;
})()}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>If missed</Typography>
<Select
size="small"
value={s.on_missed === 'run_all' ? 'run_once' : s.on_missed}
onChange={(e) => setSched({ on_missed: e.target.value as ScheduleConfig['on_missed'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="skip">Skip the missed run</MenuItem>
<MenuItem value="run_once">Run once after I wake the app</MenuItem>
</Select>
</Box>
</Box>
{/* Section: What can the agent do? */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
What can the agent do?
</Typography>
<Select
size="small"
value={draft.actions.freeze ? 'scoped' : 'full'}
onChange={(e) => {
const scoped = e.target.value === 'scoped';
if (!scoped) {
const ok = window.confirm('Full access lets this scheduled run do anything an agent normally can: run commands, edit files, browse the web, send messages. Continue?');
if (!ok) return;
}
setDraft({ ...draft, actions: { ...draft.actions, freeze: scoped } });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="scoped">Only what the original chat used (recommended)</MenuItem>
<MenuItem value="full">Anything an agent can do (run commands, edit files, browse)</MenuItem>
</Select>
</Box>
{/* Section: How should the agent ask for your permission? */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
How should the agent ask for your permission?
</Typography>
{(draft.permissions || []).map((tier, idx) => (
<PermissionRow
key={idx}
idx={idx}
tier={tier}
cloudSmsEnabled={Boolean(cloudSms)}
onChange={(patch) => setTier(idx, patch)}
onRemove={idx === 0 ? undefined : () => removeTier(idx)}
/>
))}
{canAddBackup && (
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ Escalate if I don&apos;t respond</Box>
)}
</Box>
</Box>
);
}
function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo; hour: number; minute: number; onFix: () => void }) {
const c = useClaudeTokens();
const good = info.alwaysOn;
const fmt = formatTime(hour, minute);
return (
<Box sx={{
display: 'flex', alignItems: 'center', gap: 1, pl: 0.25,
bgcolor: good ? c.status.successBg : (c.status.warningBg || c.bg.elevated),
border: `1px solid ${good ? c.status.success + '60' : (c.status.warning || c.text.muted) + '60'}`,
borderRadius: `${c.radius.md}px`, px: 1, py: 0.5,
}}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: good ? c.status.success : (c.status.warning || c.text.muted) }} />
<Typography sx={{ flex: 1, fontSize: HINT_FS, color: c.text.primary }}>
{good ? 'Will run even if you close OpenSwarm.' : `OpenSwarm must be open at ${fmt} for this to run.`}
</Typography>
{!good && (
<Tooltip title="One click: start OpenSwarm automatically when you log in, and keep a small icon in your menubar so it stays running when you close the window. You can undo both later in Settings.">
<Box onClick={onFix} role="button" sx={{ fontSize: HINT_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 700, whiteSpace: 'nowrap' }}>Always-on</Box>
</Tooltip>
)}
</Box>
);
}
function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: {
idx: number;
tier: PermissionTier;
cloudSmsEnabled: boolean;
onChange: (p: Partial<PermissionTier>) => void;
onRemove?: () => void;
}) {
const c = useClaudeTokens();
if (idx === 0) {
return (
<Select
size="small"
value="notify"
sx={{ alignSelf: 'flex-start', fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="notify">Notify me in Open Swarm</MenuItem>
</Select>
);
}
const unitLabel = tier.kind === 'call' ? 'hour' : 'minutes';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pl: 2, position: 'relative' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>after</Typography>
<InputBase
type="number"
value={tier.after_minutes}
onChange={(e) => onChange({ after_minutes: Math.max(0, Number(e.target.value) || 0) })}
sx={{ width: 44, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>{unitLabel}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Select
size="small"
value={tier.kind}
onChange={(e) => onChange({ kind: e.target.value as PermissionTier['kind'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
{tier.kind !== 'call' && <MenuItem value="text">Text me</MenuItem>}
{tier.kind === 'call' && <MenuItem value="call">Call me</MenuItem>}
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>at</Typography>
<InputBase
value={tier.phone || ''}
placeholder="+1 (000) 123 4567"
onChange={(e) => onChange({ phone: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4, color: c.text.primary }}
/>
{onRemove && (
<Box onClick={onRemove} role="button" sx={{ fontSize: HINT_FS, color: c.text.ghost, cursor: 'pointer', px: 0.5, '&:hover': { color: c.status.error } }}>×</Box>
)}
</Box>
{!cloudSmsEnabled && (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, fontStyle: 'italic' }}>
Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge.
</Typography>
)}
</Box>
);
}
@@ -0,0 +1,231 @@
import React, { useCallback, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import InputBase from '@mui/material/InputBase';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import BookmarkIcon from '@mui/icons-material/BookmarkBorderRounded';
import SearchIcon from '@mui/icons-material/Search';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonthRounded';
import OpenInFullIcon from '@mui/icons-material/OpenInFullRounded';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import AddIcon from '@mui/icons-material/Add';
import { AnimatePresence, motion } from 'framer-motion';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppSelector } from '@/shared/hooks';
import ScheduleCalendar from './ScheduleCalendar';
import { addDays, startOfWeek } from './scheduleUtils';
type Mode = 'search' | 'schedule';
interface Props {
mode: Mode;
onModeChange: (m: Mode) => void;
historyResults: { id: string; name: string; closed_at: string | null }[];
historyLoading: boolean;
historyQuery: string;
onHistoryQueryChange: (q: string) => void;
onHistorySelect: (id: string) => void;
onNewChat: () => void;
onWorkflowSelect: (id: string) => void;
onExpand: () => void;
historyScrollRef?: React.RefObject<HTMLDivElement>;
onHistoryScroll?: () => void;
}
export default function SchedulePopover({
mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange,
onHistorySelect, onNewChat, onWorkflowSelect, onExpand, historyScrollRef, onHistoryScroll,
}: Props) {
const c = useClaudeTokens();
const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('Week');
const [refDate, setRefDate] = useState<Date>(() => 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<string, string> = {};
for (const wf of Object.values(workflows)) {
if (wf.source_session_id) m[wf.source_session_id] = wf.icon || wf.title.slice(0, 1).toUpperCase();
}
return m;
}, [workflows]);
// Both Search and Schedule modes render at the same fixed dimensions so
// toggling chips doesn't resize the popover. Schedule sets the floor:
// its 7-day calendar needs ~620w x ~420h, search inherits the same.
const POPOVER_W = 620;
const CONTENT_H = 420;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', width: POPOVER_W, maxWidth: POPOVER_W, gap: 0.75, flexShrink: 0 }}>
{/* Floating mode chips OUTSIDE the content card (Figma image #30) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 0.5 }}>
<ModeChip label="Search" icon={<SearchIcon sx={{ fontSize: 14 }} />} active={mode === 'search'} onClick={() => onModeChange('search')} />
<ModeChip label="Schedule" icon={<CalendarMonthIcon sx={{ fontSize: 14 }} />} active={mode === 'schedule'} onClick={() => onModeChange('schedule')} />
</Box>
{/* Content card — separately bordered/rounded, like image #30.
Inner content crossfades on tab switch so search↔schedule isn't
a jarring jump. Outer card stays fixed-size (W×H) so the toolbar
doesn't reflow. */}
<Box sx={{
width: '100%',
height: CONTENT_H,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.lg}px`,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={mode}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: 'easeOut' }}
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column' }}>
{mode === 'search' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, flexShrink: 0 }}>
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
<InputBase
value={historyQuery}
onChange={(e) => onHistoryQueryChange(e.target.value)}
placeholder="Search past chats..."
sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, '& input::placeholder': { color: c.text.ghost, opacity: 1 } }}
/>
<Box onClick={onNewChat} role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, px: 1, py: 0.45, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated } }}>
<AddIcon sx={{ fontSize: 12 }} />
New
</Box>
</Box>
<Box ref={historyScrollRef} onScroll={onHistoryScroll} sx={{ flex: 1, overflowY: 'auto', borderTop: `1px solid ${c.border.subtle}` }}>
{historyResults.length === 0 && !historyLoading && (
<Typography sx={{ px: 1.5, py: 2.5, fontSize: '0.82rem', color: c.text.muted, textAlign: 'center' }}>{historyQuery ? 'No matching chats' : 'No chat history yet'}</Typography>
)}
{historyResults.map((entry) => {
const hasWorkflow = Boolean(workflowIconMap[entry.id]);
return (
<Box key={entry.id} onClick={() => onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.name}</Typography>
{/* 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 && (
<Tooltip title="This chat is saved as a workflow">
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18, borderRadius: '4px', color: c.text.muted }}>
<BookmarkIcon sx={{ fontSize: 13 }} />
</Box>
</Tooltip>
)}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, flexShrink: 0, whiteSpace: 'nowrap' }}>{relTime(entry.closed_at)}</Typography>
</Box>
);
})}
</Box>
</Box>
)}
{mode === 'schedule' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, pt: 1, pb: 0.5, flexShrink: 0 }}>
{(['Week', 'Month', 'List'] as const).map((v) => (
<Box key={v} onClick={() => setCalendarView(v)} role="button" sx={{ fontSize: '0.85rem', fontWeight: calendarView === v ? 700 : 500, px: 0.75, pt: 0.4, pb: 0.55, color: calendarView === v ? c.text.primary : c.text.muted, borderBottom: `2px solid ${calendarView === v ? c.accent.primary : 'transparent'}`, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>{v}</Box>
))}
<Box sx={{ flex: 1 }} />
<Box onClick={onExpand} role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, px: 1, py: 0.35, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated } }}>
<OpenInFullIcon sx={{ fontSize: 12 }} />
Expand
</Box>
</Box>
{/* Period nav: Today pill, prev/next chevrons, range label.
Apple Calendar pattern. Keeps the popover usable without
forcing a full Expand for date browsing. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 1.5, pb: 0.75, flexShrink: 0 }}>
<Box
onClick={() => 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</Box>
<IconButton size="small" onClick={onPrev} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronLeftIcon sx={{ fontSize: 17 }} /></IconButton>
<IconButton size="small" onClick={onNext} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronRightIcon sx={{ fontSize: 17 }} /></IconButton>
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary, ml: 0.25 }}>{periodLabel}</Typography>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, py: 1, borderTop: `1px solid ${c.border.subtle}`, minHeight: 0 }}>
<ScheduleCalendar view={calendarView} density="roomy" onSelectWorkflow={onWorkflowSelect} refDate={refDate} />
</Box>
</Box>
)}
</motion.div>
</AnimatePresence>
</Box>
</Box>
);
}
// Floating chip rendered ABOVE the popover card (image #30). Active gets a
// subtle filled-elevated bg + 1px border; inactive is borderless ghost.
function ModeChip({ label, icon, active, onClick }: { label: string; icon: React.ReactNode; active: boolean; onClick: () => void }) {
const c = useClaudeTokens();
return (
<Box
onClick={onClick}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
fontSize: '0.82rem', fontWeight: active ? 700 : 500,
px: 1.1, py: 0.45,
cursor: 'pointer',
color: active ? c.text.primary : c.text.muted,
bgcolor: active ? c.bg.surface : 'transparent',
border: `1px solid ${active ? c.border.subtle : 'transparent'}`,
borderRadius: `${c.radius.md}px`,
boxShadow: active ? c.shadow.sm : 'none',
'&:hover': { color: c.text.primary, bgcolor: active ? c.bg.surface : c.bg.elevated },
}}>
{icon}
{label}
</Box>
);
}
function relTime(iso: string | null): string {
if (!iso) return '';
const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (sec < 60) return 'just now';
const m = Math.floor(sec / 60); if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
@@ -0,0 +1,240 @@
// Minimum-steps-to-value entry point: from any open chat, hit "Schedule"
// in the header, pick one of four presets, and we materialize a workflow
// seeded with source_session_id (so it inherits the chat's tool surface
// + steps via the existing /workflows/create path). "Custom..." opens a
// LOCAL draft card instead of immediately POSTing /workflows/create, so
// users who change their mind don't leave behind an orphan workflow.
import React, { useCallback, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Popover from '@mui/material/Popover';
import InputBase from '@mui/material/InputBase';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createWorkflow, openWorkflowCard, type ScheduleConfig, type Workflow } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { defaultSchedule } from './scheduleUtils';
type Preset = {
label: string;
hint: string;
build: () => Partial<ScheduleConfig>;
};
const PRESETS: Preset[] = [
{ label: 'Every day at 9am', hint: 'Daily standup, morning report', build: () => ({ enabled: true, repeat_unit: 'day', repeat_every: 1, hour: 9, minute: 0 }) },
{ label: 'Weekdays at 9am', hint: 'Mon to Fri', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1, 2, 3, 4, 5], hour: 9, minute: 0 }) },
{ label: 'Every Monday at 9am', hint: 'Weekly check-in', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1], hour: 9, minute: 0 }) },
{ label: 'Every month on the 1st', hint: 'Monthly summary, billing report', build: () => ({ enabled: true, repeat_unit: 'month', repeat_every: 1, hour: 9, minute: 0 }) },
];
interface Props {
anchorEl: HTMLElement | null;
onClose: () => void;
sessionId: string;
sessionName: string;
// Hook so the caller can show "Workflow created" feedback inline.
onCreated?: (workflowId: string) => void;
// Auto-suggest path: when the caller detected time-words and wants to
// pre-fill the popover with that exact schedule, the first preset
// shown becomes "Use suggestion: <label>" and is set as the default.
prefillSchedule?: ScheduleConfig | null;
prefillLabel?: string | null;
}
export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sessionName, onCreated, prefillSchedule, prefillLabel }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const [title, setTitle] = useState<string>(sessionName || 'Untitled');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const workflows = useAppSelector((s) => s.workflows.items);
// Workflow cards only render inside the Dashboard canvas. When this
// popover is opened from somewhere else (Apps editor, etc.), Custom...
// would silently drop the user on a non-canvas page with no visible
// editor — see this session's chat history. Look up the session's
// dashboard so we can navigate there before opening the draft.
const sessionDashboardId = useAppSelector(
(s) => sessionId ? s.agents.sessions[sessionId]?.dashboard_id : null,
);
// Dup-detect: a chat session can only sanely have one schedule attached.
// If we find one already, offer "Open existing" instead of silently
// creating a duplicate that fires twice.
const existing = useMemo<Workflow | null>(() => {
if (!sessionId) return null;
for (const w of Object.values(workflows)) {
if (w.source_session_id === sessionId) return w;
}
return null;
}, [workflows, sessionId]);
const submit = useCallback(async (preset: Preset) => {
if (busy) return;
setBusy(true);
setError(null);
try {
const schedule: ScheduleConfig = { ...defaultSchedule(), ...preset.build() };
const result = await dispatch(createWorkflow({
title,
source_session_id: sessionId,
schedule,
} as Partial<Workflow>));
if (createWorkflow.fulfilled.match(result)) {
const wf = result.payload as Workflow;
dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: sessionId }));
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' }));
onCreated?.(wf.id);
onClose();
} else {
setError('Create failed. Try again.');
}
} catch (e) {
setError((e as Error)?.message || 'Create failed.');
} finally {
setBusy(false);
}
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
const openCustom = useCallback(() => {
// Open a local draft. NO backend create yet — the workflow only
// exists on disk once the user clicks Save in the editor. Closing
// the draft card from here leaves nothing behind (the "orphan"
// bug from the previous create-then-edit flow).
const tempId = `draft-${sessionId}-${Date.now()}`;
dispatch(addWorkflowCard({ workflowId: tempId, sourceSessionId: sessionId }));
dispatch(openWorkflowCard({
workflowId: tempId,
sourceSessionId: sessionId,
view: 'preview',
draft: {
title,
description: 'Scheduled from chat. Edit anytime.',
steps: [{ id: 'step-1', text: '' }],
schedule: { ...defaultSchedule() },
} as Partial<Workflow>,
}));
// If the user opened this popover from somewhere other than the
// dashboard canvas (e.g. the Apps editor), the draft card we just
// created is invisible because <WorkflowCard /> is only rendered on
// /dashboard/<id>. Navigate there so the user lands on the editable
// card. No-op when already on a dashboard route.
if (sessionDashboardId && !location.pathname.startsWith('/dashboard/')) {
navigate(`/dashboard/${sessionDashboardId}`);
}
onClose();
}, [dispatch, sessionId, title, onClose, sessionDashboardId, navigate, location.pathname]);
const openExisting = useCallback(() => {
if (!existing) return;
dispatch(addWorkflowCard({ workflowId: existing.id, sourceSessionId: sessionId }));
dispatch(openWorkflowCard({ workflowId: existing.id, view: 'saved' }));
onClose();
}, [dispatch, existing, sessionId, onClose]);
return (
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 320, p: 1.25 } } }}
>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.75 }}>
SCHEDULE THIS CHAT
</Typography>
{existing && (
<Box sx={{
display: 'flex', flexDirection: 'column', gap: 0.4,
px: 1, py: 0.75, mb: 0.75,
borderRadius: `${c.radius.md}px`,
bgcolor: c.status.warningBg || c.bg.elevated,
border: `1px solid ${(c.status.warning || c.text.muted) + '60'}`,
}}>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.primary }}>
This chat is already scheduled.
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
&quot;{existing.title}&quot; was made from this conversation. Adding another would fire twice.
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
<Box onClick={openExisting} role="button" sx={{
fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary,
cursor: 'pointer', px: 0.75, py: 0.3, borderRadius: `${c.radius.md}px`,
bgcolor: c.accent.primary + '14', border: `1px solid ${c.accent.primary}40`,
'&:hover': { bgcolor: c.accent.primary + '22' },
}}>Open existing </Box>
</Box>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary }}>Name:</Typography>
<InputBase
value={title}
onChange={(e) => setTitle(e.target.value)}
sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.3 }}
/>
</Box>
{prefillSchedule && prefillLabel && (
<Box
role="button"
onClick={() => submit({
label: prefillLabel,
hint: 'Detected from your conversation',
build: () => prefillSchedule as Partial<ScheduleConfig>,
})}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
px: 1, py: 0.7, borderRadius: `${c.radius.md}px`,
mb: 0.5,
border: `1px solid ${c.accent.primary}55`,
bgcolor: c.accent.primary + '14',
cursor: busy ? 'wait' : 'pointer',
opacity: busy ? 0.5 : 1,
'&:hover': { bgcolor: c.accent.primary + '22' },
}}>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.accent.primary, letterSpacing: '0.04em' }}>SUGGESTED</Typography>
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: c.text.primary }}>{prefillLabel}</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Detected from your last reply</Typography>
</Box>
)}
{PRESETS.map((p) => (
<Box
key={p.label}
role="button"
onClick={() => submit(p)}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
px: 1, py: 0.6, borderRadius: `${c.radius.md}px`,
cursor: busy ? 'wait' : 'pointer',
opacity: busy ? 0.5 : 1,
'&:hover': { bgcolor: c.bg.elevated },
}}>
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: c.text.primary }}>{p.label}</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>{p.hint}</Typography>
</Box>
))}
<Box
role="button"
onClick={openCustom}
sx={{
mt: 0.5, borderTop: `1px solid ${c.border.subtle}`,
px: 1, py: 0.7, borderRadius: `${c.radius.md}px`,
cursor: busy ? 'wait' : 'pointer',
opacity: busy ? 0.5 : 1,
'&:hover': { bgcolor: c.bg.elevated },
}}>
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.accent.primary }}>Custom</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Open the editor without saving yet</Typography>
</Box>
{error && (
<Typography sx={{ mt: 0.5, fontSize: '0.74rem', color: c.status.error }}>{error}</Typography>
)}
</Popover>
);
}
@@ -0,0 +1,210 @@
// Vertical step list with connector + optional live-fill during a run +
// optional auto-icon per step + optional duration estimate per step.
// Used by both the Preview (draft) view and the Saved view so the two
// stay visually consistent.
import React from 'react';
import Box from '@mui/material/Box';
import TextareaAutosize from '@mui/material/TextareaAutosize';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice';
import { stepIconFor, estimateStepDuration } from './workflowVisuals';
interface Props {
workflow?: Workflow | null;
steps: Workflow['steps'];
runs?: WorkflowRun[];
// Pass the active run id to fill the connector progressively as the
// workflow streams. Currently estimated by elapsed/expected; once
// per-step telemetry ships, swap to a real step-index signal.
activeRunId?: string | null;
// Subtle frame around each step (used by Preview's edit-mode look). The
// Saved view turns this off for a quieter read.
framed?: boolean;
// Callback when a step row is edited inline; only useful in Preview.
onChangeStep?: (idx: number, text: string) => void;
// Callback when the trash icon next to a step is clicked. Pairs with
// onAddStep on the parent. Provide both when editing; omit for read-only.
onDeleteStep?: (idx: number) => void;
onAddStep?: () => void;
}
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;
export default function StepList({ workflow, steps, runs, activeRunId, framed, onChangeStep }: Props) {
const c = useClaudeTokens();
const hasSteps = steps && steps.length > 0;
if (!hasSteps) return null;
// Determine "current step" for live-fill. We don't have per-step
// telemetry yet, so estimate via elapsed/expected ratio if a run is
// active, otherwise leave it null (no fill).
const activeStepIdx = useActiveStepIdx(steps.length, runs, activeRunId);
return (
<Box sx={{ position: 'relative', pl: 0, mt: 0.25 }}>
{/* Connector spine. SVG so the live-fill segment can clip cleanly. */}
{steps.length > 1 && (
<Box
aria-hidden
sx={{
position: 'absolute',
left: CONNECTOR_X - 0.5,
top: CIRCLE_SIZE * 0.5,
bottom: CIRCLE_SIZE * 0.5,
width: 1,
bgcolor: c.border.medium,
opacity: 0.65,
}}
/>
)}
{steps.length > 1 && activeStepIdx !== null && (
<Box
aria-hidden
sx={{
position: 'absolute',
left: CONNECTOR_X - 1,
top: CIRCLE_SIZE * 0.5,
// Progress = (active+1)/total, capped at total-1 so the fill
// never overshoots the bottom circle.
height: `calc((100% - ${CIRCLE_SIZE}px) * ${Math.min(steps.length - 1, activeStepIdx) / (steps.length - 1)})`,
width: 2,
bgcolor: c.accent.primary,
transition: 'height 0.4s ease-out',
boxShadow: `0 0 6px ${c.accent.primary}`,
}}
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.85 }}>
{steps.map((s, idx) => {
const Icon = stepIconFor(s.text || '');
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;
// All steps look identical when framed; the orange disc on
// step 1 already does the "entry point" signaling. Singling
// out step 1 made 2+ read as static text.
const frameThis = framed;
const primary = (framed && firstStep) || isActive;
return (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25, position: 'relative' }}>
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
border: `1px solid ${primary || isPast ? c.accent.primary : c.border.medium}`,
bgcolor: primary ? c.accent.primary : isPast ? c.accent.primary + '22' : c.bg.surface,
color: primary ? '#fff' : isPast ? c.accent.primary : c.text.muted,
fontSize: '0.74rem', fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
position: 'relative', zIndex: 1,
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
transition: 'background 0.25s ease, color 0.25s ease',
}}>
{Icon ? <Icon sx={{ fontSize: 13 }} /> : (idx + 1)}
</Box>
<Box
sx={{
flex: 1,
minWidth: 0,
// Hover + focus give 2+ steps a visible edge so the user
// discovers they're editable. Step 1 already shows a
// permanent frame; this just makes the rest discoverable.
'& textarea:hover': {
borderColor: `${c.border.medium} !important`,
background: `${c.bg.surface} !important`,
},
'& textarea:focus': {
borderColor: `${c.accent.primary} !important`,
background: `${c.bg.surface} !important`,
},
}}>
{onChangeStep ? (
<TextareaAutosize
value={s.text}
onChange={(e) => onChangeStep(idx, e.target.value)}
minRows={1}
style={{
width: '100%',
resize: 'none',
boxSizing: 'border-box',
fontFamily: 'inherit',
fontSize: '0.92rem',
color: c.text.primary,
border: frameThis ? `1px solid ${c.border.medium}` : '1px solid transparent',
borderRadius: `${c.radius.md}px`,
background: frameThis ? c.bg.surface : 'transparent',
padding: '6px 10px',
lineHeight: 1.45,
outline: 'none',
overflow: 'hidden',
transition: 'border-color 0.12s ease, background 0.12s ease',
}}
/>
) : (
<Box sx={{
fontSize: '0.92rem', color: c.text.primary,
border: frameThis ? `1px solid ${c.border.medium}` : 'none',
borderRadius: frameThis ? `${c.radius.md}px` : 0,
bgcolor: frameThis ? c.bg.surface : 'transparent',
px: frameThis ? 1.25 : 0, py: frameThis ? 0.75 : 0.1,
lineHeight: 1.45,
}}>
{s.text}
</Box>
)}
{duration && (
<Tooltip title="Estimated from recent successful runs (whole-run duration divided by step count).">
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, mt: 0.25, ml: framed ? 1.25 : 0.5 }}>
~{duration}
</Typography>
</Tooltip>
)}
</Box>
</Box>
);
})}
</Box>
</Box>
);
}
// Synthesize an "active step" index from the active run's elapsed time
// vs the historical average run duration. Doesn't pretend to be exact;
// good enough for the user to see the progress bar advance during a
// long workflow. Returns null when no live run.
function useActiveStepIdx(stepCount: number, runs: WorkflowRun[] | undefined, activeRunId: string | null | undefined): number | null {
const [tick, setTick] = React.useState(0);
React.useEffect(() => {
if (!activeRunId) return;
const id = window.setInterval(() => setTick((t) => (t + 1) % 1000000), 1000);
return () => window.clearInterval(id);
}, [activeRunId]);
void tick;
if (!activeRunId || !runs) return null;
const active = runs.find((r) => r.id === activeRunId && r.status === 'running');
if (!active) return null;
const elapsed = Date.now() - new Date(active.started_at).getTime();
const completed = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
if (completed.length === 0) {
// No history: jump to the middle step so the bar advances visibly.
return Math.min(stepCount - 1, Math.max(0, Math.floor(stepCount / 2)));
}
const durations = completed.slice(0, 10).map((r) => new Date(r.finished_at!).getTime() - new Date(r.started_at).getTime());
const avg = durations.reduce((a, b) => a + b, 0) / durations.length || 1;
const ratio = Math.min(0.99, Math.max(0, elapsed / avg));
return Math.min(stepCount - 1, Math.floor(ratio * stepCount));
}
@@ -0,0 +1,771 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Snackbar from '@mui/material/Snackbar';
import CloseIcon from '@mui/icons-material/Close';
import EditIcon from '@mui/icons-material/EditOutlined';
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 {
closeWorkflowCard,
fetchRuns,
openWorkflowCard as openWorkflowCardAction,
rekeyOpenCard,
runWorkflowNow,
updateWorkflow,
updateWorkflowCard,
type Workflow,
} from '@/shared/state/workflowsSlice';
import {
DEFAULT_CARD_H,
DEFAULT_CARD_W,
placeCard,
rekeyWorkflowCard,
removeWorkflowCard,
setWorkflowCardPosition,
setWorkflowCardSize,
} from '@/shared/state/dashboardLayoutSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { fetchSession } from '@/shared/state/agentsSlice';
import WorkflowEditViews from './WorkflowEditViews';
import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews';
import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals';
import { store } from '@/shared/state/store';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const EDGE_THICKNESS = 6;
const CORNER_SIZE = 14;
const MIN_W = 360;
const MIN_H = 280;
const CURSOR_MAP: Record<ResizeDir, string> = {
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
};
// Resize handles sit at zIndex 25 so they win against the drag-header
// (zIndex 16). Same fix that landed on BrowserCard for the top edge.
const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
{ dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
];
interface Props {
workflowId: string;
cardX: number;
cardY: number;
cardWidth: number;
cardHeight: number;
cardZOrder?: number;
zoom?: number;
panX?: number;
panY?: number;
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void;
}
const WorkflowCard: React.FC<Props> = ({
workflowId,
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
isSelected = false, isHighlighted = false, multiDragDelta,
onCardSelect, onDragStart, onDragMove, onDragEnd, onDoubleClick, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const card = useAppSelector((s) => s.workflows.openCards[workflowId]);
const workflow = useAppSelector((s) => s.workflows.items[workflowId]);
const runs = useAppSelector((s) => s.workflows.runs[workflowId]);
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
// Transient "Starting…" label state on the Run button. See onClick handler
// for the full rationale (avoid no-feedback flicker on fast manual runs).
const [runStarting, setRunStarting] = useState(false);
const [runToast, setRunToast] = useState<string | null>(null);
const [editDirty, setEditDirty] = useState(false);
// First-success celebration: one tiny burst the first time the
// workflow ever reaches success. We track the celebration in localStorage
// keyed by workflow id so we don't repeat it across reloads.
const [celebrate, setCelebrate] = useState(false);
useEffect(() => {
if (!workflow || !runs || runs.length === 0) return;
const successes = runs.filter((r) => r.status === 'success');
if (successes.length !== 1) return;
const key = `openswarm:first-success:${workflow.id}`;
if (typeof localStorage !== 'undefined' && localStorage.getItem(key)) return;
setCelebrate(true);
try { localStorage.setItem(key, '1'); } catch { /* private mode etc. */ }
const t = window.setTimeout(() => setCelebrate(false), 2200);
return () => window.clearTimeout(t);
}, [workflow?.id, runs]);
// Lazy-load runs whenever a view that needs them is open. Saved view
// uses runs for the live-fill connector + step duration estimates;
// History views obviously need them too.
useEffect(() => {
if (!card) return;
const needsRuns = card.view === 'saved' || card.view === 'history' || card.view === 'history_detail';
if (needsRuns && workflow && !runs) {
dispatch(fetchRuns(workflow.id));
}
}, [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
// the chat-panel wheel guard in AgentChat.tsx. Ctrl/meta + wheel is
// intentionally allowed through so canvas zoom still works when the
// cursor is over a workflow card.
const bodyScrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = bodyScrollRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) return;
const atTop = el.scrollTop <= 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
const scrollingDown = e.deltaY > 0;
const scrollingUp = e.deltaY < 0;
if ((scrollingUp && atTop) || (scrollingDown && atBottom)) {
e.preventDefault();
}
e.stopPropagation();
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
const title = workflow?.title || card?.draft?.title || 'Workflow';
const isDraft = card?.view === 'preview' && !workflow;
const steps = (workflow?.steps || card?.draft?.steps || []) as Workflow['steps'];
// ---- Card drag via title bar ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
// Don't start a card-drag when the press lands on an interactive
// child (the close button, action chips, step inputs). The header
// also hosts the X icon — bailing here is what makes the X actually
// clickable (the old overlay's setPointerCapture swallowed the click).
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return;
e.preventDefault();
e.stopPropagation();
dragState.current = {
startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY,
startPanX: panRef.current.panX, startPanY: panRef.current.panY,
};
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(workflowId, 'workflow');
}, [cardX, cardY, onDragStart, workflowId]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
if (!ds || !didDrag.current) return;
const { clientX, clientY } = lastPointerRef.current;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const dx = (clientX - ds.startX) / z - panDx;
const dy = (clientY - ds.startY) / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
useEffect(() => {
if (isDragging && didDrag.current) recomputeDragPos();
}, [panX, panY, isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
recomputeDragPos();
}, [recomputeDragPos]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setWorkflowCardPosition({ workflowId, x: finalX, y: finalY }));
justDraggedRef.current = true;
requestAnimationFrame(() => { justDraggedRef.current = false; });
}
onDragEnd?.(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, workflowId, onDragEnd]);
// ---- Resize ----
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
origX: number; origY: number; origW: number; origH: number;
} | null>(null);
const [isResizing, setIsResizing] = useState(false);
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const handleResizeDown = useCallback(
(dir: ResizeDir) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizeRef.current = {
dir, startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY, origW: cardWidth, origH: cardHeight,
};
setIsResizing(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[cardX, cardY, cardWidth, cardHeight],
);
const computeResize = useCallback(
(e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
let newX = origX, newY = origY, newW = origW, newH = origH;
if (dir.includes('e')) newW = origW + dx;
if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; }
if (dir.includes('s')) newH = origH + dy;
if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; }
if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; }
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
return { x: newX, y: newY, w: newW, h: newH };
},
[zoom],
);
const handleResizeMove = useCallback(
(e: React.PointerEvent) => {
const result = computeResize(e);
if (result) setLocalResize(result);
},
[computeResize],
);
const handleResizeUp = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const result = computeResize(e);
if (result) {
dispatch(setWorkflowCardPosition({ workflowId, x: result.x, y: result.y }));
dispatch(setWorkflowCardSize({ workflowId, width: result.w, height: result.h }));
}
resizeRef.current = null;
setLocalResize(null);
setIsResizing(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [computeResize, dispatch, workflowId]);
// X just hides the card. Schedule keeps firing in the background; the
// user can re-open from the Workflows hub. A confirm dialog here was
// more friction than value (users clicked through it without reading).
const onClose = useCallback(() => {
dispatch(closeWorkflowCard(workflowId));
dispatch(removeWorkflowCard(workflowId));
}, [dispatch, workflowId]);
// ---- Display calculations ----
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
const displayW = localResize?.w ?? cardWidth;
const displayH = localResize?.h ?? cardHeight;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
if (!card) return null;
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'
: 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`
: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md;
return (
<Box
data-select-type="workflow-card"
data-select-id={workflowId}
data-select-meta={JSON.stringify({ name: title })}
onPointerDownCapture={() => onBringToFront?.(workflowId, 'workflow')}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
onCardSelect?.(workflowId, 'workflow', e.shiftKey);
}}
onDoubleClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDoubleClick?.(workflowId, 'workflow');
}}
sx={{
position: 'absolute',
contain: 'layout style',
willChange: 'transform',
left: displayX,
top: displayY,
width: displayW,
height: displayH,
borderRadius: '14px',
border,
bgcolor: c.bg.surface,
boxShadow: shadow,
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
}}
>
{/* ===== 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. */}
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 2, py: 1.4,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none',
flexShrink: 0,
zIndex: 16,
position: 'relative',
}}
>
<DragIndicatorIcon sx={{ fontSize: 18, color: c.text.muted }} />
{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.
<InputBase
data-no-drag
onPointerDown={(e) => 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 },
}}
/>
) : (
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '1rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: '-0.005em' }}>
{title}
</Typography>
)}
{runs && runs.length > 0 && <RunSparkline runs={runs} />}
<IconButton
size="small"
data-no-drag
onClick={(e) => { e.stopPropagation(); onClose(); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.5, color: c.text.secondary, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 17 }} />
</IconButton>
</Box>
{/* ===== 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 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0, opacity: 0.45, pointerEvents: 'none' }}>
<TabBtn label="Run" icon={<PlayArrowIcon sx={{ fontSize: 16 }} />} active={false} accent onClick={() => {}} />
<TabBtn label="Edit" icon={<EditIcon sx={{ fontSize: 16 }} />} active={false} onClick={() => {}} />
<TabBtn label="History" icon={<HistoryIcon sx={{ fontSize: 16 }} />} active={false} onClick={() => {}} />
<Box sx={{ flex: 1 }} />
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.82rem', fontWeight: 500, color: c.text.secondary }}>
<ScheduleIcon sx={{ fontSize: 14 }} />
Schedule this task
</Box>
</Box>
)}
{!isDraft && workflow && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
<TabBtn
label={runStarting ? 'Starting…' : 'Run'}
icon={<PlayArrowIcon sx={{ fontSize: 16 }} />}
active={false}
accent
breathe={!runStarting && isStaleSinceLastRun(workflow)}
breatheTooltip="Haven't run this in a few days. Click to run it now."
onClick={async () => {
if (runStarting) return;
setRunStarting(true);
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }));
try {
const result = await dispatch(runWorkflowNow(workflow.id));
await dispatch(fetchRuns(workflow.id));
// Detect skipped manual runs so the user gets a real
// explanation instead of a silent button-flicker. The
// most common skip today is the monthly cost cap.
if (runWorkflowNow.fulfilled.match(result)) {
const payload = result.payload;
if (payload.status === 'skipped' && payload.error) {
setRunToast(`Run skipped: ${payload.error}`);
}
}
} finally {
// Hold the "Starting…" label briefly so the user sees the
// state change even on fast runs. Without this the button
// flickers and feels like nothing happened.
setTimeout(() => setRunStarting(false), 600);
}
}}
/>
<TabBtn
label="Edit"
icon={<EditIcon sx={{ fontSize: 16 }} />}
active={card.view === 'edit'}
dot={editDirty}
dotTooltip="You have unsaved changes in this tab."
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: card.editFacet || 'General' } }))}
/>
<TabBtn
label="History"
icon={<HistoryIcon sx={{ fontSize: 16 }} />}
active={card.view === 'history' || card.view === 'history_detail'}
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))}
/>
<Box sx={{ flex: 1 }} />
{!workflow.schedule.enabled && (
<Box
role="button"
data-no-drag
onClick={() => {
const sched = workflow.schedule;
const next = {
...sched,
enabled: true,
repeat_unit: sched.repeat_unit || 'day',
repeat_every: sched.repeat_every || 1,
hour: sched.hour || 9,
minute: sched.minute || 0,
};
dispatch(updateWorkflow({
id: workflow.id,
patch: { schedule: next as any },
ifMatch: workflow.updated_at || null,
}));
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: 'Schedule' } }));
}}
onPointerDown={(e) => e.stopPropagation()}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.82rem', fontWeight: 500,
color: c.text.secondary,
cursor: 'pointer',
'&:hover': { color: c.accent.primary },
}}>
<ScheduleIcon sx={{ fontSize: 14 }} />
Schedule this task
</Box>
)}
</Box>
)}
{/* ===== Body — view-specific subview =====
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`. */}
<Box ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain', display: 'flex', flexDirection: 'column' }}>
{/* No AnimatePresence wrapper here on purpose: framer-motion's
crossfade was racing user-input events and stealing focus
from the title/description/step InputBases on every parent
re-render (Redux dispatches from selection/zOrder/etc.). The
tab body just swaps directly; the user doesn't notice the
missing crossfade. */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{card.view === 'preview' && (
<PreviewView
workflowId={workflowId}
steps={steps}
sourceSessionId={card.sourceSessionId || null}
initialDraft={card.draft || null}
onSaved={(wf) => {
// Migrate transient view state AND layout entry to the
// real workflow id so the card stays put visually.
dispatch(rekeyOpenCard({ oldId: workflowId, newId: wf.id }));
dispatch(rekeyWorkflowCard({ oldId: workflowId, newId: wf.id }));
dispatch(openWorkflowCardAction({
workflowId: wf.id,
sourceSessionId: card.sourceSessionId,
view: 'saved',
draft: null,
}));
}}
/>
)}
{card.view === 'saved' && workflow && (
<SavedView
workflow={workflow}
steps={steps}
runs={runs}
activeRunId={(runs || []).find((r) => r.status === 'running')?.id || null}
/>
)}
{card.view === 'edit' && workflow && (
<WorkflowEditViews
workflow={workflow}
facet={card.editFacet || 'General'}
onChangeFacet={(f) => dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))}
onDirtyChange={setEditDirty}
/>
)}
{card.view === 'history' && workflow && (
<HistoryList
runs={runs || []}
onOpen={async (run) => {
if (!run.session_id) {
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } }));
return;
}
const sid = run.session_id;
if (!store.getState().agents.sessions[sid]) {
try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* fall back to detail */ }
}
if (!store.getState().agents.sessions[sid]) {
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } }));
return;
}
if (!store.getState().dashboardLayout.cards[sid]) {
dispatch(placeCard({
sessionId: sid,
x: cardX + cardWidth + 60,
y: cardY,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
expandedSessionIds,
}));
}
dispatch(setPendingFocusAgentId(sid));
}}
/>
)}
{card.view === 'history_detail' && workflow && (
<HistoryDetail
run={(runs || []).find((r) => r.id === card.historyRunId) || null}
onBack={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))}
/>
)}
</Box>
</Box>
{/* ===== Resize handles ===== */}
{HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
className="resize-handle"
onPointerDown={handleResizeDown(dir)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeUp}
sx={{
position: 'absolute',
cursor: CURSOR_MAP[dir],
opacity: 0,
zIndex: 25,
...sx,
}}
/>
))}
{/* First-success celebration. Tiny CSS-only sparkle so we don't
pull in a confetti library. ~2s self-clears via the effect. */}
{celebrate && (
<Box sx={{ position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden', zIndex: 30 }}>
<Box sx={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
fontSize: '1.4rem', fontWeight: 700, color: c.accent.primary,
bgcolor: c.bg.surface, px: 1.2, py: 0.5, borderRadius: 999,
boxShadow: c.shadow.md,
animation: 'first-success-pop 1.4s ease-out forwards',
'@keyframes first-success-pop': {
'0%': { opacity: 0, transform: 'translate(-50%,-50%) scale(0.6)' },
'20%': { opacity: 1, transform: 'translate(-50%,-50%) scale(1.08)' },
'60%': { opacity: 1, transform: 'translate(-50%,-50%) scale(1.0)' },
'100%': { opacity: 0, transform: 'translate(-50%,-50%) scale(1.0)' },
},
}}>
🎉 First success
</Box>
</Box>
)}
{/* Toast for run outcomes that need explaining beyond the History
row (cost cap, "previous run still active," etc.). Auto-hides
after 6s; user can click anywhere to dismiss. */}
<Snackbar
open={Boolean(runToast)}
autoHideDuration={6000}
onClose={() => setRunToast(null)}
message={runToast || ''}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Box>
);
};
function TabBtn({ label, icon, active, accent, breathe, breatheTooltip, dot, dotTooltip, onClick }: { label: string; icon: React.ReactNode; active: boolean; accent?: boolean; breathe?: boolean; breatheTooltip?: string; dot?: boolean; dotTooltip?: string; onClick: () => void }) {
const c = useClaudeTokens();
const btn = (
<Box
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
role="button"
data-no-drag
sx={{
// Consistent visual weight across Run/Edit/History per target
// #54: identical padding + border thickness, matched 32px row
// height. `accent` (Run only) gets the colored text + tinted bg
// so it reads as the primary verb without screaming "selected".
// Tabs no longer flip the bg on `active`; the body view itself
// tells the user where they are.
display: 'inline-flex', alignItems: 'center', gap: 0.5,
px: 1.25, py: 0.5,
minHeight: 32,
fontSize: '0.82rem', fontWeight: 600,
whiteSpace: 'nowrap',
color: accent ? c.accent.primary : c.text.secondary,
bgcolor: accent ? c.accent.primary + '14' : 'transparent',
// Only the Run (accent) tab carries a border; Edit/History sit as
// quiet text-with-icon affordances so the primary verb stands out.
border: accent ? `1px solid ${c.accent.primary}50` : '1px solid transparent',
borderRadius: `${c.radius.md}px`,
cursor: 'pointer', userSelect: 'none',
'&:hover': { bgcolor: accent ? c.accent.primary + '22' : c.bg.elevated, borderColor: accent ? c.accent.primary : 'transparent' },
// Active state: nudge bg only when this is a non-accent tab so the
// user can still see "you're on this view". Run's accent styling
// already does that job; piling a darker bg on top reads as
// disabled.
...(active && !accent && {
color: c.text.primary,
bgcolor: c.bg.elevated,
}),
// Subtle "ready" breath when a stale workflow's Run button hasn't
// been touched in over 24h. ~3% scale + glow swell, slow enough
// to read as ambient rather than urgent. Tooltip is on so users
// don't think the button is malfunctioning.
...(breathe && {
animation: 'workflow-run-breath 3.2s ease-in-out infinite',
'@keyframes workflow-run-breath': {
'0%, 100%': { boxShadow: `0 0 0 ${c.accent.primary}00`, transform: 'scale(1)' },
'50%': { boxShadow: `0 0 14px ${c.accent.primary}55`, transform: 'scale(1.03)' },
},
}),
}}>
{icon}
{label}
{dot && (
<Box sx={{
width: 7, height: 7, borderRadius: '50%',
bgcolor: c.accent.primary,
ml: 0.25,
}} />
)}
</Box>
);
if (dot && dotTooltip) {
return <Tooltip title={dotTooltip}>{btn}</Tooltip>;
}
if (breathe && breatheTooltip) {
return <Tooltip title={breatheTooltip}>{btn}</Tooltip>;
}
return btn;
}
export default React.memo(WorkflowCard);
@@ -0,0 +1,625 @@
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 { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
closeWorkflowCard,
createWorkflow,
updateWorkflow,
updateWorkflowCard,
type Workflow,
type WorkflowRun,
} from '@/shared/state/workflowsSlice';
import { removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals';
import StepList from './StepList';
export function statusColor(s: string, c: ReturnType<typeof useClaudeTokens>): 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<typeof useClaudeTokens>): 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 (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
// Compact pill matching target #58/#63. Smaller padding + smaller
// glyphs so the buttons stop overshadowing the step body.
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.78rem', fontWeight: 600,
px: 1, py: 0.35,
borderRadius: 999,
cursor: disabled ? 'not-allowed' : 'pointer',
color: palette.color,
bgcolor: palette.bg,
border: `1px solid ${palette.border}`,
opacity: disabled ? 0.5 : 1,
'&:hover': { bgcolor: palette.hover },
}}>
{icon === 'trash' && (
<Box component="span" sx={{ display: 'inline-flex', fontSize: 12, lineHeight: 1 }}>{'\u{1F5D1}'}</Box>
)}
{icon === 'check' && (
<Box component="span" sx={{ display: 'inline-flex', fontSize: 12, lineHeight: 1 }}>{'✓'}</Box>
)}
{label}
</Box>
);
}
export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, onSaved }: {
workflowId: string;
steps: Workflow['steps'];
sourceSessionId: string | null;
initialDraft: Partial<Workflow> | 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<Workflow>;
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<Workflow['steps'] | null>(null);
const liveSteps = editedSteps || steps;
const onSave = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
const result = await dispatch(createWorkflow({
title,
description,
steps: liveSteps.map((s) => ({ id: s.id, text: s.text })),
source_session_id: sourceSessionId,
use_synced_prompt: true,
} as Partial<Workflow>));
const wf = (result as unknown as { payload: Workflow }).payload;
if (wf?.id) onSaved(wf);
} finally {
setBusy(false);
}
}, [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 (
// PreviewView visually matches SavedView (target image #107): same
// Scheduled / Permissions prose, same framed step boxes. Title +
// description come from the AI gen at save time; the user doesn't
// type a description here. Discard/Save sits in the bottom-right.
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Scheduled:</Typography>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>Not scheduled</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Permissions:</Typography>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>Notify me in Open Swarm</Typography>
</Box>
</Box>
{description && (
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.55, mt: 0.5 }}>
{description}
</Typography>
)}
<StepList steps={liveSteps} framed onChangeStep={onChangeStep} />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1, mt: 'auto' }}>
<ActionBtn label="Discard" tone="danger" icon="trash" onClick={onDiscard} />
<ActionBtn label="Save" tone="success" icon="check" onClick={onSave} disabled={busy} />
</Box>
</Box>
);
}
// 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;
// All steps editable inline. Each keystroke updates a local override
// map; Discard/Save surface as soon as any step diverges from the
// saved value. On Save we PATCH the full steps array, preserving ids.
const [localSteps, setLocalSteps] = useState<Record<number, string>>({});
const [savingFirst, setSavingFirst] = useState(false);
const firstStepDirty = useMemo(() => {
for (const k of Object.keys(localSteps)) {
const idx = Number(k);
const saved = steps[idx]?.text ?? '';
if (localSteps[idx] !== saved) return true;
}
return false;
}, [localSteps, steps]);
const editableSteps = useMemo(() => {
if (!firstStepDirty) return steps;
return steps.map((s, idx) => (idx in localSteps ? { ...s, text: localSteps[idx] } : s));
}, [firstStepDirty, steps, localSteps]);
const onChangeFirstStep = useCallback((idx: number, text: string) => {
setLocalSteps((prev) => ({ ...prev, [idx]: text }));
}, []);
const onSaveFirstStep = useCallback(async () => {
if (!firstStepDirty || savingFirst) return;
setSavingFirst(true);
try {
const nextSteps = steps.map((s, idx) => (idx in localSteps ? { ...s, text: localSteps[idx] } : s));
await dispatch(updateWorkflow({
id: workflow.id,
patch: { steps: nextSteps },
ifMatch: workflow.updated_at || null,
}));
setLocalSteps({});
} finally {
setSavingFirst(false);
}
}, [firstStepDirty, savingFirst, steps, localSteps, dispatch, workflow.id, workflow.updated_at]);
const onDiscardFirstStep = useCallback(() => setLocalSteps({}), []);
// 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
// user enables a schedule.
const habitSuggestion = useMemo(() => {
if (workflow.schedule.enabled) return null;
if (!runs || runs.length < 3) return null;
const cutoff = Date.now() - 7 * 86400000;
const recent = runs.filter((r) => r.triggered_by === 'manual' && new Date(r.started_at).getTime() >= cutoff);
if (recent.length < 3) return null;
// Pick the most common hour-of-day as the seed.
const hourCounts: Record<number, number> = {};
for (const r of recent) {
const h = new Date(r.started_at).getHours();
hourCounts[h] = (hourCounts[h] || 0) + 1;
}
const sorted = Object.entries(hourCounts).sort((a, b) => b[1] - a[1]);
const topHour = Number(sorted[0][0]);
const formatted = topHour < 12 ? `${topHour === 0 ? 12 : topHour}am` : `${topHour === 12 ? 12 : topHour - 12}pm`;
return { hour: topHour, label: `daily ${formatted}`, count: recent.length };
}, [workflow.schedule.enabled, runs]);
const enableHabit = useCallback(() => {
if (!habitSuggestion) return;
dispatch(updateWorkflow({
id: workflow.id,
patch: { schedule: { ...workflow.schedule, enabled: true, repeat_unit: 'day', repeat_every: 1, hour: habitSuggestion.hour, minute: 0 } as any },
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
{/* 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. */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Scheduled:</Typography>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>{describeSchedule(workflow)}</Typography>
<Box sx={{ flex: 1 }} />
{workflow.cost_estimate && workflow.cost_estimate.fires_per_month > 0 && (
<CostChip workflow={workflow} connectionMode={connectionMode} />
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Permissions:</Typography>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>{describePermissions(workflow)}</Typography>
</Box>
</Box>
<StreakBadgeRow runs={runs} />
{habitSuggestion && (
<Box sx={{
display: 'flex', alignItems: 'center', gap: 0.75,
px: 1, py: 0.5,
borderRadius: `${c.radius.md}px`,
bgcolor: c.accent.primary + '14',
border: `1px solid ${c.accent.primary}40`,
}}>
<Typography sx={{ flex: 1, fontSize: '0.78rem', color: c.text.primary }}>
You&apos;ve run this {habitSuggestion.count}× this week. Schedule it {habitSuggestion.label}?
</Typography>
<Box onClick={enableHabit} role="button" sx={{ fontSize: '0.74rem', fontWeight: 700, color: c.accent.primary, cursor: 'pointer', px: 0.5, '&:hover': { textDecoration: 'underline' } }}>
Yes
</Box>
</Box>
)}
{workflow.description && (
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.55, mt: 0.5 }}>
{workflow.description}
</Typography>
)}
<StepList
workflow={workflow}
steps={editableSteps}
runs={runs}
activeRunId={activeRunId}
framed
onChangeStep={onChangeFirstStep}
/>
{/* 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 ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1, mt: 'auto' }}>
<ActionBtn label="Discard" tone="danger" icon="trash" onClick={onDiscardFirstStep} />
<ActionBtn label={savingFirst ? 'Saving…' : 'Save'} tone="success" icon="check" disabled={savingFirst} onClick={onSaveFirstStep} />
</Box>
) : (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 'auto' }}>
<AuditTraceLink workflowId={workflow.id} />
</Box>
)}
</Box>
);
}
// 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 (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<StreakBadge runs={runs} />
</Box>
);
}
// 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<HTMLElement | null>(null);
const [entries, setEntries] = useState<Array<{ ts: string; who: string; diff: Record<string, { before: unknown; after: unknown }> }> | 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<HTMLDivElement>) => {
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 (
<>
<Tooltip title="Recent edits to this workflow">
<Box onClick={open} role="button" sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.7rem', color: c.text.muted, cursor: 'pointer',
px: 0.5, py: 0.25, borderRadius: 0.75,
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
}}>
<HistoryIcon sx={{ fontSize: 12 }} />
{entries === null ? 'edits' : `${count} edit${count === 1 ? '' : 's'}`}
</Box>
</Tooltip>
<Popover
open={Boolean(anchor)}
anchorEl={anchor}
onClose={close}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
<Box sx={{ minWidth: 280, maxWidth: 360, p: 1 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
RECENT EDITS
</Typography>
{loading && <Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>Loading</Typography>}
{!loading && (entries === null || entries.length === 0) && (
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>No edits yet.</Typography>
)}
{!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 (
<Box key={idx} sx={{ display: 'flex', flexDirection: 'column', py: 0.5, borderTop: idx === 0 ? 'none' : `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, fontWeight: 600 }}>{e.who || 'user'}</Typography>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost }}>{relTimeShort(e.ts)}</Typography>
</Box>
<Typography sx={{ fontSize: '0.74rem', color: c.text.secondary }}>{summary}</Typography>
</Box>
);
})}
</Box>
</Popover>
</>
);
}
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<string | null>(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 <Typography sx={{ fontSize: '0.88rem', color: c.text.muted, py: 1.5, textAlign: 'center' }}>No runs yet</Typography>;
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.75 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
{recent.map((r) => (
<Box key={r.id} sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: statusColor(r.status, c) }} />
))}
</Box>
<Box sx={{ flex: 1 }} />
{(['all', 'failure', 'ran_late'] as const).map((k) => (
<Box key={k} onClick={() => 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'}
</Box>
))}
</Box>
{groups.map(({ key, runs: gRuns }) => (
<Box key={key} sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mt: 0.5, mb: 0.25 }}>
{key.toUpperCase()}
</Typography>
{gRuns.map((r) => {
const expanded = expandedId === r.id;
const dur = runDuration(r);
return (
<Box key={r.id}>
<Box
onClick={() => 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 } }}>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: 0.75, minWidth: 64, textAlign: 'center' }}>
{labelForStatus(r.status)}
</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, flex: 1 }}>{formatRunDate(r.started_at)}</Typography>
{dur && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>{dur}</Typography>}
{r.cost_usd > 0 && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>${r.cost_usd.toFixed(4)}</Typography>}
{/* Chevron makes the row read as expandable instead of
static text. Rotates 180° while open so the affordance
stays visible after click. */}
<Box sx={{ fontSize: '0.7rem', color: c.text.ghost, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s ease' }}></Box>
</Box>
{expanded && (
<Box sx={{ ml: 8, mt: 0.25, mb: 0.75, p: 1, bgcolor: c.bg.elevated, borderRadius: 0.75, border: `1px solid ${c.border.subtle}` }}>
{r.error ? (
<Typography sx={{ fontSize: '0.78rem', color: c.status.error, lineHeight: 1.4 }}>{r.error}</Typography>
) : (
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.4 }}>
{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.
</Typography>
)}
<Box sx={{ mt: 0.5, display: 'flex', justifyContent: 'flex-end' }}>
<Box onClick={(e) => { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
See full conversation
</Box>
</Box>
</Box>
)}
</Box>
);
})}
</Box>
))}
</Box>
);
}
export function HistoryDetail({ run, onBack }: { run: WorkflowRun | null; onBack: () => void }) {
const c = useClaudeTokens();
if (!run) return <Typography sx={{ fontSize: '0.88rem', color: c.text.muted }}>Run not found</Typography>;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box onClick={onBack} role="button" sx={{ fontSize: '0.82rem', color: c.text.muted, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}> back</Box>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(run.status, c), bgcolor: statusBg(run.status, c), px: 0.8, py: 0.3, borderRadius: 0.75 }}>{labelForStatus(run.status)}</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, fontWeight: 600 }}>{formatRunDate(run.started_at)}</Typography>
</Box>
{run.error && (
<Typography sx={{ fontSize: '0.85rem', color: c.status.error, bgcolor: c.status.errorBg, p: 1, borderRadius: 0.75 }}>{run.error}</Typography>
)}
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, lineHeight: 1.5 }}>Started {formatRunDate(run.started_at)}, finished {run.finished_at ? formatRunDate(run.finished_at) : 'in progress'}.</Typography>
{run.session_id && (
<Box sx={{ fontSize: '0.82rem', color: c.accent.primary, mt: 0.5 }}>Session: {run.session_id.slice(0, 8)}</Box>
)}
</Box>
);
}
@@ -0,0 +1,149 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
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, HINT_FS, LABEL_FS } from './workflowEditCommon';
import GeneralFacet from './GeneralFacet';
import ActionsFacet from './ActionsFacet';
import ScheduleFacet from './ScheduleFacet';
interface Props {
workflow: Workflow;
facet: 'General' | 'Actions' | 'Schedule';
onChangeFacet: (facet: 'General' | 'Actions' | 'Schedule') => void;
// Lifted dirty state so the parent card can decorate the Edit tab with
// an unsaved-changes dot. Optional; older callers don't need to wire it.
onDirtyChange?: (dirty: boolean) => void;
}
export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDirtyChange }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [draft, setDraft] = useState<Workflow>(workflow);
const [busy, setBusy] = useState(false);
const [savedFlash, setSavedFlash] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(workflow), [draft, workflow]);
// Push the dirty flag up so the parent card can decorate the Edit tab.
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
// Clear the parent's flag on unmount so a closed editor doesn't leave
// a stale "you have unsaved changes" dot on the tab.
useEffect(() => () => { onDirtyChange?.(false); }, [onDirtyChange]);
// Save is explicit only. The previous auto-save raced the Save button:
// the user toggled a field, autosave fired 800ms later, dirty went
// false, and a manual Save click became a no-op.
const onSave = useCallback(async () => {
if (busy || !dirty) return;
const reason = validateDraft(draft);
if (reason) {
setSaveError(reason);
return;
}
setSaveError(null);
setBusy(true);
try {
// If-Match: pass the workflow's current updated_at so the backend
// can reject a stale write. Without this, two open windows or a
// mid-edit background fire silently clobber each other.
const result = await dispatch(updateWorkflow({
id: workflow.id,
patch: draft,
ifMatch: workflow.updated_at || null,
}));
if (updateWorkflow.fulfilled.match(result)) {
// Rebase the draft on the server's echoed copy. Without this,
// `dirty` would stay true after Save (because updated_at differs)
// and the user would see a phantom "unsaved" state.
const saved = result.payload as Workflow;
if (saved) setDraft(saved);
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1400);
} else if (result.payload?.kind === 'stale') {
setSaveError('This workflow was changed in another window or by a recent run. Discard to reload the latest, then re-apply your edits.');
} else {
setSaveError(result.payload?.message || 'Save failed. Please try again.');
}
} catch (e) {
setSaveError((e as Error)?.message || 'Save failed.');
} finally {
setBusy(false);
}
}, [busy, dirty, dispatch, workflow.id, workflow.updated_at, draft]);
const onDiscard = useCallback(() => {
setDraft(workflow);
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{/* 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. */}
{/* Match target image #111: left cluster (label + facet picker)
flush-left, action pills flush-right, generous breathing room
between. Gap inside each cluster stays tight so the two read
as two distinct groups, not five evenly-spaced chips. */}
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, py: 0.5 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, fontWeight: 500 }}>Currently Editing</Typography>
<Select
size="small"
value={facet}
onChange={(e) => onChangeFacet(e.target.value as Props['facet'])}
sx={{ fontSize: LABEL_FS, minWidth: 110, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="General">General</MenuItem>
<MenuItem value="Actions">Actions</MenuItem>
<MenuItem value="Schedule">Schedule</MenuItem>
</Select>
</Box>
<Box sx={{ flex: 1, minWidth: 24 }} />
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<ActionBtn
label="Discard"
tone="danger"
icon="trash"
disabled={!dirty || busy}
onClick={onDiscard}
/>
<Box sx={{ display: 'inline-flex', minWidth: 80, justifyContent: 'center' }}>
<ActionBtn
label={busy ? 'Saving…' : 'Save'}
tone="success"
icon="check"
disabled={!dirty || busy || saveState === 'saved'}
onClick={onSave}
/>
</Box>
</Box>
</Box>
{saveError && (
<Typography sx={{ fontSize: HINT_FS, color: c.status.error, bgcolor: c.status.errorBg, px: 1, py: 0.5, borderRadius: `${c.radius.md}px` }}>
{saveError}
</Typography>
)}
{facet === 'General' && <GeneralFacet draft={draft} setDraft={setDraft} />}
{facet === 'Actions' && <ActionsFacet draft={draft} setDraft={setDraft} />}
{facet === 'Schedule' && <ScheduleFacet draft={draft} setDraft={setDraft} />}
</Box>
);
}
@@ -0,0 +1,631 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import InputBase from '@mui/material/InputBase';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
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 {
addWorkflowCard,
closeWorkflowsHub,
setWorkflowsHubPosition,
setWorkflowsHubSize,
} from '@/shared/state/dashboardLayoutSlice';
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';
import ScheduleCalendar from './ScheduleCalendar';
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid } from './scheduleUtils';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const EDGE_THICKNESS = 6;
const CORNER_SIZE = 14;
const MIN_W = 720;
const MIN_H = 420;
const CURSOR_MAP: Record<ResizeDir, string> = {
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
};
const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
{ dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
];
interface Props {
cardX: number;
cardY: number;
cardWidth: number;
cardHeight: number;
cardZOrder?: number;
zoom?: number;
panX?: number;
panY?: number;
}
type CalendarView = 'Week' | 'Month' | 'List';
// Small badge in the hub header that adds up successful scheduled runs
// across all workflows and renders an approximate "time saved" figure.
// Heuristic: 3 minutes saved per scheduled run that the user would have
// otherwise done by hand. Not precise — meant as a quiet "you got back
// X hours" affirmation, not an audit number.
function TimeSavedBadge() {
const c = useClaudeTokens();
const runsByWorkflow = useAppSelector((s) => s.workflows.runs);
const items = useAppSelector((s) => s.workflows.items);
let count = 0;
for (const arr of Object.values(runsByWorkflow)) {
for (const r of arr) {
if (r.triggered_by === 'schedule' && (r.status === 'success' || r.status === 'ran_late')) count += 1;
}
}
// Fallback: if no runs are loaded yet (cards never opened), use
// last_run_status as a coarse proxy so brand-new users don't see 0.
if (count === 0) {
for (const w of Object.values(items)) {
if (w.last_run_status === 'success' || w.last_run_status === 'ran_late') count += 1;
}
}
if (count === 0) return null;
const totalMin = count * 3;
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 (
<Tooltip title={`${count} workflow runs completed for you. Rough estimate of ~3 min saved per run vs. doing it by hand.`}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
ml: 1, px: 0.85, py: 0.2,
fontSize: '0.74rem', fontWeight: 600,
color: c.text.secondary,
bgcolor: 'transparent',
border: `1px solid ${c.border.subtle}`,
borderRadius: 999,
}}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 14, height: 14, borderRadius: '50%', bgcolor: (c.status.success || c.accent.primary) + '22', color: c.status.success || c.accent.primary, fontSize: 9, fontWeight: 800 }}></Box>
<span style={{ color: c.text.primary }}>{count}</span>
<span style={{ color: c.text.muted }}>·</span>
<span style={{ color: c.text.secondary }}>{timeLabel} back</span>
</Box>
</Tooltip>
);
}
const WorkflowsHubCard: React.FC<Props> = ({
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const workflows = useAppSelector((s) => s.workflows.items);
const paused = useAppSelector((s) => s.workflows.paused);
useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]);
const togglePaused = useCallback(() => {
dispatch(setPausedAll(!paused));
}, [dispatch, paused]);
const [view, setView] = useState<CalendarView>('Week');
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), []);
// "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' });
const onSelectWorkflow = useCallback((wid: string) => {
dispatch(addWorkflowCard({ workflowId: wid }));
dispatch(openWorkflowCard({ workflowId: wid, view: 'saved' }));
}, [dispatch]);
const onNew = useCallback(() => {
const tempId = `draft-${Date.now()}`;
dispatch(addWorkflowCard({ workflowId: tempId }));
dispatch(openWorkflowCard({
workflowId: tempId,
view: 'preview',
draft: { title: 'New workflow', description: 'Describe what this workflow should do.', steps: [{ id: 'step-1', text: '' }] },
}));
}, [dispatch]);
// ---- Card drag via header ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const onHeaderPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return;
e.preventDefault();
e.stopPropagation();
dragState.current = {
startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY,
startPanX: panRef.current.panX, startPanY: panRef.current.panY,
};
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY]);
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
setLocalDragPos({
x: dragState.current.origX + rawDx / z - panDx,
y: dragState.current.origY + rawDy / z - panDy,
});
}, []);
const onHeaderPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setWorkflowsHubPosition({ x: finalX, y: finalY }));
}
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch]);
// ---- Resize ----
const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null);
const [isResizing, setIsResizing] = useState(false);
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizeRef.current = { dir, sx0: e.clientX, sy0: e.clientY, ox: cardX, oy: cardY, ow: cardWidth, oh: cardHeight };
setIsResizing(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, cardWidth, cardHeight]);
const compute = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current;
const dx = (e.clientX - sx0) / zoom;
const dy = (e.clientY - sy0) / zoom;
let nx = ox, ny = oy, nw = ow, nh = oh;
if (dir.includes('e')) nw = ow + dx;
if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; }
if (dir.includes('s')) nh = oh + dy;
if (dir.includes('n')) { nh = oh - dy; ny = oy + dy; }
if (nw < MIN_W) { if (dir.includes('w')) nx = ox + ow - MIN_W; nw = MIN_W; }
if (nh < MIN_H) { if (dir.includes('n')) ny = oy + oh - MIN_H; nh = MIN_H; }
return { x: nx, y: ny, w: nw, h: nh };
}, [zoom]);
const onResizeMove = useCallback((e: React.PointerEvent) => {
const r = compute(e);
if (r) setLocalResize(r);
}, [compute]);
const onResizeUp = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const r = compute(e);
if (r) {
dispatch(setWorkflowsHubPosition({ x: r.x, y: r.y }));
dispatch(setWorkflowsHubSize({ width: r.w, height: r.h }));
}
resizeRef.current = null;
setLocalResize(null);
setIsResizing(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [compute, dispatch]);
const dx = localResize?.x ?? localDragPos?.x ?? cardX;
const dy = localResize?.y ?? localDragPos?.y ?? cardY;
const dw = localResize?.w ?? cardWidth;
const dh = localResize?.h ?? cardHeight;
return (
<Box
data-select-type="workflows-hub-card"
sx={{
position: 'absolute',
contain: 'layout style',
willChange: 'transform',
left: dx,
top: dy,
width: dw,
height: dh,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.lg}px`,
boxShadow: (isDragging || isResizing) ? c.shadow.lg : c.shadow.md,
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
transition: (isDragging || isResizing) ? 'none' : 'box-shadow 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
}}
>
{/* ===== Title strip (drag handle) ===== */}
<Box
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 0.6,
px: 1.5, py: 0.6,
borderBottom: `1px solid ${c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none',
flexShrink: 0,
}}
>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18, color: c.accent.primary }}>
{/* CallSplit natively forks upward; rotated 90deg the fork
points right, matching the Workflows brand mark. */}
<CallSplitRoundedIcon sx={{ fontSize: 16, transform: 'rotate(90deg)' }} />
</Box>
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '0.88rem', color: c.text.primary }}>Workflows</Typography>
<IconButton
size="small"
data-no-drag
onClick={(e) => { e.stopPropagation(); dispatch(closeWorkflowsHub()); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.35, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
</Box>
{/* ===== Toolbar row (matches Figma image #8 header) ===== */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.65, px: 1.5, py: 0.7, borderBottom: `1px solid ${c.border.subtle}`, flexShrink: 0 }}>
<Tooltip title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}>
<IconButton size="small" data-no-drag onClick={() => setSidebarOpen((v) => !v)} sx={{ p: 0.5, color: sidebarOpen ? c.text.secondary : c.text.muted, '&:hover': { color: c.text.primary } }}>
<MenuIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Box
onClick={onNew}
role="button"
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.85rem', fontWeight: 600, color: c.text.primary,
bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}`,
px: 1, py: 0.4, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary },
}}
>
<AddIcon sx={{ fontSize: 14 }} />
New
</Box>
<Tooltip title={paused ? 'Scheduled runs are paused. In-flight runs will finish; new fires are blocked until you resume.' : 'Stop all future scheduled runs without disabling them one-by-one. Any run already in flight will finish.'}>
<Box
onClick={togglePaused}
role="button"
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4, ml: 0.5,
fontSize: '0.8rem', fontWeight: 600,
color: paused ? c.status.warning || c.accent.primary : c.text.secondary,
bgcolor: paused ? (c.status.warningBg || c.bg.elevated) : 'transparent',
border: `1px solid ${paused ? (c.status.warning || c.accent.primary) + '60' : c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
}}>
<Switch size="small" checked={paused} sx={{ pointerEvents: 'none', mr: -0.5, ml: -0.5 }} />
<span>{paused ? 'Paused' : 'Pause all'}</span>
</Box>
</Tooltip>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.75 }}>
<Box
onClick={() => setRefDate(new Date())}
role="button"
data-no-drag
sx={{
fontSize: '0.82rem', fontWeight: 500, color: c.text.secondary,
border: `1px solid ${c.border.subtle}`,
px: 1.1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
}}>Today</Box>
<IconButton size="small" data-no-drag onClick={() => setRefDate(addDays(refDate, view === 'Month' ? -28 : -7))} sx={{ p: 0.3 }}><ChevronLeftIcon sx={{ fontSize: 18 }} /></IconButton>
<IconButton size="small" data-no-drag onClick={() => setRefDate(addDays(refDate, view === 'Month' ? 28 : 7))} sx={{ p: 0.3 }}><ChevronRightIcon sx={{ fontSize: 18 }} /></IconButton>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>{monthLabel}</Typography>
<TimeSavedBadge />
</Box>
<IconButton size="small" data-no-drag sx={{ p: 0.5, color: c.text.muted }}>
<SearchIcon sx={{ fontSize: 18 }} />
</IconButton>
<Box sx={{ position: 'relative' }}>
<Box
onClick={() => setViewOpen((v) => !v)}
role="button"
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.25,
fontSize: '0.82rem', fontWeight: 500, color: c.text.secondary,
border: `1px solid ${c.border.subtle}`, px: 1, py: 0.35,
borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
}}>
{view}
<KeyboardArrowDownIcon sx={{ fontSize: 16 }} />
</Box>
{viewOpen && (
<Box sx={{ position: 'absolute', top: '100%', right: 0, mt: 0.5, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, boxShadow: c.shadow.md, zIndex: 5, minWidth: 110 }}>
{(['Week', 'Month', 'List'] as const).map((v) => (
<Box
key={v}
data-no-drag
onClick={() => { setView(v); setViewOpen(false); }}
sx={{ px: 1.25, py: 0.65, fontSize: '0.85rem', color: view === v ? c.accent.primary : c.text.primary, fontWeight: view === v ? 600 : 400, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
{v}
</Box>
))}
</Box>
)}
</Box>
</Box>
{/* ===== Body: sidebar + main calendar ===== */}
<Box sx={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* Sidebar */}
{sidebarOpen && (
<Box sx={{ width: 240, flexShrink: 0, borderRight: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ px: 1.5, pt: 1.25, pb: 0.75 }}>
<InputBase
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search workflows"
startAdornment={<SearchIcon sx={{ fontSize: 16, color: c.text.muted, mr: 0.75 }} />}
sx={{ fontSize: '0.82rem', color: c.text.primary, width: '100%', '& input::placeholder': { color: c.text.ghost, opacity: 1 } }}
/>
</Box>
<MiniMonth refDate={refDate} onPick={setRefDate} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, pb: 1.5 }}>
<SidebarSection title="Scheduled workflows" items={scheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
<SidebarSection title="Un-scheduled workflows" items={unscheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
</Box>
</Box>
)}
{/* Main calendar area */}
<Box sx={{ flex: 1, minWidth: 0, overflow: 'auto', p: 1.5 }}>
<ScheduleCalendar view={view} density="roomy" onSelectWorkflow={onSelectWorkflow} refDate={refDate} />
</Box>
</Box>
{/* Right-click menu shared across all sidebar workflow rows */}
<Menu
open={Boolean(sidebarCtxMenu)}
onClose={closeSidebarCtxMenu}
anchorReference="anchorPosition"
anchorPosition={sidebarCtxMenu ? { top: sidebarCtxMenu.y, left: sidebarCtxMenu.x } : undefined}>
<MenuItem onClick={() => {
if (!sidebarCtxMenu) return;
dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id));
closeSidebarCtxMenu();
}}>Run now</MenuItem>
<MenuItem onClick={() => {
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'}</MenuItem>
<MenuItem onClick={() => {
if (!sidebarCtxMenu) return;
dispatch(addWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id }));
dispatch(openWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id, view: 'edit', editFacet: 'Schedule' }));
closeSidebarCtxMenu();
}}>Edit</MenuItem>
<MenuItem
onClick={() => {
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
</MenuItem>
</Menu>
{/* Resize handles */}
{HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
className="resize-handle"
onPointerDown={onResizeDown(dir)}
onPointerMove={onResizeMove}
onPointerUp={onResizeUp}
sx={{ position: 'absolute', cursor: CURSOR_MAP[dir], opacity: 0, zIndex: 25, ...sx }}
/>
))}
</Box>
);
};
function MiniMonth({ refDate, onPick }: { refDate: Date; onPick: (d: Date) => void }) {
const c = useClaudeTokens();
const start = startOfMonthGrid(refDate);
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
const today = new Date();
const label = refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
return (
<Box sx={{ px: 1.5, pb: 1, borderBottom: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', py: 0.5 }}>
<Typography sx={{ flex: 1, fontSize: '0.82rem', fontWeight: 700, color: c.text.primary }}>{label}</Typography>
<IconButton size="small" data-no-drag onClick={() => onPick(addMonths(refDate, -1))} sx={{ p: 0.15 }}><ChevronLeftIcon sx={{ fontSize: 14 }} /></IconButton>
<IconButton size="small" data-no-drag onClick={() => onPick(addMonths(refDate, 1))} sx={{ p: 0.15 }}><ChevronRightIcon sx={{ fontSize: 14 }} /></IconButton>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
{WEEKDAY_LABEL.map((l, i) => (
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.66rem', color: c.text.muted, fontWeight: 600, py: 0.2 }}>{l}</Typography>
))}
{cells.map((d) => {
const isToday = sameDay(d, today);
const inMonth = d.getMonth() === refDate.getMonth();
const selected = sameDay(d, refDate);
return (
<Box key={d.toISOString()} onClick={() => onPick(d)} data-no-drag sx={{ textAlign: 'center', py: 0.2, opacity: inMonth ? 1 : 0.4, cursor: 'pointer' }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : selected ? c.accent.primary + '30' : 'transparent', color: isToday ? '#fff' : c.text.secondary, fontWeight: isToday ? 700 : 500, fontSize: '0.72rem' }}>{d.getDate()}</Box>
</Box>
);
})}
</Box>
</Box>
);
}
function SidebarSection({ title, items, onPick, scheduled, onContext }: {
title: string;
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 (
<Box sx={{ mt: 1.5 }}>
<Box
onClick={() => setOpen((v) => !v)}
role="button"
data-no-drag
sx={{ display: 'flex', alignItems: 'center', mb: 0.5, cursor: 'pointer', '&:hover .section-chev': { color: c.text.primary } }}>
<Typography sx={{ flex: 1, fontSize: '0.78rem', fontWeight: 700, color: c.text.secondary }}>{title}</Typography>
<KeyboardArrowDownIcon className="section-chev" sx={{ fontSize: 14, color: c.text.muted, transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 0.15s ease' }} />
</Box>
{open && items.length === 0 && (
<Typography sx={{ fontSize: '0.76rem', color: c.text.muted, fontStyle: 'italic', py: 0.5, pl: 0.5 }}>None yet</Typography>
)}
{open && items.map((w) => (
<Box
key={w.id}
onClick={() => 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 ? (
<Tooltip title={w.schedule.enabled ? 'Pause this schedule' : 'Resume this schedule'}>
<Box
onClick={(e) => 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 ? '✓' : ''}
</Box>
</Tooltip>
) : (
<AddIcon sx={{ fontSize: 13, color: c.text.muted, flexShrink: 0 }} />
)}
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: scheduled && !w.schedule.enabled ? 'line-through' : 'none', opacity: scheduled && !w.schedule.enabled ? 0.6 : 1 }}>{w.title}</Typography>
</Box>
))}
</Box>
);
}
function isSchedulable(w: Workflow): boolean {
if (w.schedule.enabled) return true;
// Heuristic: any prior config means the user already opened the
// Schedule facet and committed something. Pure defaults stay in
// "Un-scheduled" so brand-new workflows don't pollute the list.
const s = w.schedule;
return Boolean(s.on_days?.length || s.ends_at || s.max_runs || s.runs_count);
}
function match(title: string, query: string): boolean {
if (!query.trim()) return true;
return title.toLowerCase().includes(query.trim().toLowerCase());
}
function addMonths(d: Date, n: number): Date {
const x = new Date(d);
x.setMonth(x.getMonth() + n);
return x;
}
export default React.memo(WorkflowsHubCard);
@@ -0,0 +1,31 @@
import type { Workflow, PermissionTier } from '@/shared/state/workflowsSlice';
// Pre-save validation. Returns the first user-visible reason save should
// be blocked, or null when the draft is good to ship. Phone numbers on
// text/call tiers must be non-empty and at least 7 digits so the eventual
// SMS/voice bridge has something usable to dial.
export function validateDraft(draft: Workflow): string | null {
for (const tier of (draft.permissions || [])) {
if (tier.kind === 'notify') continue;
const cleaned = (tier.phone || '').replace(/[^\d+]/g, '');
if (!cleaned) {
return tier.kind === 'text'
? 'Add a phone number for the text-me tier.'
: 'Add a phone number for the call-me tier.';
}
if (cleaned.replace(/^\+/, '').length < 7) {
return `Phone number looks too short (${tier.kind} tier).`;
}
}
return null;
}
// Walk the existing permissions list and produce the next tier in the
// chain (notify -> text -> call). Returns null if we're already at call,
// which the UI uses to hide the "+ add backup" affordance.
export function nextTierAfter(tiers: PermissionTier[]): PermissionTier | null {
const last = tiers.length ? tiers[tiers.length - 1].kind : 'notify';
if (last === 'notify') return { kind: 'text', after_minutes: 5, phone: '' };
if (last === 'text') return { kind: 'call', after_minutes: 60, phone: '' };
return null;
}
@@ -0,0 +1,103 @@
// Lightweight text-to-schedule detector. Runs on agent replies (and user
// prompts) to surface a "Schedule this?" chip when the conversation has
// time-shaped language. Cheap regex pass, no LLM call. Returns the best
// matching preset or null. Conservative on purpose: a false positive
// shows a quietly-dismissable chip; a false negative just means the user
// uses the regular Schedule button.
import type { ScheduleConfig } from '@/shared/state/workflowsSlice';
import { defaultSchedule } from './scheduleUtils';
export interface DetectedSchedule {
schedule: ScheduleConfig;
presetLabel: string;
}
const HOUR_WORDS: Record<string, number> = {
morning: 9, noon: 12, afternoon: 14, evening: 18, night: 21, midnight: 0,
};
// 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<string, number> = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
export function detectSchedule(text: string): DetectedSchedule | null {
if (!text) return null;
const t = text.toLowerCase();
// Require either an explicit frequency keyword or a clear weekday +
// time pattern. Avoids false positives on stray "tomorrow at 9."
const isDaily = /\b(every ?day|each day|daily)\b/.test(t);
const isWeekdays = /\b(weekdays?|each weekday|every weekday|mon(?:day)?\s*(?:to|-|through|)\s*fri(?:day)?)\b/.test(t);
const isWeekly = /\b(every week|weekly|each week|once a week)\b/.test(t);
const isMonthly = /\b(every month|monthly|each month|once a month)\b/.test(t);
const dayMatches = Array.from(t.matchAll(DAY_RE)).map((m) => DAY_MAP[m[1].toLowerCase().slice(0, 3)]);
const hasExplicitDays = dayMatches.length > 0;
if (!isDaily && !isWeekdays && !isWeekly && !isMonthly && !hasExplicitDays) return null;
// Extract hour:minute.
let hour = 9;
let minute = 0;
let presetTimeWord: string | null = null;
for (const word of Object.keys(HOUR_WORDS)) {
if (t.includes(word)) { hour = HOUR_WORDS[word]; presetTimeWord = word; break; }
}
const hm = t.match(HOUR_RE);
if (hm) {
// 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;
if (Number.isFinite(h) && h >= 0 && h < 24) {
if (!presetTimeWord) { hour = h; minute = m; }
}
}
const base = defaultSchedule();
if (isMonthly) {
return {
schedule: { ...base, enabled: true, repeat_unit: 'month', repeat_every: 1, hour, minute },
presetLabel: `Every month at ${formatHour(hour, minute)}`,
};
}
if (isWeekdays) {
return {
schedule: { ...base, enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1, 2, 3, 4, 5], hour, minute },
presetLabel: `Weekdays at ${formatHour(hour, minute)}`,
};
}
if (isDaily) {
return {
schedule: { ...base, enabled: true, repeat_unit: 'day', repeat_every: 1, hour, minute },
presetLabel: `Every day at ${formatHour(hour, minute)}`,
};
}
if (hasExplicitDays || isWeekly) {
const days = Array.from(new Set(dayMatches.length ? dayMatches : [new Date().getDay()]));
days.sort();
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const label = days.length === 1 ? `Every ${dayNames[days[0]]} at ${formatHour(hour, minute)}` : `${days.map((d) => dayNames[d]).join('/')} at ${formatHour(hour, minute)}`;
return {
schedule: { ...base, enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: days, hour, minute },
presetLabel: label,
};
}
return null;
}
function formatHour(h: number, m: number): string {
const suffix = h < 12 ? 'am' : 'pm';
const h12 = ((h + 11) % 12) + 1;
return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, '0')}${suffix}`;
}
@@ -0,0 +1,171 @@
import type { Workflow, ScheduleConfig } from '@/shared/state/workflowsSlice';
export const WEEKDAY_LABEL = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
export const WEEKDAY_LABEL_SHORT = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
export const WEEKDAY_FULL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function defaultSchedule(): ScheduleConfig {
// Pick the host's IANA tz so new schedules start with an explicit zone
// instead of the legacy "local" sentinel. Backend storage still coerces
// "local" if a record predates this default; new records skip that path.
let tz = 'local';
try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'; } catch { /* keep 'local' */ }
return {
enabled: false,
repeat_every: 1,
repeat_unit: 'week',
on_days: [],
hour: 9,
minute: 0,
timezone: tz,
on_missed: 'skip',
ends_at: null,
max_runs: null,
runs_count: 0,
};
}
export function formatTime(hour: number, minute: number): string {
const h12 = ((hour + 11) % 12) + 1;
const suffix = hour < 12 ? 'am' : 'pm';
const mm = String(minute).padStart(2, '0');
return minute === 0 ? `${h12}${suffix}` : `${h12}:${mm}${suffix}`;
}
// Used in the roomy hub calendar: "10 AM", "12 PM", "1 PM"...
// Matches Figma image #8 styling for the left-column time labels.
export function formatHourLabel(hour: number): string {
const h12 = ((hour + 11) % 12) + 1;
const suffix = hour < 12 ? 'AM' : 'PM';
return `${h12} ${suffix}`;
}
export function describeSchedule(sched: ScheduleConfig): string {
if (!sched.enabled) return 'Not scheduled';
const time = formatTime(sched.hour, sched.minute);
if (sched.repeat_unit === 'day') {
return sched.repeat_every === 1 ? `Every day at ${time}` : `Every ${sched.repeat_every} days at ${time}`;
}
if (sched.repeat_unit === 'month') {
return sched.repeat_every === 1 ? `Every month at ${time}` : `Every ${sched.repeat_every} months at ${time}`;
}
const days = sched.on_days.length === 0 ? 'week' : sched.on_days
.slice()
.sort()
.map((d) => WEEKDAY_FULL[d])
.join(', ');
const cadence = sched.repeat_every === 1 ? `Every ${days}` : `Every ${sched.repeat_every} weeks on ${days}`;
return `${cadence} at ${time}`;
}
export function describePermissions(workflow: Workflow): string {
if (!workflow.permissions || workflow.permissions.length === 0) return 'Notify only';
const labels: string[] = [];
for (const p of workflow.permissions) {
if (p.kind === 'notify') labels.push('notify in app');
else if (p.kind === 'text') labels.push('text');
else if (p.kind === 'call') labels.push('call');
}
return `First ${labels.join(', then ')}`;
}
export function startOfWeek(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - d.getDay());
return d;
}
export function startOfMonthGrid(date: Date): Date {
const d = new Date(date.getFullYear(), date.getMonth(), 1);
d.setDate(d.getDate() - d.getDay());
return d;
}
export function sameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
export function addDays(date: Date, n: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + n);
return d;
}
function lastDayOfMonth(year: number, monthZeroBased: number): number {
// Date(year, month, 0) returns the last day of the previous month, so
// passing month+1 gives the last day of `monthZeroBased`. Matches the
// backend's calendar.monthrange behavior so the FE preview no longer
// clamps to day 28 (the old shared bug between this and previewNextRun).
return new Date(year, monthZeroBased + 1, 0).getDate();
}
export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = 40): Date[] {
const sched = workflow.schedule;
if (!sched.enabled) return [];
// Honor end conditions on the FE preview too, so the calendar doesn't
// paint pills for fires the backend will refuse to run. ends_at is an
// ISO string in workflow state; max_runs/runs_count are numbers.
if (sched.ends_at) {
const endsAt = new Date(sched.ends_at);
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);
if (effectiveCap === 0) return [];
const out: Date[] = [];
const cursor = new Date(from);
cursor.setHours(0, 0, 0, 0);
if (sched.repeat_unit === 'day') {
const step = Math.max(1, sched.repeat_every);
for (let i = 0; i < 366 && out.length < effectiveCap; i += step) {
const d = new Date(cursor);
d.setDate(d.getDate() + i);
d.setHours(sched.hour, sched.minute, 0, 0);
if (d >= from && d <= to) out.push(d);
if (d > to) break;
}
return out;
}
if (sched.repeat_unit === 'month') {
const startDay = from.getDate();
let year = from.getFullYear();
let month = from.getMonth();
let guard = 0;
while (out.length < effectiveCap && guard < 60) {
const day = Math.min(startDay, lastDayOfMonth(year, month));
const d = new Date(year, month, day, sched.hour, sched.minute, 0, 0);
if (d > to) break;
if (d >= from) out.push(d);
month += Math.max(1, sched.repeat_every);
year += Math.floor(month / 12);
month = ((month % 12) + 12) % 12;
guard += 1;
}
return out;
}
const allowed = sched.on_days.length ? sched.on_days : [from.getDay()];
for (let i = 0; i < 60 && out.length < effectiveCap; i += 1) {
const day = new Date(cursor);
day.setDate(day.getDate() + i);
if (!allowed.includes(day.getDay())) continue;
day.setHours(sched.hour, sched.minute, 0, 0);
if (day >= from && day <= to) out.push(day);
if (day > to) break;
}
return out;
}
@@ -0,0 +1,52 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import CheckIcon from '@mui/icons-material/Check';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export const BODY_FS = '0.88rem';
export const LABEL_FS = '0.82rem';
export const HINT_FS = '0.78rem';
export const INPUT_FS = '0.88rem';
export function FieldRow({ label, children, align }: { label: string; children: React.ReactNode; align?: 'top' | 'center' }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'flex', alignItems: align === 'top' ? 'flex-start' : 'center', gap: 1 }}>
<Typography sx={{ width: 100, flexShrink: 0, fontSize: LABEL_FS, color: c.text.secondary, mt: align === 'top' ? 0.75 : 0, fontWeight: 500 }}>{label}:</Typography>
{children}
</Box>
);
}
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 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 (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.45,
fontSize: LABEL_FS, fontWeight: 600, px: 1.25, py: 0.5,
borderRadius: 999,
cursor: disabled ? 'not-allowed' : 'pointer',
color: palette.color,
bgcolor: palette.bg,
border: `1px solid ${palette.border}`,
opacity: disabled ? 0.5 : 1,
'&:hover': { bgcolor: palette.hover },
}}>
{icon === 'trash' && <DeleteOutlineIcon sx={{ fontSize: 15 }} />}
{icon === 'check' && <CheckIcon sx={{ fontSize: 15 }} />}
{label}
</Box>
);
}
@@ -0,0 +1,503 @@
// Shared visual helpers for the workflow card UI tier: schedule/permission
// pill chips, status dot, run-status sparkline, step connector, step icon
// auto-classifier. Kept as plain functions/components so individual views
// can compose without owning the styling.
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import Popover from '@mui/material/Popover';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import { useAppDispatch } from '@/shared/hooks';
import { updateWorkflow } from '@/shared/state/workflowsSlice';
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
import NotificationsIcon from '@mui/icons-material/NotificationsRounded';
import SmsIcon from '@mui/icons-material/SmsRounded';
import PhoneInTalkIcon from '@mui/icons-material/PhoneInTalkRounded';
import EmailIcon from '@mui/icons-material/MailOutlineRounded';
import EventNoteIcon from '@mui/icons-material/EventNoteRounded';
import ChromeReaderModeIcon from '@mui/icons-material/ChromeReaderModeRounded';
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import CalendarTodayIcon from '@mui/icons-material/CalendarTodayRounded';
import ArticleIcon from '@mui/icons-material/ArticleRounded';
import LanguageIcon from '@mui/icons-material/LanguageRounded';
import AttachMoneyIcon from '@mui/icons-material/AttachMoneyRounded';
import AllInclusiveIcon from '@mui/icons-material/AllInclusiveRounded';
import CodeIcon from '@mui/icons-material/CodeRounded';
import SearchIcon from '@mui/icons-material/SearchRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { Workflow, WorkflowRun, ScheduleConfig, PermissionTier } from '@/shared/state/workflowsSlice';
import { formatTime, WEEKDAY_LABEL } from './scheduleUtils';
// ---------- Status colors ----------
export type LastRunStatus = NonNullable<Workflow['last_run_status']>;
export function statusDotColor(status: LastRunStatus | null | undefined, c: ReturnType<typeof useClaudeTokens>) {
switch (status) {
case 'success': return c.status.success;
case 'ran_late': return c.status.warning || '#f59e0b';
case 'failure': return c.status.error;
case 'running': return c.accent.primary;
case 'skipped': return c.text.muted;
default: return c.text.ghost;
}
}
// Human-readable status word. We surface "ran late" instead of the
// underscore-y "ran_late" everywhere it'd be visible to a user.
export function statusWord(status: LastRunStatus | null | undefined): string {
if (!status) return 'Never run';
if (status === 'ran_late') return 'Ran late';
return status.charAt(0).toUpperCase() + status.slice(1);
}
// Status pill rendered next to the title. Bigger than the previous 9px
// dot and pairs the color with a short word so a non-dev knows what
// they're looking at instead of squinting at a single grey pixel.
export function StatusDot({ status }: { status: LastRunStatus | null | undefined }) {
const c = useClaudeTokens();
const word = statusWord(status);
const dotColor = statusDotColor(status, c);
return (
<Tooltip title={status ? `Last run: ${word.toLowerCase()}` : 'This workflow has never run.'}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
height: 18, px: 0.6, borderRadius: 999,
bgcolor: status === 'failure' ? c.status.errorBg : status === 'ran_late' ? c.status.warningBg : status === 'success' ? c.status.successBg : c.bg.elevated,
border: `1px solid ${dotColor}55`,
flexShrink: 0,
}}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: dotColor, boxShadow: status === 'failure' ? `0 0 4px ${c.status.error}` : 'none' }} />
<Typography sx={{ fontSize: '0.66rem', fontWeight: 700, color: dotColor, letterSpacing: '0.02em' }}>
{word}
</Typography>
</Box>
</Tooltip>
);
}
// ---------- Pill chips ----------
function scheduleShort(sched: ScheduleConfig): string {
if (!sched.enabled) return 'Not scheduled';
const time = formatTime(sched.hour, sched.minute);
if (sched.repeat_unit === 'day') {
return sched.repeat_every === 1 ? `Daily ${time}` : `Every ${sched.repeat_every}d ${time}`;
}
if (sched.repeat_unit === 'month') {
return sched.repeat_every === 1 ? `Monthly ${time}` : `Every ${sched.repeat_every}mo ${time}`;
}
if (sched.on_days.length === 5 && [1, 2, 3, 4, 5].every((d) => sched.on_days.includes(d))) return `Weekdays ${time}`;
if (sched.on_days.length === 2 && [0, 6].every((d) => sched.on_days.includes(d))) return `Weekends ${time}`;
if (sched.on_days.length === 1) {
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return `${labels[sched.on_days[0]]} ${time}`;
}
if (sched.on_days.length === 0) return `Weekly ${time}`;
return `${sched.on_days.length}×/wk ${time}`;
}
// Weekday-dot strip "S M T W T F S" with active days filled. Rendered
// inline next to the chip when the schedule is weekly so users can
// pattern-match days without parsing prose. Active = filled accent dot.
export function WeekdayDots({ on_days }: { on_days: number[] }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, ml: 0.5 }}>
{WEEKDAY_LABEL.map((lbl, idx) => {
const active = on_days.includes(idx);
return (
<Box key={`${lbl}-${idx}`} sx={{
width: 12, height: 12, borderRadius: '50%',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.6rem', fontWeight: 700,
color: active ? '#fff' : c.text.ghost,
bgcolor: active ? c.accent.primary : 'transparent',
border: `1px solid ${active ? c.accent.primary : c.border.subtle}`,
lineHeight: 1,
}}>
{lbl}
</Box>
);
})}
</Box>
);
}
function permIcon(kind: PermissionTier['kind'], size = 13) {
if (kind === 'text') return <SmsIcon sx={{ fontSize: size }} />;
if (kind === 'call') return <PhoneInTalkIcon sx={{ fontSize: size }} />;
return <NotificationsIcon sx={{ fontSize: size }} />;
}
// Compact "🔔 → 💬 → 📞" representation of the escalation chain. Hover
// shows the literal prose (notify, text, call, with delays).
export function PermissionChip({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
const tiers = workflow.permissions || [];
if (tiers.length === 0) return null;
const label = tiers.map((t) => {
if (t.kind === 'notify') return 'notify in app';
const unit = t.kind === 'call' ? 'h' : 'm';
return `${t.kind} after ${t.after_minutes}${unit}`;
}).join(' → ');
return (
<Tooltip title={label}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.35,
fontSize: '0.74rem', fontWeight: 500,
color: c.text.secondary,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: 999,
}}>
{tiers.map((t, i) => (
<React.Fragment key={i}>
{permIcon(t.kind)}
{i < tiers.length - 1 && <Box sx={{ fontSize: '0.7rem', color: c.text.ghost, mx: 0.1 }}></Box>}
</React.Fragment>
))}
</Box>
</Tooltip>
);
}
export function ScheduleChip({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const enabled = workflow.schedule.enabled;
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
// Inline edit: time + AM/PM only. Anything richer should open the
// full editor. Saves on change with optimistic updated_at If-Match.
const sched = workflow.schedule;
const patchSched = (patch: Partial<typeof sched>) => {
const next = { ...sched, ...patch };
dispatch(updateWorkflow({
id: workflow.id,
patch: { schedule: next as any },
ifMatch: workflow.updated_at || null,
}));
};
return (
<>
<Tooltip title={enabled ? `Click to tweak time. Full editor lives in the Edit tab.` : 'Not scheduled'}>
<Box
onClick={(e) => enabled && setAnchor(e.currentTarget as HTMLElement)}
role={enabled ? 'button' : undefined}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.74rem', fontWeight: 600,
color: enabled ? c.accent.primary : c.text.muted,
bgcolor: enabled ? c.accent.primary + '14' : c.bg.elevated,
border: `1px solid ${enabled ? c.accent.primary + '40' : c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: 999,
cursor: enabled ? 'pointer' : 'default',
'&:hover': enabled ? { bgcolor: c.accent.primary + '22' } : undefined,
}}>
<ScheduleIcon sx={{ fontSize: 13 }} />
{scheduleShort(workflow.schedule)}
{enabled && workflow.schedule.repeat_unit === 'week' && (
<WeekdayDots on_days={workflow.schedule.on_days} />
)}
</Box>
</Tooltip>
<Popover
open={Boolean(anchor)}
anchorEl={anchor}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}>
<Box sx={{ p: 1, display: 'flex', flexDirection: 'column', gap: 0.5, minWidth: 220 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em' }}>
QUICK TIME EDIT
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Select
size="small"
value={((sched.hour + 11) % 12) + 1}
onChange={(e) => {
const h12 = Number(e.target.value);
const isPm = sched.hour >= 12;
patchSched({ hour: (h12 % 12) + (isPm ? 12 : 0) });
}}
sx={{ fontSize: '0.78rem', '& .MuiSelect-select': { py: 0.4 } }}>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<MenuItem key={h} value={h}>{h}</MenuItem>
))}
</Select>
<Typography sx={{ fontSize: '0.85rem' }}>:</Typography>
<Select
size="small"
value={sched.minute}
onChange={(e) => patchSched({ minute: Number(e.target.value) })}
sx={{ fontSize: '0.78rem', '& .MuiSelect-select': { py: 0.4 } }}>
{[0, 15, 30, 45].map((m) => (
<MenuItem key={m} value={m}>{String(m).padStart(2, '0')}</MenuItem>
))}
</Select>
<Select
size="small"
value={sched.hour < 12 ? 'AM' : 'PM'}
onChange={(e) => {
const wasPm = sched.hour >= 12;
const willBePm = e.target.value === 'PM';
if (wasPm === willBePm) return;
patchSched({ hour: willBePm ? sched.hour + 12 : sched.hour - 12 });
}}
sx={{ fontSize: '0.78rem', '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="AM">AM</MenuItem>
<MenuItem value="PM">PM</MenuItem>
</Select>
</Box>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, mt: 0.25 }}>
Saved as you change.
</Typography>
</Box>
</Popover>
</>
);
}
// Classify a workflow's billing route based on its model id + the user's
// global connection mode. Mirrors the per-session logic in AgentChat so
// the workflow card tells the same story the chat header does. Returns
// 'metered' when the user pays per call (Anthropic/OpenAI/Gemini API
// keys, custom OpenAI-compatible) or 'subscription' when a flat-rate
// account is doing the work (Claude Pro/Max, ChatGPT Plus/Pro, Gemini
// Advanced, OpenSwarm Pro proxy). `subLabel` names the plan for tooltips.
export type RoutingKind = 'metered' | 'subscription';
export interface Routing {
kind: RoutingKind;
subLabel?: string;
}
export function routingFor(model: string, connectionMode: string | undefined): Routing {
const m = (model || '').toLowerCase();
if (m.endsWith('-api')) return { kind: 'metered' };
if (m.endsWith('-cc')) return { kind: 'subscription', subLabel: 'Claude Pro/Max' };
const isPlainAnthropic = m === 'sonnet' || m === 'opus' || m === 'haiku';
if (isPlainAnthropic && connectionMode === 'openswarm-pro') {
return { kind: 'subscription', subLabel: 'OpenSwarm Pro' };
}
if (isPlainAnthropic) return { kind: 'metered' };
if (m.startsWith('gpt-5') || m.startsWith('gpt-4') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4')) {
return { kind: 'subscription', subLabel: 'ChatGPT Plus/Pro' };
}
if (m.startsWith('gemini-')) {
return { kind: 'subscription', subLabel: 'Gemini Advanced' };
}
// Unknown model id, default to metered so we don't oversell "free."
return { kind: 'metered' };
}
export function CostChip({ workflow, connectionMode }: { workflow: Workflow; connectionMode?: string }) {
const c = useClaudeTokens();
const est = workflow.cost_estimate;
const route = routingFor(workflow.model, connectionMode);
// Subscription-routed workflows have no metered per-call cost. Surface
// a usage chip instead so the user knows runs are "free" under their
// existing plan but still sees the projected fire frequency.
if (route.kind === 'subscription') {
if (!est || est.fires_per_month === 0) {
return (
<Tooltip title={`Runs are covered by your ${route.subLabel} plan. No upcoming runs scheduled.`}>
<Box sx={chipSx(c)}>
<AllInclusiveIcon sx={{ fontSize: 12 }} />
{route.subLabel || 'Subscription'}
</Box>
</Tooltip>
);
}
return (
<Tooltip title={`Routed through your ${route.subLabel} plan; no per-run cost. About ${est.fires_per_month} runs per month at the current schedule.`}>
<Box sx={chipSx(c)}>
<AllInclusiveIcon sx={{ fontSize: 12 }} />
~{est.fires_per_month} runs/mo
</Box>
</Tooltip>
);
}
// Metered route: only render the cost chip once we actually have a
// last-run figure to project from. Avoids "$0.00/mo" gaslighting.
if (!est || est.fires_per_month === 0 || est.last_run_usd <= 0) return null;
const monthly = est.monthly_usd || 0;
return (
<Tooltip title={`About $${est.last_run_usd.toFixed(4)} per run, times ${est.fires_per_month} runs per month.`}>
<Box sx={chipSx(c)}>
<AttachMoneyIcon sx={{ fontSize: 12, ml: -0.25 }} />
{monthly < 0.01 ? '<0.01' : monthly.toFixed(2)}/mo
</Box>
</Tooltip>
);
}
function chipSx(c: ReturnType<typeof useClaudeTokens>) {
return {
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.74rem', fontWeight: 600,
color: c.text.secondary,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
px: 0.75, py: 0.3, borderRadius: 999,
} as const;
}
// Compact "last fired" mini-label, used inside the Run-tab summary.
export function LastFiredHint({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
if (!workflow.last_run_at) return null;
const ms = Date.now() - new Date(workflow.last_run_at).getTime();
const ago = relTime(ms);
return (
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost }}>Last ran {ago}</Typography>
);
}
function relTime(ms: number): string {
if (ms < 0) return 'just now';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
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);
if (d < 30) return `${d}d ago`;
const mo = Math.floor(d / 30);
return `${mo}mo ago`;
}
// ---------- Run history sparkline ----------
// 10-dot horizontal strip of last N runs colored by status. Easy "lately
// healthy?" check without opening the History tab. Tooltip names the
// pattern out loud so a non-dev knows the dots aren't decorative.
export function RunSparkline({ runs, max = 10 }: { runs: WorkflowRun[]; max?: number }) {
const c = useClaudeTokens();
if (!runs || runs.length === 0) return null;
const slice = runs.slice(0, max).reverse();
const successes = slice.filter((r) => r.status === 'success').length;
const failures = slice.filter((r) => r.status === 'failure').length;
const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} ok, ${failures} failed (oldest left → newest right). Green = success, red = failure, amber = ran late.`;
return (
<Tooltip title={tooltip}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.3, ml: 0.5 }}>
{slice.map((r) => (
<Box key={r.id} sx={{
width: 6, height: 6, borderRadius: '50%',
bgcolor: statusDotColor(r.status as LastRunStatus, c),
}} />
))}
</Box>
</Tooltip>
);
}
// ---------- Streak badge ----------
// Count consecutive successful runs at the head of the runs list.
// `runs[0]` is the most recent run, so we walk forward until we hit a
// non-success. Returns 0 when no streak is active.
export function successStreak(runs: WorkflowRun[] | undefined): number {
if (!runs || runs.length === 0) return 0;
let n = 0;
for (const r of runs) {
if (r.status === 'success' || r.status === 'ran_late') n += 1;
else break;
}
return n;
}
export function StreakBadge({ runs }: { runs: WorkflowRun[] | undefined }) {
const c = useClaudeTokens();
const n = successStreak(runs);
if (n < 3) return null;
return (
<Tooltip title={`${n} successful runs in a row.`}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.72rem', fontWeight: 700,
color: c.status.warning || '#f59e0b',
bgcolor: (c.status.warningBg || c.bg.elevated),
border: `1px solid ${(c.status.warning || '#f59e0b') + '60'}`,
px: 0.7, py: 0.2, borderRadius: 999,
}}>
🔥 {n}
</Box>
</Tooltip>
);
}
// ---------- Step icon auto-classifier ----------
// Pick a glyph by keyword scan of the step text. Falls back to the
// step number when nothing matches. Same Roman-numeral simple heuristic
// the user sees: "summarize email" -> mail icon, "make notion page" ->
// article icon, etc.
const ICON_RULES: Array<{ pattern: RegExp; Icon: React.ElementType }> = [
{ pattern: /\b(email|inbox|gmail|outlook|mail)\b/i, Icon: EmailIcon },
{ pattern: /\b(calendar|schedule|event|meeting)\b/i, Icon: CalendarTodayIcon },
{ pattern: /\b(notion|doc|page|page template|document|article)\b/i, Icon: ArticleIcon },
{ pattern: /\b(text|sms|message|whatsapp|imessage)\b/i, Icon: SmsIcon },
{ pattern: /\b(call|phone|dial|ring)\b/i, Icon: PhoneInTalkIcon },
{ pattern: /\b(browser|web|website|url|fetch|visit|navigate)\b/i, Icon: LanguageIcon },
{ pattern: /\b(search|find|look up|google)\b/i, Icon: SearchIcon },
{ pattern: /\b(code|github|repo|script|bash|run)\b/i, Icon: CodeIcon },
{ pattern: /\b(read|review|summarize|summary)\b/i, Icon: ChromeReaderModeIcon },
{ pattern: /\b(chat|reply|respond|dm)\b/i, Icon: ChatBubbleOutlineIcon },
{ pattern: /\b(note|memo|journal|log)\b/i, Icon: EventNoteIcon },
];
export function stepIconFor(text: string): React.ElementType | null {
for (const rule of ICON_RULES) {
if (rule.pattern.test(text)) return rule.Icon;
}
return null;
}
// ---------- Step duration learner ----------
// Estimates per-step duration by averaging recent runs. Today we only
// have whole-run duration on each WorkflowRun (started_at -> finished_at),
// so the heuristic spreads it evenly across the step count. When per-step
// telemetry lands later, swap this for a per-step lookup.
export function estimateStepDuration(workflow: Workflow, runs: WorkflowRun[] | undefined, stepIdx: number): string | null {
if (!runs || runs.length === 0) return null;
const steps = workflow.steps?.length || 1;
const successful = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
if (successful.length === 0) return null;
const durations = successful.slice(0, 10).map((r) => {
const start = new Date(r.started_at).getTime();
const end = new Date(r.finished_at!).getTime();
return Math.max(0, end - start);
});
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
const perStepMs = avg / steps;
void stepIdx;
return humanDuration(perStepMs);
}
export function humanDuration(ms: number): string {
if (ms < 1000) return '<1s';
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return rem > 0 && m < 5 ? `${m}m ${rem}s` : `${m}m`;
}
// ---------- Run-button breath logic ----------
// Returns true when the workflow hasn't been run in over 24h. Used by
// the Run tab to add a subtle CSS breathing animation so the button
// invites use without yelling.
export function isStaleSinceLastRun(workflow: Workflow): boolean {
if (!workflow.last_run_at) return false;
const age = Date.now() - new Date(workflow.last_run_at).getTime();
return age > 24 * 3600 * 1000;
}
@@ -10,20 +10,31 @@ export function useKeyboardShortcuts() {
const handler = useCallback(
(e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isInput =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable;
const target = e.target as HTMLElement | null;
const active = document.activeElement as HTMLElement | null;
// Double-guard: e.target AND document.activeElement. A bare-letter
// shortcut would otherwise fire if focus is on a wrapper Box and the
// child input never received it, kicking the user out mid-type.
const isInputLike = (el: HTMLElement | null) =>
!!el && (
el.tagName === 'INPUT' ||
el.tagName === 'TEXTAREA' ||
el.isContentEditable ||
!!el.closest('input, textarea, [contenteditable="true"]')
);
if (isInputLike(target) || isInputLike(active)) return;
if (isInput) return;
if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
// Mod-gated shortcuts only. Bare letters were footguns: typing the
// letter "d" anywhere outside a tagged input field used to navigate
// home, which surprised users typing workflow titles/descriptions.
if (e.key.toLowerCase() === 'd' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
e.preventDefault();
navigate('/');
return;
}
if (e.key === 'A' && e.shiftKey && !e.metaKey && !e.ctrlKey) {
if (e.key === 'A' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
for (const session of Object.values(sessions)) {
for (const req of session.pending_approvals) {
dispatch(handleApproval({ requestId: req.id, behavior: 'allow' }));
@@ -32,7 +43,8 @@ export function useKeyboardShortcuts() {
return;
}
if (e.key === 'D' && e.shiftKey && !e.metaKey && !e.ctrlKey) {
if (e.key === 'D' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
for (const session of Object.values(sessions)) {
for (const req of session.pending_approvals) {
dispatch(handleApproval({ requestId: req.id, behavior: 'deny' }));
@@ -41,7 +53,8 @@ export function useKeyboardShortcuts() {
return;
}
if (e.key >= '1' && e.key <= '9' && !e.metaKey && !e.ctrlKey) {
if (e.key >= '1' && e.key <= '9' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
e.preventDefault();
const idx = parseInt(e.key) - 1;
const sessionList = Object.values(sessions).sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
+324 -59
View File
@@ -2,16 +2,14 @@ import { createSlice, createAsyncThunk, PayloadAction, createAction } from '@red
import { launchAndSendFirstMessage } from './agentsSlice';
import { API_BASE } from '@/shared/config';
// Cross-slice listener: when agentsSlice's fetchSession thunk rejects
// with a 404/410, the session is gone server-side. We strip the card
// from layout here so AgentChat doesn't keep re-mounting + re-fetching
// the same dead id in a loop (the visible "404 spam" in dev logs).
// Matching the rejected-thunk action type literally avoids a circular
// import on the thunk's reject metadata.
// fetchSession 404/410 strips the layout card to stop AgentChat remount-loop. Matched by string to avoid circular import.
const fetchSessionRejectedAction = createAction<
{ sessionId?: string; status?: number } | undefined
>('agents/fetchSession/rejected');
// Cascade workflow delete to layout so the "Make workflow" tether stops pointing at empty space.
const deleteWorkflowFulfilledAction = createAction<string>('workflows/delete/fulfilled');
const DASHBOARDS_API = `${API_BASE}/dashboards`;
export const DEFAULT_CARD_W = 480;
@@ -20,6 +18,10 @@ export const DEFAULT_VIEW_CARD_W = 1280;
export const DEFAULT_VIEW_CARD_H = 800;
export const DEFAULT_BROWSER_CARD_W = 1280;
export const DEFAULT_BROWSER_CARD_H = 800;
export const DEFAULT_WORKFLOW_CARD_W = 440;
export const DEFAULT_WORKFLOW_CARD_H = 520;
export const DEFAULT_WORKFLOWS_HUB_W = 1200;
export const DEFAULT_WORKFLOWS_HUB_H = 640;
export const EXPANDED_CARD_MIN_H = 620;
export const GRID_GAP = 24;
const GRID_ORIGIN = { x: 40, y: 100 };
@@ -60,12 +62,29 @@ export interface BrowserCardPosition {
width: number;
height: number;
zOrder: number;
// Agent session id that spawned this browser. null/undefined for
// user-created. Used to auto-remove the browser when its owner agent
// reaches a terminal completed/error state.
/** Agent session that spawned this browser; auto-removed when its owner reaches terminal state. */
spawned_by?: string | null;
}
export interface WorkflowCardPosition {
workflow_id: string;
x: number;
y: number;
width: number;
height: number;
zOrder: number;
source_session_id?: string | null;
}
/** Singleton per dashboard; only one Workflows Hub card open at a time. */
export interface WorkflowsHubPosition {
x: number;
y: number;
width: number;
height: number;
zOrder: number;
}
export type NoteColor = 'yellow' | 'pink' | 'blue' | 'green' | 'purple' | 'gray';
export interface NotePosition {
@@ -82,10 +101,21 @@ export interface NotePosition {
export const DEFAULT_NOTE_W = 240;
export const DEFAULT_NOTE_H = 200;
export interface ConfigurePanelPosition {
workflow_id: string;
x: number;
y: number;
width: number;
height: number;
}
export interface DashboardLayoutState {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
configurePanels: Record<string, ConfigurePanelPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
closedCardPositions: Record<string, CardPosition>;
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
@@ -94,18 +124,21 @@ export interface DashboardLayoutState {
nextZOrder: number;
loading: boolean;
initialized: boolean;
// Transient signal: when a new browser card is created via addBrowserCard
// (link click, "+ Browser" button, pending URL flow), the reducer sets this
// to the new card's id. Dashboard.tsx watches it and pans/zooms the canvas
// to center on the new card, then dispatches clearPendingFocusBrowserId.
/** Transient: new browser card id; Dashboard pans/zooms to it then clears via clearPendingFocusBrowserId. */
pendingFocusBrowserId: string | null;
pendingFocusNoteId: string | null;
pendingFocusWorkflowId: string | null;
/** Transient: signals Dashboard to pan/zoom to the singleton Workflows Hub on open. */
pendingFocusWorkflowsHub: boolean;
}
const initialState: DashboardLayoutState = {
cards: {},
viewCards: {},
browserCards: {},
workflowCards: {},
configurePanels: {},
workflowsHub: null,
notes: {},
closedCardPositions: {},
glowingBrowserCards: {},
@@ -116,12 +149,17 @@ const initialState: DashboardLayoutState = {
initialized: false,
pendingFocusBrowserId: null,
pendingFocusNoteId: null,
pendingFocusWorkflowId: null,
pendingFocusWorkflowsHub: false,
};
interface LayoutPayload {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
configurePanels: Record<string, ConfigurePanelPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
expandedSessionIds: string[];
}
@@ -154,6 +192,9 @@ export const fetchLayout = createAsyncThunk(
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
browserCards: browserCards as Record<string, BrowserCardPosition>,
workflowCards: (layout.workflow_cards ?? {}) as Record<string, WorkflowCardPosition>,
configurePanels: (layout.configure_panels ?? {}) as Record<string, ConfigurePanelPosition>,
workflowsHub: (layout.workflows_hub ?? null) as WorkflowsHubPosition | null,
notes: (layout.notes ?? {}) as Record<string, NotePosition>,
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
} satisfies LayoutPayload;
@@ -175,6 +216,9 @@ export const saveLayout = createAsyncThunk(
cards: payload.cards,
view_cards: payload.viewCards,
browser_cards: payload.browserCards,
workflow_cards: payload.workflowCards,
configure_panels: payload.configurePanels,
workflows_hub: payload.workflowsHub,
notes: payload.notes,
expanded_session_ids: payload.expandedSessionIds,
},
@@ -211,6 +255,12 @@ function collectOccupiedRects(
for (const c of Object.values(state.browserCards)) {
rects.push({ x: c.x, y: c.y, w: c.width, h: c.height });
}
for (const w of Object.values(state.workflowCards)) {
rects.push({ x: w.x, y: w.y, w: w.width, h: w.height });
}
if (state.workflowsHub) {
rects.push({ x: state.workflowsHub.x, y: state.workflowsHub.y, w: state.workflowsHub.width, h: state.workflowsHub.height });
}
for (const n of Object.values(state.notes)) {
rects.push({ x: n.x, y: n.y, w: n.width, h: n.height });
}
@@ -241,18 +291,7 @@ export function findOpenGridCell(
}
}
// Like findOpenGridCell but biased to stay near a proposed (x,y) anchor.
// Used when the backend hands us a card with a position that's already
// occupied (sub-agent or sub-browser spawning on top of its parent or a
// sibling). Spirals outward from the anchor on a grid, snapping to
// cell-aligned positions so the result still looks intentional, not
// dropped from orbit. Caps the spiral search at ~1000 cells to avoid
// pathological work in adversarial layouts — falls back to
// findOpenGridCell after that.
//
// Cost: O(rects × cells_scanned). Spawn events are rare (not per-frame),
// so this only runs when a new card appears. Typical scan resolves in
// <10 cells, well below the cap. No perf impact on steady-state UI.
/** findOpenGridCell variant biased toward an (x,y) anchor; spiral search capped at ring=32. */
export function findOpenSpotNear(
anchorX: number,
anchorY: number,
@@ -262,7 +301,7 @@ export function findOpenSpotNear(
): { x: number; y: number } {
const cellW = DEFAULT_CARD_W + GRID_GAP;
const cellH = DEFAULT_CARD_H + GRID_GAP;
// Snap the anchor to the nearest grid cell so all cards align cleanly.
// Snap the anchor to the nearest grid cell so cards align.
const baseCol = Math.round((anchorX - GRID_ORIGIN.x) / cellW);
const baseRow = Math.round((anchorY - GRID_ORIGIN.y) / cellH);
@@ -273,7 +312,6 @@ export function findOpenSpotNear(
return !occupiedRects.some((r) => rectsOverlap(candidate, r));
};
// Try the anchor itself first.
if (cellFree(baseCol, baseRow)) {
return {
x: GRID_ORIGIN.x + baseCol * cellW,
@@ -281,18 +319,14 @@ export function findOpenSpotNear(
};
}
// Spiral search: expand rings around the anchor. Each ring r covers
// the perimeter of a (2r+1)×(2r+1) square. First free cell wins,
// preferring right/down (read order) within each ring for stability.
// Spiral by ring perimeter; right/down preference for stability.
const MAX_RING = 32;
for (let r = 1; r <= MAX_RING; r++) {
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
// Only perimeter of this ring (interior was scanned in r-1).
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
const col = baseCol + dx;
const row = baseRow + dy;
// Don't place above the grid origin.
if (col < 0 || row < 0) continue;
if (cellFree(col, row)) {
return {
@@ -304,8 +338,6 @@ export function findOpenSpotNear(
}
}
// Pathological — full canvas occupied near anchor. Fall back to the
// global first-empty scan so we never return an overlap.
return findOpenGridCell(occupiedRects, newW, newH);
}
@@ -345,14 +377,7 @@ const dashboardLayoutSlice = createSlice({
y: number;
width: number;
height: number;
// Optional: which existing sessions are currently expanded
// (showing their full chat history). Without this, the collision
// check uses each card's STORED height — which is the collapsed
// value — even when the card is currently rendering at the
// expanded ~620px. Result: new sub-agent cards spawn into the
// collapsed footprint but overlap the visually expanded one.
// Caller (Dashboard.tsx) passes the current expanded set so
// the collision math matches what the user actually sees.
/** Currently-expanded sessions; collision math uses rendered (not stored) heights. */
expandedSessionIds?: string[];
}>
) {
@@ -371,9 +396,33 @@ const dashboardLayoutSlice = createSlice({
bringToFront(
state,
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>,
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' }>,
) {
const { id, type } = action.payload;
// Compute the current top zOrder across ALL card types so we can
// short-circuit when the target is already on top. Without this
// guard, every click on a card (which fires onPointerDownCapture +
// onClick + onDoubleClick) bumps zOrder and triggers a Redux
// mutation. That mutation cascades into a re-render that unmounts
// inputs mid-keystroke, causing the workflow card's title /
// description / step textareas to lose focus on every click.
let maxZ = 0;
let currentZ = 0;
const tally = (z: number | undefined) => { if (typeof z === 'number' && z > maxZ) maxZ = z; };
for (const c of Object.values(state.cards)) tally(c.zOrder);
for (const c of Object.values(state.viewCards)) tally(c.zOrder);
for (const c of Object.values(state.browserCards)) tally(c.zOrder);
for (const c of Object.values(state.workflowCards)) tally(c.zOrder);
for (const n of Object.values(state.notes)) tally(n.zOrder);
if (state.workflowsHub) tally(state.workflowsHub.zOrder);
if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0;
else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0;
else if (type === 'note') currentZ = state.notes[id]?.zOrder ?? 0;
else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0;
else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0;
else currentZ = state.browserCards[id]?.zOrder ?? 0;
if (currentZ >= maxZ) return; // Already on top: no-op.
const z = state.nextZOrder++;
if (type === 'agent') {
const card = state.cards[id];
@@ -384,6 +433,11 @@ const dashboardLayoutSlice = createSlice({
} else if (type === 'note') {
const note = state.notes[id];
if (note) note.zOrder = z;
} else if (type === 'workflow') {
const card = state.workflowCards[id];
if (card) card.zOrder = z;
} else if (type === 'workflows-hub') {
if (state.workflowsHub) state.workflowsHub.zOrder = z;
} else {
const card = state.browserCards[id];
if (card) card.zOrder = z;
@@ -439,13 +493,15 @@ const dashboardLayoutSlice = createSlice({
const agentCards = Object.values(state.cards);
const viewCards = Object.values(state.viewCards);
const bCards = Object.values(state.browserCards);
const total = agentCards.length + viewCards.length + bCards.length;
const wCards = Object.values(state.workflowCards);
const total = agentCards.length + viewCards.length + bCards.length + wCards.length;
if (total === 0) return;
const allItems = [
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...wCards.map((c) => ({ kind: 'workflow' as const, id: c.workflow_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
];
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
@@ -470,6 +526,9 @@ const dashboardLayoutSlice = createSlice({
} else if (item.kind === 'view') {
const card = state.viewCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; }
} else if (item.kind === 'workflow') {
const card = state.workflowCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; }
} else {
const card = state.browserCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; }
@@ -544,7 +603,6 @@ const dashboardLayoutSlice = createSlice({
height: DEFAULT_BROWSER_CARD_H,
zOrder: state.nextZOrder++,
};
// Signal Dashboard.tsx to pan/zoom and highlight this new card.
state.pendingFocusBrowserId = id;
},
@@ -557,13 +615,7 @@ const dashboardLayoutSlice = createSlice({
if (state.browserCards[card.browser_id]) return;
const w = card.width || DEFAULT_BROWSER_CARD_W;
const h = card.height || DEFAULT_BROWSER_CARD_H;
// Collision-resolve the backend-proposed position. Backend agents
// often spawn sub-browsers at the parent's coordinates or at a
// default (0,0) — without this guard, the new card lands on top
// of an existing one and the user sees a single card with
// multiple titles fighting for the z-index. Bias toward the
// proposed position so the spawn still LOOKS related to wherever
// the agent intended.
// Resolve collisions while biasing toward the proposed position so the spawn looks related.
const rects = collectOccupiedRects(state);
const pos = findOpenSpotNear(card.x, card.y, rects, w, h);
state.browserCards[card.browser_id] = {
@@ -601,6 +653,187 @@ const dashboardLayoutSlice = createSlice({
delete state.browserCards[action.payload];
},
addWorkflowCard(
state,
action: PayloadAction<{
workflowId: string;
sourceSessionId?: string | null;
expandedSessionIds?: string[];
}>,
) {
const { workflowId, sourceSessionId, expandedSessionIds } = action.payload;
if (state.workflowCards[workflowId]) {
state.workflowCards[workflowId].zOrder = state.nextZOrder++;
state.pendingFocusWorkflowId = workflowId;
return;
}
// Fall back to persistedExpandedSessionIds when the caller didn't
// wire the live list through. Without it, collectOccupiedRects sees
// every chat at its stored (collapsed) height, and a workflow
// spawned from an open chat lands on top of the visibly-tall card.
const expanded = expandedSessionIds ?? state.persistedExpandedSessionIds;
const rects = collectOccupiedRects(state, expanded);
let posX: number, posY: number;
const parentCard = sourceSessionId ? state.cards[sourceSessionId] : null;
if (parentCard) {
const anchorX = parentCard.x + parentCard.width + GRID_GAP * 6;
const anchorY = parentCard.y;
const pos = findOpenSpotNear(anchorX, anchorY, rects, DEFAULT_WORKFLOW_CARD_W, DEFAULT_WORKFLOW_CARD_H);
posX = pos.x;
posY = pos.y;
} else {
const pos = findOpenGridCell(rects, DEFAULT_WORKFLOW_CARD_W, DEFAULT_WORKFLOW_CARD_H);
posX = pos.x;
posY = pos.y;
}
state.workflowCards[workflowId] = {
workflow_id: workflowId,
x: posX,
y: posY,
width: DEFAULT_WORKFLOW_CARD_W,
height: DEFAULT_WORKFLOW_CARD_H,
zOrder: state.nextZOrder++,
source_session_id: sourceSessionId || null,
};
state.pendingFocusWorkflowId = workflowId;
},
setWorkflowCardPosition(
state,
action: PayloadAction<{ workflowId: string; x: number; y: number }>,
) {
const { workflowId, x, y } = action.payload;
const card = state.workflowCards[workflowId];
if (card) { card.x = x; card.y = y; }
},
setWorkflowCardSize(
state,
action: PayloadAction<{ workflowId: string; width: number; height: number }>,
) {
const { workflowId, width, height } = action.payload;
const card = state.workflowCards[workflowId];
if (card) {
card.width = Math.max(360, width);
card.height = Math.max(280, height);
}
},
removeWorkflowCard(state, action: PayloadAction<string>) {
delete state.workflowCards[action.payload];
},
// Rekey draft- id to the server-assigned id without visually hopping the card.
rekeyWorkflowCard(
state,
action: PayloadAction<{ oldId: string; newId: string }>,
) {
const { oldId, newId } = action.payload;
const card = state.workflowCards[oldId];
if (!card) return;
delete state.workflowCards[oldId];
state.workflowCards[newId] = { ...card, workflow_id: newId };
// Carry any open Action-Library panel along with the rekey so the
// popout doesn't disappear when a draft is saved.
const panel = state.configurePanels[oldId];
if (panel) {
delete state.configurePanels[oldId];
state.configurePanels[newId] = { ...panel, workflow_id: newId };
}
if (state.pendingFocusWorkflowId === oldId) state.pendingFocusWorkflowId = newId;
},
openConfigurePanel(
state,
action: PayloadAction<{ workflowId: string }>,
) {
const { workflowId } = action.payload;
// Anchor the panel just to the right of the workflow card.
const wfCard = state.workflowCards[workflowId];
const baseX = wfCard ? wfCard.x + wfCard.width + GRID_GAP * 6 : 600;
const baseY = wfCard ? wfCard.y : 200;
const existing = state.configurePanels[workflowId];
if (existing) {
existing.x = baseX;
existing.y = baseY;
return;
}
state.configurePanels[workflowId] = {
workflow_id: workflowId,
x: baseX,
y: baseY,
width: 580,
height: 600,
};
},
setConfigurePanelPosition(
state,
action: PayloadAction<{ workflowId: string; x: number; y: number }>,
) {
const { workflowId, x, y } = action.payload;
const p = state.configurePanels[workflowId];
if (p) { p.x = x; p.y = y; }
},
setConfigurePanelSize(
state,
action: PayloadAction<{ workflowId: string; width: number; height: number }>,
) {
const { workflowId, width, height } = action.payload;
const p = state.configurePanels[workflowId];
if (p) {
p.width = Math.max(360, width);
p.height = Math.max(280, height);
}
},
closeConfigurePanel(state, action: PayloadAction<string>) {
delete state.configurePanels[action.payload];
},
clearPendingFocusWorkflowId(state) {
state.pendingFocusWorkflowId = null;
},
openWorkflowsHub(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) {
if (state.workflowsHub) {
state.workflowsHub.zOrder = state.nextZOrder++;
state.pendingFocusWorkflowsHub = true;
return;
}
const rects = collectOccupiedRects(state, action.payload?.expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_WORKFLOWS_HUB_W, DEFAULT_WORKFLOWS_HUB_H);
state.workflowsHub = {
x: pos.x,
y: pos.y,
width: DEFAULT_WORKFLOWS_HUB_W,
height: DEFAULT_WORKFLOWS_HUB_H,
zOrder: state.nextZOrder++,
};
state.pendingFocusWorkflowsHub = true;
},
clearPendingFocusWorkflowsHub(state) {
state.pendingFocusWorkflowsHub = false;
},
closeWorkflowsHub(state) {
state.workflowsHub = null;
},
setWorkflowsHubPosition(state, action: PayloadAction<{ x: number; y: number }>) {
if (!state.workflowsHub) return;
state.workflowsHub.x = action.payload.x;
state.workflowsHub.y = action.payload.y;
},
setWorkflowsHubSize(state, action: PayloadAction<{ width: number; height: number }>) {
if (!state.workflowsHub) return;
state.workflowsHub.width = Math.max(720, action.payload.width);
state.workflowsHub.height = Math.max(420, action.payload.height);
},
pasteBrowserCard(
state,
action: PayloadAction<{
@@ -749,7 +982,7 @@ const dashboardLayoutSlice = createSlice({
moveCards(
state,
action: PayloadAction<{
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>;
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' }>;
dx: number;
dy: number;
}>,
@@ -774,6 +1007,12 @@ const dashboardLayoutSlice = createSlice({
note.x += dx;
note.y += dy;
}
} else if (item.type === 'workflow') {
const card = state.workflowCards[item.id];
if (card) {
card.x += dx;
card.y += dy;
}
} else {
const card = state.browserCards[item.id];
if (card) {
@@ -901,6 +1140,9 @@ const dashboardLayoutSlice = createSlice({
state.cards = {};
state.viewCards = {};
state.browserCards = {};
state.workflowCards = {};
state.configurePanels = {};
state.workflowsHub = null;
state.notes = {};
state.closedCardPositions = {};
state.glowingBrowserCards = {};
@@ -909,6 +1151,7 @@ const dashboardLayoutSlice = createSlice({
state.nextZOrder = 1;
state.initialized = false;
state.pendingFocusNoteId = null;
state.pendingFocusWorkflowId = null;
},
},
@@ -923,10 +1166,12 @@ const dashboardLayoutSlice = createSlice({
state.cards = action.payload.cards;
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.workflowCards = action.payload.workflowCards || {};
state.configurePanels = action.payload.configurePanels || {};
state.workflowsHub = action.payload.workflowsHub || null;
state.notes = action.payload.notes || {};
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
// Ensure all cards have a zOrder and compute nextZOrder from persisted data
let maxZ = 0;
for (const c of Object.values(state.cards)) {
if (!c.zOrder) c.zOrder = 0;
@@ -940,6 +1185,10 @@ const dashboardLayoutSlice = createSlice({
if (!c.zOrder) c.zOrder = 0;
if (c.zOrder > maxZ) maxZ = c.zOrder;
}
for (const w of Object.values(state.workflowCards)) {
if (!w.zOrder) w.zOrder = 0;
if (w.zOrder > maxZ) maxZ = w.zOrder;
}
for (const n of Object.values(state.notes)) {
if (!n.zOrder) n.zOrder = 0;
if (n.zOrder > maxZ) maxZ = n.zOrder;
@@ -951,11 +1200,7 @@ const dashboardLayoutSlice = createSlice({
state.initialized = true;
})
.addCase(fetchSessionRejectedAction, (state, action) => {
// 404/410 means the session is permanently gone from the
// backend; remove its card so AgentChat doesn't keep remounting
// and re-fetching it in a loop. Same id, same dead path. Other
// failure modes (network blip, 500) leave the card in place
// because the next fetch may succeed.
// 404/410 means permanent; strip the card. Other failure modes leave it (next fetch may succeed).
const payload = action.payload;
if (!payload?.sessionId) return;
if (payload.status !== 404 && payload.status !== 410) return;
@@ -963,6 +1208,11 @@ const dashboardLayoutSlice = createSlice({
if (state.cards[id]) delete state.cards[id];
if (state.closedCardPositions[id]) delete state.closedCardPositions[id];
})
.addCase(deleteWorkflowFulfilledAction, (state, action) => {
const id = action.payload;
if (id && state.workflowCards[id]) delete state.workflowCards[id];
if (id && state.configurePanels[id]) delete state.configurePanels[id];
})
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
const { draftId, session } = action.payload;
const card = state.cards[draftId];
@@ -1010,6 +1260,21 @@ export const {
fadeGlowingAgentCard,
clearGlowingAgentCard,
clearPendingFocusBrowserId,
addWorkflowCard,
setWorkflowCardPosition,
setWorkflowCardSize,
removeWorkflowCard,
rekeyWorkflowCard,
openConfigurePanel,
closeConfigurePanel,
setConfigurePanelPosition,
setConfigurePanelSize,
clearPendingFocusWorkflowId,
openWorkflowsHub,
closeWorkflowsHub,
setWorkflowsHubPosition,
setWorkflowsHubSize,
clearPendingFocusWorkflowsHub,
addNote,
setNotePosition,
setNoteSize,
+2
View File
@@ -15,6 +15,7 @@ import updateReducer from './updateSlice';
import modelsReducer from './modelsSlice';
import interactionReducer from './interactionSlice';
import subscriptionsReducer from './subscriptionsSlice';
import workflowsReducer from './workflowsSlice';
import onboardingProgressReducer from '@/app/components/Onboarding/OnboardingProgressSlice';
export const store = configureStore({
@@ -35,6 +36,7 @@ export const store = configureStore({
models: modelsReducer,
interaction: interactionReducer,
subscriptions: subscriptionsReducer,
workflows: workflowsReducer,
onboardingProgress: onboardingProgressReducer,
},
// Disable Redux Toolkit's dev-mode invariant middleware (serializable +
+296
View File
@@ -0,0 +1,296 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API = `${API_BASE}/workflows`;
export type PermissionKind = 'notify' | 'text' | 'call';
export interface PermissionTier {
kind: PermissionKind;
after_minutes: number;
phone?: string | null;
}
export interface ScheduleConfig {
enabled: boolean;
repeat_every: number;
repeat_unit: 'day' | 'week' | 'month';
on_days: number[];
hour: number;
minute: number;
timezone: string;
on_missed: 'skip' | 'run_once' | 'run_all';
/** End conditions; null on both = forever. Scheduler auto-disables on threshold. */
ends_at: string | null;
max_runs: number | null;
runs_count: number;
}
export interface CostEstimate {
monthly_usd: number;
last_run_usd: number;
fires_per_month: number;
}
export interface ActiveRun {
workflow_id: string;
run_id: string;
title: string;
started_at: string | null;
}
export interface ActionsConfig {
prevent_unused: boolean;
freeze: boolean;
configured_sets: string[];
}
export interface WorkflowStep {
id: string;
text: string;
}
export interface Workflow {
id: string;
title: string;
description: string;
icon: string;
system_prompt: string | null;
use_synced_prompt: boolean;
steps: WorkflowStep[];
actions: ActionsConfig;
schedule: ScheduleConfig;
permissions: PermissionTier[];
source_session_id?: string | null;
dashboard_id?: string | null;
model: string;
mode: string;
provider: string;
created_at: string;
updated_at: string;
last_run_at: string | null;
last_run_status: 'success' | 'failure' | 'ran_late' | 'running' | 'skipped' | null;
last_run_id: string | null;
next_run_at: string | null;
cost_cap_usd_monthly: number | null;
cost_estimate?: CostEstimate;
}
export interface WorkflowRun {
id: string;
workflow_id: string;
status: 'running' | 'success' | 'failure' | 'ran_late' | 'skipped';
scheduled_for: string | null;
started_at: string;
finished_at: string | null;
session_id: string | null;
error: string | null;
cost_usd: number;
triggered_by: 'schedule' | 'manual' | 'retry';
}
/** Transient view-only state per card; position lives in dashboardLayoutSlice.workflowCards. */
export interface OpenCard {
workflowId: string;
sourceSessionId?: string | null;
draft?: Partial<Workflow> | null;
view: 'preview' | 'saved' | 'edit' | 'history' | 'history_detail';
editFacet?: 'General' | 'Actions' | 'Schedule';
historyRunId?: string | null;
}
interface State {
items: Record<string, Workflow>;
runs: Record<string, WorkflowRun[]>;
openCards: Record<string, OpenCard>;
loaded: boolean;
loading: boolean;
paused: boolean;
active: ActiveRun[];
cloudSmsEnabled: boolean;
}
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false };
export const fetchWorkflows = createAsyncThunk(
'workflows/fetch',
async (dashboardId?: string) => {
const url = dashboardId ? `${API}/list?dashboard_id=${encodeURIComponent(dashboardId)}` : `${API}/list`;
const res = await fetch(url);
const data = await res.json();
return data.workflows as Workflow[];
},
{ condition: (_, { getState }) => !(getState() as { workflows: State }).workflows.loading },
);
export const createWorkflow = createAsyncThunk(
'workflows/create',
async (body: Partial<Workflow>) => {
const res = await fetch(`${API}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`create failed ${res.status}`);
return (await res.json()) as Workflow;
},
);
// Optimistic concurrency via If-Match: server 409s on stale writes; rejectWithValue lets FE distinguish.
export const updateWorkflow = createAsyncThunk<
Workflow,
{ id: string; patch: Partial<Workflow>; ifMatch?: string | null },
{ rejectValue: { kind: 'stale' | 'network' | 'server'; message: string; current_updated_at?: string } }
>(
'workflows/update',
async ({ id, patch, ifMatch }, { rejectWithValue }) => {
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (ifMatch) headers['If-Match'] = ifMatch;
const res = await fetch(`${API}/${id}`, {
method: 'PATCH',
headers,
body: JSON.stringify(patch),
});
if (res.status === 409) {
const data = await res.json().catch(() => ({}));
const detail = (data && (data.detail || data)) || {};
return rejectWithValue({
kind: 'stale',
message: detail.message || 'This workflow changed elsewhere. Reload and try again.',
current_updated_at: detail.current_updated_at,
});
}
if (!res.ok) {
return rejectWithValue({ kind: 'server', message: `Update failed (${res.status}).` });
}
return (await res.json()) as Workflow;
} catch (e) {
return rejectWithValue({ kind: 'network', message: (e as Error)?.message || 'Network error.' });
}
},
);
export const deleteWorkflow = createAsyncThunk('workflows/delete', async (id: string) => {
await fetch(`${API}/${id}`, { method: 'DELETE' });
return id;
});
export const runWorkflowNow = createAsyncThunk('workflows/run', async (id: string) => {
const res = await fetch(`${API}/${id}/run`, { method: 'POST' });
if (!res.ok) throw new Error(`run failed ${res.status}`);
const data = await res.json();
return {
id,
run_id: (data.run_id || '') as string,
status: (data.status || null) as string | null,
error: (data.error || null) as string | null,
};
});
export const fetchRuns = createAsyncThunk(
'workflows/runs',
async (id: string) => {
const res = await fetch(`${API}/${id}/runs?limit=50`);
const data = await res.json();
return { id, runs: data.runs as WorkflowRun[] };
},
);
export const fetchPausedState = createAsyncThunk('workflows/paused', async () => {
const res = await fetch(`${API}/paused`);
const data = await res.json();
return Boolean(data.paused);
});
export const fetchActiveRuns = createAsyncThunk('workflows/active', async () => {
const res = await fetch(`${API}/active`);
const data = await res.json();
return (data.active || []) as ActiveRun[];
});
export const setPausedAll = createAsyncThunk('workflows/setPaused', async (paused: boolean) => {
const res = await fetch(`${API}/${paused ? 'pause-all' : 'resume-all'}`, { method: 'POST' });
if (!res.ok) throw new Error(`pause-all toggle failed ${res.status}`);
const data = await res.json();
return Boolean(data.paused);
});
export const ackRun = createAsyncThunk('workflows/ackRun', async (runId: string) => {
const res = await fetch(`${API}/runs/${encodeURIComponent(runId)}/ack`, { method: 'POST' });
if (!res.ok) throw new Error(`ack failed ${res.status}`);
return runId;
});
export const fetchCloudSmsStatus = createAsyncThunk('workflows/cloudSms', async () => {
try {
const res = await fetch(`${API}/cloud/sms/status`);
const data = await res.json();
return Boolean(data.enabled);
} catch {
return false;
}
});
const slice = createSlice({
name: 'workflows',
initialState,
reducers: {
openWorkflowCard(state, action: { payload: OpenCard }) {
state.openCards[action.payload.workflowId] = action.payload;
},
updateWorkflowCard(state, action: { payload: { workflowId: string; patch: Partial<OpenCard> } }) {
const existing = state.openCards[action.payload.workflowId];
if (existing) state.openCards[action.payload.workflowId] = { ...existing, ...action.payload.patch };
},
closeWorkflowCard(state, action: { payload: string }) {
delete state.openCards[action.payload];
},
rekeyOpenCard(state, action: { payload: { oldId: string; newId: string } }) {
const entry = state.openCards[action.payload.oldId];
if (!entry) return;
delete state.openCards[action.payload.oldId];
state.openCards[action.payload.newId] = { ...entry, workflowId: action.payload.newId };
},
upsertRun(state, action: { payload: WorkflowRun }) {
const r = action.payload;
const arr = state.runs[r.workflow_id] || [];
const idx = arr.findIndex((x) => x.id === r.id);
if (idx >= 0) arr[idx] = r; else arr.unshift(r);
state.runs[r.workflow_id] = arr.slice(0, 100);
const wf = state.items[r.workflow_id];
if (wf) {
wf.last_run_at = r.finished_at || r.started_at;
wf.last_run_status = r.status === 'skipped' ? wf.last_run_status : (r.status as Workflow['last_run_status']);
wf.last_run_id = r.id;
}
},
},
extraReducers: (builder) => {
builder
.addCase(fetchWorkflows.pending, (state) => { state.loading = true; })
.addCase(fetchWorkflows.fulfilled, (state, action) => {
state.loading = false;
state.loaded = true;
state.items = {};
for (const w of action.payload) state.items[w.id] = w;
})
.addCase(fetchWorkflows.rejected, (state) => { state.loading = false; state.loaded = true; })
.addCase(createWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(updateWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(deleteWorkflow.fulfilled, (state, action) => {
delete state.items[action.payload];
delete state.runs[action.payload];
})
.addCase(fetchRuns.fulfilled, (state, action) => {
state.runs[action.payload.id] = action.payload.runs;
})
.addCase(fetchPausedState.fulfilled, (state, action) => { state.paused = action.payload; })
.addCase(setPausedAll.fulfilled, (state, action) => { state.paused = action.payload; })
.addCase(fetchActiveRuns.fulfilled, (state, action) => { state.active = action.payload; })
.addCase(fetchCloudSmsStatus.fulfilled, (state, action) => { state.cloudSmsEnabled = action.payload; });
},
});
export const { upsertRun, openWorkflowCard, updateWorkflowCard, closeWorkflowCard, rekeyOpenCard } = slice.actions;
export default slice.reducer;
+86 -1
View File
@@ -23,8 +23,9 @@ import {
clearTurnLabel,
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd } from '../state/streamingSlice';
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, addWorkflowCard } from '../state/dashboardLayoutSlice';
import { upsertOutput } from '../state/outputsSlice';
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard } from '../state/workflowsSlice';
import { getAuthToken } from '../config';
import { notifyAgentCompletion } from '../notifications';
@@ -701,6 +702,65 @@ class WebSocketManager {
}
break;
case 'workflow:run':
if (data.run) {
store.dispatch(upsertRun(data.run));
}
break;
case 'workflow:notify':
try {
notifyAgentCompletion({
sessionId: data.session_id || data.workflow_id,
sessionName: data.workflow_title || 'Workflow',
status: data.status === 'success' ? 'completed' : 'error',
});
} catch { /* notifications are best-effort */ }
try {
const w: any = (window as any).openswarm;
if (w?.notify) {
// Seed by workflow id + current minute so multiple workflows pick different copy
// while a single workflow stays stable within a few minutes.
const seed = ((data.workflow_id || '').length + Math.floor(Date.now() / 60000)) | 0;
const SUCCESS_TITLES = [
`${data.workflow_title || 'Workflow'} — done`,
`${data.workflow_title || 'Workflow'} just wrapped up`,
`Heads up: ${data.workflow_title || 'Workflow'} finished`,
`${data.workflow_title || 'Workflow'} is ready`,
];
const FAILURE_TITLES = [
`${data.workflow_title || 'Workflow'} hit a snag`,
`${data.workflow_title || 'Workflow'} couldn't finish`,
`Something went sideways on ${data.workflow_title || 'Workflow'}`,
];
const LATE_TITLES = [
`${data.workflow_title || 'Workflow'} caught up late`,
`${data.workflow_title || 'Workflow'} ran late but made it`,
];
const pool = data.status === 'success' ? SUCCESS_TITLES
: data.status === 'failure' ? FAILURE_TITLES
: data.status === 'ran_late' ? LATE_TITLES
: [`${data.workflow_title || 'Workflow'}${data.status}`];
const title = pool[Math.abs(seed) % pool.length];
const isMac = (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform));
const body = data.tier_kind && data.fallback
? `Would have ${data.tier_kind === 'call' ? 'called' : 'texted'} you. (Cloud SMS not wired yet.)`
: data.status === 'success'
? (isMac ? 'Tap to see what it did.' : 'Click to see what it did.')
: data.status === 'failure'
? (isMac ? 'Tap to see what went wrong.' : 'Click to see what went wrong.')
: (isMac ? 'Tap to open the run.' : 'Click to open the run.');
const deepLink = data.workflow_id ? `openswarm://workflow/${data.workflow_id}/run/${data.run_id || ''}` : undefined;
const actions = [
{ text: 'Looks good', outcome: 'ack' },
{ text: 'Re-run', outcome: 'rerun' },
{ text: 'Adjust', outcome: 'edit' },
];
w.notify({ title, body, deepLink, runId: data.run_id, workflowId: data.workflow_id, actions });
}
} catch { /* native notif optional */ }
break;
case 'dashboard:browser_card_added':
if (data.browser_card) {
store.dispatch(addBrowserCardFromBackend(data.browser_card));
@@ -798,6 +858,31 @@ class WebSocketManager {
import { WS_BASE } from '@/shared/config';
// Bridge native-notification button actions to workflow actions. Subscribe at module
// import time so we never miss an early callback fired before any component mounts.
(() => {
try {
const w: any = (typeof window !== 'undefined') ? (window as any).openswarm : null;
if (!w?.onNotificationAction) return;
w.onNotificationAction(({ outcome, runId, workflowId }: { outcome: string; runId?: string; workflowId?: string }) => {
if (!workflowId) return;
if (outcome === 'ack' && runId) {
store.dispatch(ackRun(runId));
return;
}
if (outcome === 'rerun') {
store.dispatch(runWorkflowNow(workflowId));
return;
}
if (outcome === 'edit' || outcome === 'open') {
store.dispatch(addWorkflowCard({ workflowId }));
store.dispatch(openWorkflowCard({ workflowId, view: outcome === 'edit' ? 'edit' : 'saved', editFacet: outcome === 'edit' ? 'Schedule' : undefined }));
return;
}
});
} catch { /* native notifications optional */ }
})();
export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { skipStreamEvents: true });
// Per-session high-water mark for the resume protocol. Survives across