[aidan] feat: new scheduled task design ported

This commit is contained in:
abccodes
2026-06-21 01:28:14 -07:00
parent d3548dde10
commit 17a66749f6
40 changed files with 1535 additions and 4804 deletions
@@ -32,6 +32,7 @@ import LinearProgress from '@mui/material/LinearProgress';
import CircularProgress from '@mui/material/CircularProgress';
// Settings modal lazy-loaded so its 2.3K LOC + Stripe/OAuth helpers don't ship on first paint.
const Settings = React.lazy(() => import('@/app/pages/Settings/Settings'));
const WorkflowsApp = React.lazy(() => import('@/app/pages/Workflows/app/WorkflowsApp'));
import DynamicIsland from '@/app/components/overlays/DynamicIsland';
import Dashboard from '@/app/pages/Dashboard/Dashboard';
import DashboardHost from '@/app/components/Layout/DashboardHost';
@@ -1175,6 +1176,10 @@ const AppShell: React.FC = () => {
<Settings />
</React.Suspense>
<React.Suspense fallback={null}>
<WorkflowsApp />
</React.Suspense>
<Snackbar
open={showUpdateSnackbar}
autoHideDuration={10000}
@@ -30,7 +30,7 @@ import ChatInput from '@/app/pages/AgentChat/ChatInput';
import type { ContextPath } from '@/app/components/editor/DirectoryBrowser';
import SchedulePopover from '@/app/pages/Workflows/SchedulePopover';
import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice';
import { addWorkflowCard, openWorkflowsHub, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { addWorkflowCard, openWorkflowsApp, closeWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -191,7 +191,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const allRuns = useAppSelector((s) => s.workflows.allRuns);
const allRunsLoading = useAppSelector((s) => s.workflows.allRunsLoading);
const workflowItems = useAppSelector((s) => s.workflows.items);
const workflowsHubOpen = useAppSelector((s) => Boolean(s.dashboardLayout.workflowsHub));
const workflowsHubOpen = useAppSelector((s) => s.dashboardLayout.workflowsAppOpen);
const outputList = useMemo(() => Object.values(outputs), [outputs]);
const filteredOutputs = useMemo(() => {
@@ -454,33 +454,6 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
<AddRounded sx={{ fontSize: 12 }} />
New Chat
</Box>
<Box
onClick={() => {
// Schedule is a destination, not a toggle: clicking it always
// lands on (and stays on) the calendar. It used to call
// handleCloseHistory when already open, which read as "Schedule
// does nothing" because it closed the calendar you were viewing.
// Close the composer first; inputOpen takes precedence in the
// render branch below so the popover would hide behind it.
if (inputOpen) onCancel();
setPopoverMode('schedule');
if (!historyOpen) setHistoryOpen(true);
}}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.74rem', fontWeight: 600,
color: historyOpen ? c.text.primary : c.text.secondary,
bgcolor: c.bg.surface,
border: `1px solid ${historyOpen ? c.border.medium : c.border.subtle}`,
boxShadow: historyOpen ? c.shadow.sm : 'none',
px: 0.85, py: 0.3, borderRadius: 999,
cursor: 'pointer',
'&:hover': { bgcolor: c.bg.elevated },
}}>
<CalendarMonthRounded sx={{ fontSize: 12 }} />
Schedule
</Box>
</Box>
)}
<MotionBox
@@ -539,33 +512,18 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
onHistorySelect={handleHistorySelect}
onNewChat={() => { handleCloseHistory(); onNewAgent(); }}
onWorkflowSelect={(wid) => {
dispatch(addWorkflowCard({ workflowId: wid }));
dispatch(openWorkflowCard({
workflowId: wid,
view: 'saved',
}));
dispatch(openWorkflowsApp({ workflowId: wid }));
handleCloseHistory();
}}
onExpand={() => {
// Singleton per dashboard, second Expand brings the existing card forward.
const alreadyOpen = Boolean(store.getState().dashboardLayout.workflowsHub);
dispatch(openWorkflowsHub({ expandedSessionIds: [] }));
if (alreadyOpen) setExpandToast('Calendar view is already open');
dispatch(openWorkflowsApp());
handleCloseHistory();
}}
allRuns={allRuns}
allRunsLoading={allRunsLoading}
workflowTitleFor={(wid) => workflowItems[wid]?.title || 'Workflow'}
onRunOpen={(run) => {
// Splice the clicked run in first so HistoryDetail finds it
// before fetchRuns resolves; avoids a "Run not found" flash.
dispatch(upsertRun(run));
dispatch(addWorkflowCard({ workflowId: run.workflow_id }));
dispatch(openWorkflowCard({
workflowId: run.workflow_id,
view: 'history_detail',
historyRunId: run.id,
}));
dispatch(openWorkflowsApp({ workflowId: run.workflow_id }));
handleCloseHistory();
}}
historyScrollRef={historyListRef as React.RefObject<HTMLDivElement>}
@@ -824,7 +782,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
role="button"
aria-label="Workflows"
tabIndex={0}
onClick={() => dispatch(workflowsHubOpen ? closeWorkflowsHub() : openWorkflowsHub({ expandedSessionIds: [] }))}
onClick={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())}
sx={{
display: 'flex',
alignItems: 'center',
@@ -4,10 +4,6 @@ import AgentCard from '../cards/AgentCard';
import DashboardViewCard from '../cards/DashboardViewCard';
import BrowserCard from '../cards/BrowserCard';
import NoteCard from '../cards/NoteCard';
import WorkflowCard from '@/app/pages/Workflows/WorkflowCard';
import WorkflowsHubCard from '@/app/pages/Workflows/WorkflowsHubCard';
import MissedRunsCard from '@/app/pages/Workflows/MissedRunsCard';
import ConfigurePanelCard from '@/app/pages/Workflows/ConfigurePanelCard';
import {
EXPANDED_CARD_MIN_H,
DEFAULT_CARD_W,
@@ -270,78 +266,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
onBringToFront={onBringToFront}
/>
))}
{workflowsHub && (
<WorkflowsHubCard
dashboardId={dashboardId}
cardX={workflowsHub.x}
cardY={workflowsHub.y}
cardWidth={workflowsHub.width}
cardHeight={workflowsHub.height}
cardZOrder={workflowsHub.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
isSelected={selection.isSelected('workflows-hub')}
isHighlighted={highlightedCardId === 'workflows-hub'}
multiDragDelta={selection.isSelected('workflows-hub') ? multiDragDelta : null}
onCardSelect={onCardSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onBringToFront={onBringToFront}
/>
)}
{missedRunsCard && (
<MissedRunsCard
cardX={missedRunsCard.x}
cardY={missedRunsCard.y}
cardWidth={missedRunsCard.width}
cardHeight={missedRunsCard.height}
cardZOrder={missedRunsCard.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
isSelected={selection.isSelected('missed-runs')}
isHighlighted={highlightedCardId === 'missed-runs'}
multiDragDelta={selection.isSelected('missed-runs') ? multiDragDelta : null}
onCardSelect={onCardSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onBringToFront={onBringToFront}
/>
)}
{Object.values(workflowCards).map((wc) => (
<WorkflowCard
key={`workflow-${wc.workflow_id}`}
workflowId={wc.workflow_id}
cardX={wc.x}
cardY={wc.y}
cardWidth={wc.width}
cardHeight={wc.height}
cardZOrder={wc.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
isSelected={selection.isSelected(wc.workflow_id)}
isHighlighted={highlightedCardId === wc.workflow_id}
multiDragDelta={multiDragDelta}
onCardSelect={onCardSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onDoubleClick={onDoubleClick}
onBringToFront={onBringToFront}
onMeasuredHeight={onMeasuredHeight}
/>
))}
{Object.values(configurePanels).map((p) => (
<ConfigurePanelCard
key={`configure-${p.workflow_id}`}
panel={p}
zOrder={1}
/>
))}
{/* Workflows now live in the shell-level Workflows app, not on the canvas. */}
{/* Marquee selection rectangle */}
{selection.marquee && (
<div
@@ -44,7 +44,7 @@ import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScro
import { useStreamingMessage } from '@/shared/state/streamingSlice';
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
import { openWorkflowCard, updateWorkflowCard, generateWorkflowMetadata, applyGeneratedMetadata, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice';
import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice';
import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime';
import { friendlyStatusLabel } from '@/shared/statusLabel';
@@ -509,9 +509,9 @@ const AgentCard: React.FC<Props> = ({
dispatch(fadeGlowingAgentCard(session.id));
}, [workflowSuggestion, canConvertToWorkflow, dispatch, session.id]);
// When the agent schedules a workflow from this chat, pop its card open
// next to the chat. Baseline the count once on mount so historical
// schedules (e.g. after an app reload) don't re-open on their own.
// When the agent schedules a workflow from this chat, open it in the
// Workflows app. Baseline the count once on mount so historical schedules
// (e.g. after an app reload) don't re-open on their own.
const scheduleWorkflowCount = useMemo(() => countScheduleWorkflowCalls(session), [session]);
const baselineScheduleCountRef = useRef<number | null>(null);
const autoOpenedWorkflowIdsRef = useRef<Set<string>>(new Set());
@@ -525,10 +525,9 @@ const AgentCard: React.FC<Props> = ({
if (wf.source_session_id !== session.id) continue;
if (autoOpenedWorkflowIdsRef.current.has(wf.id)) continue;
autoOpenedWorkflowIdsRef.current.add(wf.id);
dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: session.id, expandedSessionIds }));
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' }));
dispatch(openWorkflowsApp({ workflowId: wf.id }));
}
}, [scheduleWorkflowCount, workflowItems, session.id, dispatch, expandedSessionIds]);
}, [scheduleWorkflowCount, workflowItems, session.id, dispatch]);
const cardBoxRef = useRef<HTMLDivElement>(null);
// Ref so ResizeObserver sees latest value without re-attaching when active flips.
@@ -1096,29 +1095,6 @@ const AgentCard: React.FC<Props> = ({
onPointerDown={(e) => e.stopPropagation()}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
>
{showSourceWorkflowSchedule && (
<Tooltip title="Schedule the workflow made from this chat">
<Box
role="button"
onClick={openSourceWorkflowScheduling}
onMouseDown={(e) => e.stopPropagation()}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
color: '#fff',
bgcolor: c.accent.primary,
border: `1px solid ${c.accent.primary}`,
fontSize: '0.78rem', fontWeight: 700,
px: 1.1, py: 0.5,
borderRadius: `${c.radius.md}px`,
cursor: 'pointer',
'&:hover': { filter: 'brightness(1.05)' },
}}
>
<CalendarMonthRounded sx={{ fontSize: 14 }} />
Schedule Workflow
</Box>
</Tooltip>
)}
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
@@ -1160,55 +1136,6 @@ const AgentCard: React.FC<Props> = ({
</Typography>
)}
</Box>
{showConvertToWorkflow && (
<Tooltip title={canConvertToWorkflow ? 'Turn this chat into a reusable workflow' : 'Wait for the current response to finish before converting'}>
<Box
key={`convert-workflow-${suggestGlowCycle}-${canConvertToWorkflow ? 'ready' : 'blocked'}`}
component={motion.div}
role="button"
aria-disabled={!canConvertToWorkflow}
onClick={handleConvertToWorkflow}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
animate={suggestGlowCycle > 0 ? {
scale: [1, 1.06, 1, 1.045, 1],
filter: ['brightness(1)', 'brightness(1.18)', 'brightness(1)', 'brightness(1.12)', 'brightness(1)'],
boxShadow: [
`0 0 0 0 ${c.accent.primary}00`,
`0 0 0 4px ${c.accent.primary}99, 0 0 20px 6px ${c.accent.primary}66`,
`0 0 0 8px ${c.accent.primary}00`,
`0 0 0 3px ${c.accent.primary}88, 0 0 16px 4px ${c.accent.primary}55`,
canConvertToWorkflow ? c.shadow.sm : 'none',
],
} : undefined}
transition={{ duration: 2.4, ease: 'easeInOut' }}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.35,
color: canConvertToWorkflow ? '#fff' : c.text.tertiary,
bgcolor: canConvertToWorkflow ? c.accent.primary : c.bg.secondary,
border: `1px solid ${canConvertToWorkflow ? c.accent.primary : c.border.medium}`,
fontSize: '0.68rem',
lineHeight: 1,
fontWeight: 700,
px: 0.8,
py: 0.35,
minHeight: 22,
borderRadius: `${c.radius.sm}px`,
cursor: canConvertToWorkflow ? (converting ? 'wait' : 'pointer') : 'not-allowed',
opacity: converting ? 0.7 : 1,
whiteSpace: 'nowrap',
flexShrink: 0,
boxShadow: canConvertToWorkflow ? c.shadow.sm : 'none',
'&:hover': canConvertToWorkflow ? { filter: 'brightness(1.05)' } : { bgcolor: c.bg.secondary },
}}
>
<AutoAwesomeOutlinedIcon sx={{ fontSize: 13 }} />
{converting ? 'Converting...' : 'Convert to workflow'}
</Box>
</Tooltip>
)}
</Box>
</Box>
@@ -1420,80 +1347,6 @@ const AgentCard: React.FC<Props> = ({
) : null}
</>
)}
<Fade in={showWorkflowSuggestionPrompt} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
onClick={(e) => {
e.stopPropagation();
setDismissedWorkflowPromptKey(workflowSuggestionKey);
}}
sx={{
position: 'absolute',
inset: 0,
zIndex: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
bgcolor: 'rgba(0,0,0,0.28)',
backdropFilter: 'blur(1.5px)',
}}
>
<Box
onClick={(e) => e.stopPropagation()}
sx={{
width: '100%',
maxWidth: 340,
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.lg}px`,
boxShadow: c.shadow.lg,
p: 2.25,
}}
>
<Typography sx={{ fontSize: '0.98rem', fontWeight: 700, mb: 0.75 }}>
Would you like to make this a workflow?
</Typography>
<Typography sx={{ fontSize: '0.85rem', lineHeight: 1.5, color: c.text.secondary }}>
I can open a workflow draft from this chat. You can review the steps and choose the schedule there.
</Typography>
{workflowSuggestion?.cadence && (
<Typography sx={{ mt: 1, fontSize: '0.78rem', color: c.text.tertiary }}>
Suggested cadence: {workflowSuggestion.cadence}
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}>
<Button
variant="text"
size="small"
onClick={() => setDismissedWorkflowPromptKey(workflowSuggestionKey)}
sx={{ textTransform: 'none', color: c.text.tertiary, fontWeight: 700 }}
>
No, keep chatting
</Button>
<Button
variant="contained"
size="small"
startIcon={<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />}
onClick={() => {
setDismissedWorkflowPromptKey(workflowSuggestionKey);
convertChatToWorkflow();
}}
sx={{
textTransform: 'none',
bgcolor: c.accent.primary,
color: '#fff',
fontWeight: 700,
boxShadow: c.shadow.sm,
'&:hover': { bgcolor: c.accent.hover },
}}
>
Yes, open workflow
</Button>
</Box>
</Box>
</Box>
</Fade>
<Snackbar
open={!!workflowToast}
autoHideDuration={3200}
@@ -1,138 +0,0 @@
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>
)}
<RememberedApprovals draft={draft} setDraft={setDraft} />
</Box>
);
}
// Permissions this workflow learned on an earlier run and now reuses without
// asking. A reused "allow" runs unattended, so the user has to be able to see
// and take it back here.
function RememberedApprovals({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const entries = Object.entries(draft.remembered_approvals || {});
if (entries.length === 0) return null;
const prettyName = (tool: string) => (tool.includes('__') ? tool.split('__').pop() || tool : tool);
const forget = (tool: string) => {
const next = { ...(draft.remembered_approvals || {}) };
const nextStepUsage = Object.fromEntries(
Object.entries(draft.step_tool_usage || {}).map(([stepId, tools]) => {
const copy = { ...(tools || {}) };
delete copy[tool];
return [stepId, copy];
}),
);
delete next[tool];
setDraft({ ...draft, remembered_approvals: next, step_tool_usage: nextStepUsage });
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mt: 1, pt: 1.25, borderTop: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5 }}>
Saved permissions this workflow reuses on later runs.
</Typography>
<Box
onClick={() => setDraft({ ...draft, remembered_approvals: {}, step_tool_usage: {} })}
role="button"
sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', whiteSpace: 'nowrap', ml: 1, '&:hover': { color: c.text.primary } }}>
Clear all
</Box>
</Box>
{entries.map(([tool, answer]) => (
<Box key={tool} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 0.75, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.primary, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{prettyName(tool)}
</Typography>
<Typography sx={{ fontSize: LABEL_FS, fontWeight: 600, color: answer === 'allow' ? c.status.success : c.status.error }}>
{answer === 'allow' ? 'Allowed' : 'Blocked'}
</Typography>
<Box
onClick={() => forget(tool)}
role="button"
aria-label={`Forget ${prettyName(tool)}`}
sx={{ display: 'inline-flex', fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', px: 0.4, '&:hover': { color: c.status.error } }}>
</Box>
</Box>
))}
</Box>
);
}
@@ -1,67 +0,0 @@
import React, { useCallback } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Popover from '@mui/material/Popover';
import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
import { needsScheduleTestWarning } from './scheduleUtils';
interface Props {
anchorEl: HTMLElement | null;
workflow: Workflow | null;
onClose: () => void;
}
// Opens off an Unscheduled workflow's "+" icon. These workflows have no
// real cadence yet, so the only safe action is to create one.
export default function AddToSchedulePopover({ anchorEl, workflow, onClose }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const makeSchedule = useCallback(() => {
if (!workflow) return;
dispatch(addWorkflowCard({ workflowId: workflow.id }));
// Untested steps: land on the saved card so its Schedule button can warn and
// offer a test run (which needs the card's sidecar context). Otherwise go
// straight to scheduling.
const view = needsScheduleTestWarning(workflow) ? 'saved' : 'scheduling';
dispatch(openWorkflowCard({ workflowId: workflow.id, view }));
onClose();
}, [dispatch, workflow, onClose]);
const rowSx = {
display: 'flex', alignItems: 'center', gap: 0.9,
px: 0.75, py: 0.65, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { bgcolor: c.bg.elevated },
};
const iconSx = {
width: 28, height: 28, borderRadius: `${c.radius.md}px`, flexShrink: 0,
bgcolor: c.accent.primary + '18', color: c.accent.primary,
display: 'flex', alignItems: 'center', justifyContent: 'center',
};
return (
<Popover
open={Boolean(anchorEl && workflow)}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: 'center', horizontal: 'right' }}
transformOrigin={{ vertical: 'center', horizontal: 'left' }}
slotProps={{ paper: { sx: { width: 272, p: 1, ml: 0.75 } } }}
>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', px: 0.75, mb: 0.5 }}>
NEEDS SCHEDULE
</Typography>
<Box role="button" onClick={makeSchedule} sx={rowSx}>
<Box sx={iconSx}><CalendarMonthRounded sx={{ fontSize: 16 }} /></Box>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary }}>Make a schedule</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>This workflow does not have a schedule yet. Choose when it should run.</Typography>
</Box>
</Box>
</Popover>
);
}
@@ -1,158 +0,0 @@
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>
);
}
@@ -1,91 +0,0 @@
// The two popovers in the Edit Agent Save flow (Image #50): on Save we ask
// "test before finishing?"; once a test ends we ask "Confirm save". Both are
// presentational and reuse the ScheduleThisPopover Popover styling so the
// modify flow feels of a piece with scheduling.
import React from 'react';
import Popover from '@mui/material/Popover';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface OptionProps {
label: string;
hint: string;
onClick: () => void;
accent?: boolean;
danger?: boolean;
}
function OptionRow({ label, hint, onClick, accent, danger }: OptionProps) {
const c = useClaudeTokens();
const labelColor = danger ? c.status.error : accent ? c.accent.primary : c.text.primary;
return (
<Box
role="button"
onClick={onClick}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
px: 1, py: 0.7, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { bgcolor: c.bg.elevated },
}}>
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: labelColor }}>{label}</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>{hint}</Typography>
</Box>
);
}
// 'testing' is a live state with no popover (the Test Agent card owns the
// post-test decision); the popover only shows for 'ask-test' and 'confirm-discard'.
export type SavePhase = 'idle' | 'ask-test' | 'testing' | 'confirm-discard';
interface Props {
phase: SavePhase;
anchorEl: HTMLElement | null;
onClose: () => void;
onSaveNow: () => void;
onRunTest: () => void;
onConfirmDiscard: () => void;
}
export default function EditAgentSavePopovers({
phase, anchorEl, onClose, onSaveNow, onRunTest, onConfirmDiscard,
}: Props) {
const c = useClaudeTokens();
const heading = (text: string) => (
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.75 }}>
{text}
</Typography>
);
return (
<Popover
open={phase === 'ask-test' || phase === 'confirm-discard'}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 300, p: 1.25 } } }}
>
{phase === 'ask-test' && (
<>
{heading('BEFORE YOU SAVE')}
<Typography sx={{ fontSize: '0.84rem', color: c.text.secondary, mb: 0.75, lineHeight: 1.4 }}>
Want to test the workflow before finishing your edits?
</Typography>
<OptionRow label="Yes, test it" hint="Run the draft once so you can watch it work" onClick={onRunTest} accent />
<OptionRow label="No, save now" hint="Commit your edits without a test run" onClick={onSaveNow} />
</>
)}
{phase === 'confirm-discard' && (
<>
{heading('DISCARD CHANGES')}
<Typography sx={{ fontSize: '0.84rem', color: c.text.secondary, mb: 0.75, lineHeight: 1.4 }}>
Throw away every edit from this session? This can&apos;t be undone.
</Typography>
<OptionRow label="Discard changes" hint="Revert to the saved workflow" onClick={onConfirmDiscard} danger />
<OptionRow label="Keep editing" hint="Stay on the editing window" onClick={onClose} />
</>
)}
</Popover>
);
}
@@ -1,304 +0,0 @@
// Image #38, #48: Edit Agent embedded in the workflow card.
// Creates a real, sticky-per-workflow agent session via /workflows/{id}/
// edit-agent-session and embeds AgentChat so tool calls render as their
// normal cards (MCP Activation, Gmail Query, etc.). The card IS the chat:
// a collapsible "Workflow" strip on top peeks at the live steps, the chat
// fills the rest. In fix mode (Image #48) the first message is a
// failure-context prompt and a red prefix card renders above the chat.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import BuildRounded from '@mui/icons-material/BuildRounded';
import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearFixSeed, commitDraft, discardDraft, setCardSidecar, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
import { fetchSession } from '@/shared/state/agentsSlice';
import { API_BASE, getAuthToken } from '@/shared/config';
import StepList from './StepList';
import AgentChat from '@/app/pages/AgentChat/AgentChat';
import { useOpenSidecar } from './WorkflowCardLiveViews';
import EditAgentSavePopovers, { type SavePhase } from './EditAgentSavePopovers';
import { runWorkflowTest } from './runWorkflowTest';
import { needsScheduleTestWarning } from './scheduleUtils';
interface Props {
workflow: Workflow;
steps: Workflow['steps'];
isFixMode?: boolean;
// The card header (in WorkflowCard) renders the model/time subtitle and the
// Save Workflow button, so it needs the live edit-agent session id.
onEditSessionIdChange?: (sessionId: string | null) => void;
}
export default function EditAgentView({ workflow, steps, isFixMode = false, onEditSessionIdChange }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
const fixSeed = card?.fixSeed || null;
const [stepsOpen, setStepsOpen] = useState(true);
const [fixPrefixExpanded, setFixPrefixExpanded] = useState(false);
const [editSessionId, setEditSessionId] = useState<string | null>(workflow.edit_agent_session_id || null);
const [seedSent, setSeedSent] = useState(false);
// Surface the live session id to the card header (Save button + model/time).
useEffect(() => { onEditSessionIdChange?.(editSessionId); }, [editSessionId, onEditSessionIdChange]);
// Clear the fix seed after the view unmounts so re-entering edit_agent
// (without going through Fix-with-Agent) doesn't re-show the prefix.
useEffect(() => () => { dispatch(clearFixSeed(workflow.id)); }, [dispatch, workflow.id]);
// On entering edit, ALWAYS hit edit-agent-session once (not just when the
// session is missing): the call reattaches the sticky chat AND, on the
// backend, snapshots a fresh draft from the current committed steps. If we
// skipped it when a session already existed (re-edit), the draft would never
// be created and edits would leak onto the live workflow.
const didInit = useRef(false);
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
let alive = true;
(async () => {
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/edit-agent-session`, {
method: 'POST',
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
});
if (!res.ok) return;
const data = await res.json();
const sid = data?.session_id as string | undefined;
if (!sid || !alive) return;
try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* may not be hydrated yet */ }
if (alive) setEditSessionId(sid);
} catch { /* best-effort */ }
})();
return () => { alive = false; };
}, [workflow.id, dispatch]);
// First-turn seed: post the hidden opener so the agent's first reply
// is the friendly "How would you like to modify the workflow..." prompt
// (or, in fix mode, an analysis of the failure context).
const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined);
useEffect(() => {
if (!editSessionId || !editSession || seedSent) return;
const msgs = editSession.messages || [];
if (msgs.length > 0) {
setSeedSent(true);
return;
}
const seed = isFixMode && fixSeed
? `The most recent run failed on Step ${fixSeed.stepIdx + 1} (${fixSeed.stepLabel}). Error: ${fixSeed.error}\n\nWalk me through what likely went wrong and propose a concrete prompt change for that step.`
// A brand-new workflow has no steps yet, so open in build mode ("what
// should this do?") rather than the modify-an-existing-flow prompt.
: steps.length === 0
? 'Greet me briefly, then ask: "What should this workflow do?"'
: 'Greet me briefly, then ask: "How would you like to modify the workflow (e.g. filter out spam emails before summarizing)?"';
setSeedSent(true);
(async () => {
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(editSessionId)}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
body: JSON.stringify({ prompt: seed, hidden: true }),
});
} catch { /* best-effort */ }
})();
}, [editSessionId, editSession, seedSent, isFixMode, fixSeed, steps.length]);
// Save flow: Save -> "test first?" popover -> optional test run -> "confirm
// save" popover. The step edits are staged in workflow.draft_steps; commit
// makes them live, discard throws them away.
const openSidecar = useOpenSidecar(workflow.id);
const [savePhase, setSavePhase] = useState<SavePhase>('idle');
const [saveAnchorEl, setSaveAnchorEl] = useState<HTMLElement | null>(null);
const [testSessionId, setTestSessionId] = useState<string | null>(null);
const draftSteps = workflow.draft_steps ?? steps;
const canSave = draftSteps.some((s) => (s.text || '').trim().length > 0);
const allowDiscard = !workflow.unsaved;
// A draft always exists in edit mode (we snapshot on entry), so only flag
// "unsaved" once the draft actually diverges from the committed steps.
const hasChanges = workflow.draft_steps != null && JSON.stringify(workflow.draft_steps) !== JSON.stringify(workflow.steps);
const toSaved = useCallback(() => {
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
}, [dispatch, workflow.id]);
const clearSidecar = useCallback(() => {
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null }));
}, [dispatch, workflow.id]);
const stopTest = useCallback(async () => {
if (!testSessionId) return;
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(testSessionId)}/stop`, {
method: 'POST', headers: tok ? { Authorization: `Bearer ${tok}` } : {},
});
} catch { /* best-effort */ }
}, [testSessionId]);
const onSaveNow = useCallback(async () => {
if (!canSave) return;
setSavePhase('idle');
try {
await dispatch(commitDraft({ id: workflow.id, model: editSession?.model })).unwrap();
} catch {
return;
}
toSaved();
}, [canSave, dispatch, workflow.id, editSession?.model, toSaved]);
const onSaveClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
if (!canSave) return;
// Already validated this exact version? Skip the "test first?" nudge.
if (!needsScheduleTestWarning(workflow)) { void onSaveNow(); return; }
setSaveAnchorEl(e.currentTarget);
setSavePhase('ask-test');
}, [canSave, workflow, onSaveNow]);
const onRunTest = useCallback(async () => {
setSavePhase('idle');
// The Test Agent card now owns the post-test decision (Continue editing /
// Save workflow) in its own footer, so just close this popover.
const sid = await runWorkflowTest(workflow.id, draftSteps, openSidecar);
if (sid) setTestSessionId(sid);
}, [workflow.id, draftSteps, openSidecar]);
const onDiscardClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
setSaveAnchorEl(e.currentTarget);
setSavePhase('confirm-discard');
}, []);
const onConfirmDiscard = useCallback(async () => {
setSavePhase('idle');
if (testSessionId) { await stopTest(); clearSidecar(); }
await dispatch(discardDraft(workflow.id));
toSaved();
}, [dispatch, workflow.id, testSessionId, stopTest, clearSidecar, toSaved]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{/* The "tab with the workflow inside": a collapsible strip that peeks
at the live steps (they update as the agent edits) without leaving
the chat. The header's Save Workflow button drops back to the card. */}
<Box sx={{ flexShrink: 0, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<Box
onClick={() => setStepsOpen((x) => !x)}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.25, cursor: 'pointer',
fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary,
'&:hover': { color: c.text.primary },
}}>
<KeyboardArrowDownRounded sx={{ fontSize: 16, transform: stepsOpen ? 'none' : 'rotate(-90deg)', transition: 'transform 0.15s ease' }} />
Workflow ({draftSteps.length} step{draftSteps.length === 1 ? '' : 's'})
</Box>
{hasChanges && (
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted }}>· unsaved</Typography>
)}
<Box sx={{ flex: 1 }} />
{allowDiscard && (
<Box
onClick={onDiscardClick}
role="button"
sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.muted, cursor: 'pointer', mr: 1, '&:hover': { color: c.status.error } }}>
Discard
</Box>
)}
<Box
onClick={canSave ? onSaveClick : undefined}
role="button"
title={canSave ? undefined : 'Add at least one step before saving'}
sx={{
fontSize: '0.8rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary,
px: 1.2, py: 0.35, borderRadius: c.radius.full, cursor: canSave ? 'pointer' : 'not-allowed',
opacity: canSave ? 1 : 0.45,
'&:hover': { filter: 'brightness(1.05)' },
}}>
Save
</Box>
</Box>
{stepsOpen && (
<Box sx={{ mt: 0.75 }}>
<StepList steps={draftSteps} />
{isFixMode && fixSeed && (
<Box sx={{ mt: 0.75 }}>
<FixPrefixCard seed={fixSeed} expanded={fixPrefixExpanded} onToggle={() => setFixPrefixExpanded((x) => !x)} />
</Box>
)}
</Box>
)}
</Box>
<EditAgentSavePopovers
phase={savePhase}
anchorEl={saveAnchorEl}
onClose={() => setSavePhase('idle')}
onSaveNow={onSaveNow}
onRunTest={onRunTest}
onConfirmDiscard={onConfirmDiscard}
/>
{/* The card IS the chat. AgentChat owns the composer + message list +
tool-call cards. Negative margins cancel the card body's p:2 so the
thread runs edge-to-edge like a normal chat (it supplies its own px). */}
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', mx: -2, mb: -2 }}>
{editSessionId ? (
<AgentChat sessionId={editSessionId} embedded autoFocus workflowEditId={workflow.id} />
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.muted, fontSize: '0.85rem' }}>
Starting the Edit Agent...
</Box>
)}
</Box>
</Box>
);
}
function FixPrefixCard({ seed, expanded, onToggle }: { seed: { stepIdx: number; stepLabel: string; error: string }; expanded: boolean; onToggle: () => void }) {
const c = useClaudeTokens();
const PREVIEW_MAX = 110;
const needsExpand = (seed.error || '').length > PREVIEW_MAX;
const shown = !needsExpand || expanded
? seed.error
: (seed.error || '').slice(0, PREVIEW_MAX).trimEnd() + '...';
return (
<Box
onClick={needsExpand ? onToggle : undefined}
sx={{
display: 'flex', alignItems: 'flex-start', gap: 1.25,
p: 1.25, borderRadius: `${c.radius.lg}px`,
bgcolor: c.status.errorBg,
border: `1px solid ${c.status.error}30`,
cursor: needsExpand ? 'pointer' : 'default',
'&:hover': needsExpand ? { bgcolor: c.status.error + '14' } : {},
}}>
<Box sx={{
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
bgcolor: c.status.error + '22', color: c.status.error,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<BuildRounded sx={{ fontSize: 16 }} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ flex: 1, fontSize: '0.92rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
Fixing Step {seed.stepIdx + 1}: {seed.stepLabel}
</Typography>
{needsExpand && (
<KeyboardArrowDownRounded sx={{
fontSize: 18,
color: c.text.muted,
transform: expanded ? 'rotate(180deg)' : 'none',
transition: 'transform 0.18s ease',
flexShrink: 0,
}} />
)}
</Box>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45, whiteSpace: 'pre-wrap' }}>
{shown}
</Typography>
</Box>
</Box>
);
}
@@ -1,162 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
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 Tooltip from '@mui/material/Tooltip';
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
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 }}
/>
<Tooltip title={draft.steps.length > 1 ? 'Remove step' : 'Workflow needs at least one step'}>
<span>
<IconButton
size="small"
aria-label={`Remove step ${idx + 1}`}
disabled={draft.steps.length <= 1}
onClick={() => {
if (draft.steps.length <= 1) return;
setDraft({ ...draft, steps: draft.steps.filter((_, i) => i !== idx) });
}}
sx={{
width: 26,
height: 26,
p: 0,
mt: 0.3,
color: c.text.muted,
flexShrink: 0,
'&:hover': {
color: c.status.error,
bgcolor: c.status.errorBg,
},
}}
>
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
</Box>
))}
</Box>
</Box>
);
}
@@ -1,361 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Checkbox from '@mui/material/Checkbox';
import CloseIcon from '@mui/icons-material/Close';
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
closeMissedRunsCard,
setMissedRunsCardPosition,
} from '@/shared/state/dashboardLayoutSlice';
import {
runMissedRuns,
dismissMissedRuns,
type MissedRunItem,
} from '@/shared/state/missedRunsSlice';
// Above this many selected, "Run" asks once before firing: each missed run is
// a real agent run, so a fat-fingered Run-all shouldn't quietly spend money.
const CONFIRM_THRESHOLD = 10;
interface Props {
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: 'missed_runs', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'missed_runs') => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
onBringToFront?: (id: string, type: 'missed_runs') => void;
}
function formatWhen(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, {
weekday: 'short', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
}
const MissedRunsCard: React.FC<Props> = ({
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
isSelected = false, isHighlighted = false, multiDragDelta = null,
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const items = useAppSelector((s) => s.missedRuns.items);
// Unchecked ids; default is everything checked. Run acts on the checked set.
const [unchecked, setUnchecked] = useState<Set<string>>(new Set());
const [confirming, setConfirming] = useState(false);
const selectedIds = useMemo(
() => items.filter((m) => !unchecked.has(m.id)).map((m) => m.id),
[items, unchecked],
);
const groups = useMemo(() => {
const by = new Map<string, { title: string; runs: MissedRunItem[] }>();
for (const m of items) {
const g = by.get(m.workflow_id) || { title: m.workflow_title, runs: [] };
g.runs.push(m);
by.set(m.workflow_id, g);
}
return Array.from(by.values());
}, [items]);
// Once everything has been run or dismissed, the card has nothing left to say.
useEffect(() => {
if (items.length === 0) dispatch(closeMissedRunsCard());
}, [items.length, dispatch]);
const toggle = useCallback((id: string) => {
setConfirming(false);
setUnchecked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
// A group header's checkbox flips every run under it: if any are checked,
// clear the lot; otherwise check the lot.
const toggleGroup = useCallback((runs: MissedRunItem[]) => {
setConfirming(false);
setUnchecked((prev) => {
const anyChecked = runs.some((r) => !prev.has(r.id));
const next = new Set(prev);
for (const r of runs) {
if (anyChecked) next.add(r.id); else next.delete(r.id);
}
return next;
});
}, []);
const runSelected = useCallback(() => {
if (selectedIds.length === 0) return;
if (selectedIds.length > CONFIRM_THRESHOLD && !confirming) {
setConfirming(true);
return;
}
setConfirming(false);
dispatch(runMissedRuns(selectedIds));
}, [dispatch, selectedIds, confirming]);
// Closing means "I'm done": drop whatever's still listed, logged as skipped.
const closeAndDismissRest = useCallback(() => {
const rest = items.map((m) => m.id);
if (rest.length) dispatch(dismissMissedRuns(rest));
dispatch(closeMissedRunsCard());
}, [dispatch, items]);
// ---- Card drag via header (mirrors WorkflowsHubCard) ----
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 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')) 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);
onDragStart?.('missed-runs', 'missed_runs');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, onDragStart]);
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;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy });
onDragMove?.(dx, dy, e.clientX, e.clientY);
}, [onDragMove]);
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) {
justDraggedRef.current = true;
setTimeout(() => { justDraggedRef.current = false; }, 0);
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(setMissedRunsCardPosition({ x: finalX, y: finalY }));
}
onDragEnd?.(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, onDragEnd]);
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const dx = (localDragPos?.x ?? cardX) + mdDx;
const dy = (localDragPos?.y ?? cardY) + mdDy;
const border = isHighlighted
? `2px solid ${c.accent.primary}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.strong}`;
const shadow = isDragging ? c.shadow.lg : isSelected ? `0 0 0 1px #3b82f6, ${c.shadow.md}` : c.shadow.sm;
const runLabel = confirming
? `Run ${selectedIds.length} now?`
: selectedIds.length === items.length
? `Run all ${items.length}`
: `Run ${selectedIds.length} selected`;
return (
<Box
data-select-type="missed-runs-card"
data-select-id="missed-runs"
onPointerDownCapture={(e: React.PointerEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag]')) return;
onBringToFront?.('missed-runs', 'missed_runs');
}}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag]')) return;
onCardSelect?.('missed-runs', 'missed_runs', e.shiftKey);
}}
sx={{
position: 'absolute',
contain: 'layout style',
willChange: 'transform',
left: dx,
top: dy,
width: cardWidth,
height: cardHeight,
bgcolor: c.bg.surface,
border,
borderRadius: 3,
boxShadow: shadow,
display: 'flex',
flexDirection: 'column',
zIndex: isDragging ? 999999 : cardZOrder,
transition: isDragging ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease',
}}
>
{/* Title strip (drag handle) */}
<Box
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 0.6,
px: 1.5, py: 0.7,
borderBottom: `1px solid ${c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none', flexShrink: 0,
}}
>
<HistoryRoundedIcon sx={{ fontSize: 17, color: c.accent.primary }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 600, fontSize: '0.95rem', color: c.text.primary }}>Missed while you were away</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
{items.length} run{items.length === 1 ? '' : 's'} didn&apos;t fire. Run the ones you still want.
</Typography>
</Box>
<IconButton
size="small"
data-no-drag
onClick={(e) => { e.stopPropagation(); closeAndDismissRest(); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Scrollable list grouped by workflow */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 1, py: 0.5 }}>
{groups.map((g) => {
const checkedCount = g.runs.filter((r) => !unchecked.has(r.id)).length;
const allChecked = checkedCount === g.runs.length;
return (
<Box key={g.title + g.runs[0].workflow_id} sx={{ mb: 0.75 }}>
<Box
data-no-drag
onClick={() => toggleGroup(g.runs)}
sx={{ display: 'flex', alignItems: 'center', gap: 0.4, px: 0.75, py: 0.4, borderRadius: `${c.radius.sm}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}
>
<Checkbox
size="small"
checked={allChecked}
indeterminate={checkedCount > 0 && !allChecked}
onChange={() => toggleGroup(g.runs)}
onClick={(e) => e.stopPropagation()}
sx={{ p: 0.25, color: c.text.muted, '&.Mui-checked': { color: c.accent.primary }, '&.MuiCheckbox-indeterminate': { color: c.accent.primary } }}
/>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 700, color: c.accent.primary, flexShrink: 0 }}>{g.runs.length}</Typography>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{g.title}</Typography>
</Box>
{g.runs.map((m) => (
<Box
key={m.id}
data-no-drag
onClick={() => toggle(m.id)}
sx={{
display: 'flex', alignItems: 'center', gap: 0.4,
pl: 1, pr: 0.75, py: 0.15, ml: 1.5,
borderRadius: `${c.radius.sm}px`, cursor: 'pointer',
'&:hover': { bgcolor: c.bg.elevated },
}}
>
<Checkbox
size="small"
checked={!unchecked.has(m.id)}
onChange={() => toggle(m.id)}
onClick={(e) => e.stopPropagation()}
sx={{ p: 0.25, color: c.text.muted, '&.Mui-checked': { color: c.accent.primary } }}
/>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>{formatWhen(m.scheduled_for)}</Typography>
</Box>
))}
</Box>
);
})}
</Box>
{/* Footer actions */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.25, py: 0.85, borderTop: `1px solid ${c.border.subtle}`, flexShrink: 0 }}>
{confirming && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, flex: 1 }}>
That&apos;s {selectedIds.length} real runs.
</Typography>
)}
{!confirming && <Box sx={{ flex: 1 }} />}
<Box
data-no-drag
role="button"
onClick={confirming ? () => setConfirming(false) : closeAndDismissRest}
sx={{ fontSize: '0.78rem', color: c.text.muted, cursor: 'pointer', px: 1, py: 0.5, '&:hover': { color: c.text.primary } }}
>
{confirming ? 'Cancel' : 'Skip all'}
</Box>
<Box
data-no-drag
role="button"
onClick={runSelected}
sx={{
fontSize: '0.78rem', fontWeight: 600,
color: selectedIds.length === 0 ? c.text.ghost : '#fff',
bgcolor: selectedIds.length === 0 ? c.bg.secondary : c.accent.primary,
cursor: selectedIds.length === 0 ? 'default' : 'pointer',
px: 1.25, py: 0.5, borderRadius: `${c.radius.md}px`,
'&:hover': { bgcolor: selectedIds.length === 0 ? c.bg.secondary : c.accent.hover },
}}
>
{runLabel}
</Box>
</Box>
</Box>
);
};
export default MissedRunsCard;
@@ -1,6 +1,6 @@
// Bottom-left nudge shown on launch when scheduled runs elapsed while the app
// was closed. It stays put until the user acts (no auto-hide): Review opens and
// pans the canvas to the missed-runs card; clicking away or the X dismisses it.
// was closed. It stays put until the user acts (no auto-hide): Review opens the
// Workflows app (its Home surfaces the missed runs); the X dismisses it.
import React from 'react';
import Snackbar from '@mui/material/Snackbar';
@@ -11,7 +11,7 @@ import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { hideMissedRunsToast } from '@/shared/state/missedRunsSlice';
import { openMissedRunsCard } from '@/shared/state/dashboardLayoutSlice';
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
export default function MissedRunsToast() {
const c = useClaudeTokens();
@@ -20,7 +20,7 @@ export default function MissedRunsToast() {
const count = useAppSelector((s) => s.missedRuns.items.length);
const onReview = React.useCallback(() => {
dispatch(openMissedRunsCard(undefined));
dispatch(openWorkflowsApp());
dispatch(hideMissedRunsToast());
}, [dispatch]);
@@ -1,532 +0,0 @@
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, isScheduleActive } 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 (!isScheduleActive(sched)) 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;
if (sched.repeat_unit === 'minute') {
const step = Math.max(15, sched.repeat_every);
let c = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), 0, 0);
while (c <= now) c = new Date(c.getTime() + step * 60000);
return c;
}
if (sched.repeat_unit === 'hour') {
const step = Math.max(1, sched.repeat_every);
let c = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), sched.minute, 0, 0);
while (c <= now) c = new Date(c.getTime() + step * 3600000);
return c;
}
let candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), sched.hour, sched.minute, 0, 0);
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 (candidate <= now) candidate = new Date(candidate.getTime() + 86400000);
if (sched.repeat_unit === 'week') {
const allowed = sched.on_days.length ? sched.on_days : [jsWeekday(now)];
const step = Math.max(1, sched.repeat_every);
const anchorWeek = new Date(now);
anchorWeek.setHours(0, 0, 0, 0);
anchorWeek.setDate(anchorWeek.getDate() - anchorWeek.getDay());
for (let i = 0; i < 7 * step + 7; i += 1) {
const candidateWeek = new Date(candidate);
candidateWeek.setHours(0, 0, 0, 0);
candidateWeek.setDate(candidateWeek.getDate() - candidateWeek.getDay());
const weekDelta = Math.floor((candidateWeek.getTime() - anchorWeek.getTime()) / (7 * 86400000));
if (allowed.includes(jsWeekday(candidate)) && candidate > now && (weekDelta === 0 || weekDelta % step === 0)) 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 = sched.day_of_month || 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} frequent={s.repeat_unit === 'minute' || s.repeat_unit === 'hour'} 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) => {
// Per-unit bounds mirror the backend: minute 15..1440 (24h),
// every other unit 1..365.
const min = s.repeat_unit === 'minute' ? 15 : 1;
const max = s.repeat_unit === 'minute' ? 1440 : 365;
setSched({ repeat_every: Math.min(max, Math.max(min, Number(e.target.value) || min)) });
}}
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) => {
const unit = e.target.value as ScheduleConfig['repeat_unit'];
// 15 is the floor for the minute unit; bump repeat_every up
// when switching in so the input never shows an invalid value.
const patch: Partial<ScheduleConfig> = { repeat_unit: unit };
if (unit === 'minute' && s.repeat_every < 15) patch.repeat_every = 15;
if (unit === 'month' && !s.day_of_month) patch.day_of_month = new Date().getDate();
setSched(patch);
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="minute">minute</MenuItem>
<MenuItem value="hour">hour</MenuItem>
<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>
)}
{s.repeat_unit === 'month' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 12, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.muted }}> on day</Typography>
<InputBase
type="number"
value={s.day_of_month || new Date().getDate()}
onChange={(e) => setSched({ day_of_month: Math.min(31, 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 }}
/>
</Box>
)}
{/* minute-unit schedules have no anchor time; hour-unit schedules
only need the minute offset; the rest pick a full clock time. */}
{s.repeat_unit === 'hour' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>At</Typography>
<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>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>past the hour</Typography>
</Box>
)}
{(s.repeat_unit === 'day' || s.repeat_unit === 'week' || s.repeat_unit === 'month') && (
<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, 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, 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>
{/* 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, frequent, onFix }: { info: AppOpenInfo; hour: number; minute: number; frequent: boolean; 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,
border: `1px solid ${good ? c.status.success + '60' : c.status.warning + '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 }} />
<Typography sx={{ flex: 1, fontSize: HINT_FS, color: c.text.primary }}>
{good ? 'Will run even if you close OpenSwarm.' : (frequent ? 'OpenSwarm must be open for this to run.' : `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, fontStyle: 'italic' }}>
Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge.
</Typography>
)}
</Box>
);
}
@@ -1,266 +0,0 @@
// 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, day_of_month: 1, hour: 9, minute: 0 }) },
];
function extractStepsFromSession(session: { messages?: Array<{ role: string; content: unknown; hidden?: boolean }> } | null | undefined): 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;
}
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,
);
const sourceSession = useAppSelector((s) => sessionId ? s.agents.sessions[sessionId] : 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,
steps: extractStepsFromSession(sourceSession),
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, sourceSession, 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,
border: `1px solid ${c.status.warning + '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>
);
}
@@ -1,148 +0,0 @@
// Image #49: the scheduling chat embedded in the workflow card.
// Mirrors EditAgentView: a sticky-per-workflow agent session (via
// /workflows/{id}/schedule-agent-session) interprets the user's cadence
// ("every Wednesday at 1pm") itself and commits via UpdateScheduledWorkflow,
// which is force-gated to "ask" so the commit shows up as a real ApprovalBar
// tool card in the chat. No deterministic pre-parse: the cadence is a model
// decision. Once the schedule turns enabled, we drop back to the saved view.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
import { fetchSession } from '@/shared/state/agentsSlice';
import { API_BASE, getAuthToken } from '@/shared/config';
import StepList from './StepList';
import AgentChat from '@/app/pages/AgentChat/AgentChat';
interface Props {
workflow: Workflow;
steps: Workflow['steps'];
}
export default function SchedulingView({ workflow, steps }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [stepsOpen, setStepsOpen] = useState(true);
const [scheduleSessionId, setScheduleSessionId] = useState<string | null>(workflow.schedule_agent_session_id || null);
const [seedSent, setSeedSent] = useState(false);
// Spawn (or reattach to) the sticky scheduling-agent session on mount.
useEffect(() => {
if (scheduleSessionId) return;
let alive = true;
(async () => {
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/schedule-agent-session`, {
method: 'POST',
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
});
if (!res.ok) return;
const data = await res.json();
const sid = data?.session_id as string | undefined;
if (!sid || !alive) return;
try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* may not be hydrated yet */ }
if (alive) setScheduleSessionId(sid);
} catch { /* best-effort */ }
})();
return () => { alive = false; };
}, [scheduleSessionId, workflow.id, dispatch]);
// First-turn seed: hidden opener so the agent's first reply is the
// figma's "When should this workflow run..." question.
const scheduleSession = useAppSelector((s) => scheduleSessionId ? s.agents.sessions[scheduleSessionId] : undefined);
useEffect(() => {
if (!scheduleSessionId || !scheduleSession || seedSent) return;
const msgs = scheduleSession.messages || [];
if (msgs.length > 0) {
setSeedSent(true);
return;
}
setSeedSent(true);
(async () => {
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const cadenceHint = workflow.suggested_cadence ? ` I think it should run ${workflow.suggested_cadence}.` : '';
const prompt = `Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"${cadenceHint}`;
await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(scheduleSessionId)}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
body: JSON.stringify({
prompt,
hidden: true,
}),
});
} catch { /* best-effort */ }
})();
}, [scheduleSessionId, scheduleSession, seedSent]);
// Drop back to the saved view once a commit lands. The scheduling agent
// only PATCHes through the approved tool call, so a changed updated_at
// with the schedule now enabled means the user approved it. Comparing
// against the mount-time value avoids bouncing out on entry (e.g. when
// rescheduling a workflow that was already enabled).
const initialUpdatedAt = useRef(workflow.updated_at);
useEffect(() => {
if (workflow.schedule?.enabled && workflow.updated_at !== initialUpdatedAt.current) {
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
}
}, [workflow.schedule?.enabled, workflow.updated_at, workflow.id, dispatch]);
const onCancel = useCallback(() => {
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
}, [dispatch, workflow.id]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{/* Collapsible "here's the workflow" strip peeks at the read-only steps
without leaving the chat; Cancel drops back to the saved card. */}
<Box sx={{ flexShrink: 0, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<Box
onClick={() => setStepsOpen((x) => !x)}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.25, cursor: 'pointer',
fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary,
'&:hover': { color: c.text.primary },
}}>
<KeyboardArrowDownRounded sx={{ fontSize: 16, transform: stepsOpen ? 'none' : 'rotate(-90deg)', transition: 'transform 0.15s ease' }} />
Workflow ({steps.length} step{steps.length === 1 ? '' : 's'})
</Box>
<Box sx={{ flex: 1 }} />
<Box
onClick={onCancel}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.8rem', fontWeight: 600, color: c.text.muted, cursor: 'pointer',
'&:hover': { color: c.status.error },
}}>
<DeleteOutlineRounded sx={{ fontSize: 15 }} />
Cancel task scheduling
</Box>
</Box>
{stepsOpen && (
<Box sx={{ mt: 0.75 }}>
<StepList steps={steps} />
</Box>
)}
</Box>
{/* The card IS the chat. Negative margins cancel the card body's p:2 so
the thread (and the ApprovalBar tool card) runs edge-to-edge. */}
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', mx: -2, mb: -2 }}>
{scheduleSessionId ? (
<AgentChat sessionId={scheduleSessionId} embedded autoFocus />
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.muted, fontSize: '0.85rem' }}>
Starting...
</Box>
)}
</Box>
</Box>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,162 +0,0 @@
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 ArrowBackRounded from '@mui/icons-material/ArrowBackRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { updateWorkflow, updateWorkflowCard, 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 }}>
<Box
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }))}
role="button"
aria-label="Back"
sx={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 26, height: 26, borderRadius: 999, mr: 0.25,
color: c.text.secondary, cursor: 'pointer',
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
}}>
<ArrowBackRounded sx={{ fontSize: 17 }} />
</Box>
<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>
);
}
@@ -1,8 +1,7 @@
// Clickable "your {workflow} is running now" nudge for scheduled runs that
// fire while the user isn't looking. Detection lives in the upsertRun reducer
// (it owns the into-running edge); this just renders the redux toast state and,
// on View, jumps the canvas to the workflow, opening its live conversation if
// it wasn't already on screen.
// on View, opens the Workflows app to that workflow's live detail.
import React from 'react';
import Snackbar from '@mui/material/Snackbar';
@@ -10,39 +9,19 @@ import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { store } from '@/shared/state/store';
import { dismissRunningToast, openWorkflowCard, type OpenCard } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { useOpenSidecar } from './WorkflowCardLiveViews';
import { dismissRunningToast } from '@/shared/state/workflowsSlice';
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
export default function WorkflowRunningToast() {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const toast = useAppSelector((s) => s.workflows.runningToast);
const openSidecar = useOpenSidecar(toast?.workflowId || '');
const onView = React.useCallback(() => {
if (!toast) return;
const { workflowId, runId } = toast;
const st = store.getState();
const alreadyOpen = Boolean(st.dashboardLayout.workflowCards[workflowId]);
// addWorkflowCard pans the canvas to the card whether it already exists or
// gets created here (both set pendingFocusWorkflowId for the lifecycle hook).
dispatch(addWorkflowCard({ workflowId }));
if (!alreadyOpen) {
const run = st.workflows.runs[workflowId]?.find((r) => r.id === runId);
const status = run?.status;
const view: OpenCard['view'] = status === 'failure' ? 'failed'
: (status === 'success' || status === 'ran_late') ? 'completed' : 'running';
dispatch(openWorkflowCard({ workflowId, view, runId }));
if (run?.session_id) {
const kind = status === 'failure' ? 'viewing-error'
: (status === 'success' || status === 'ran_late') ? 'viewing-completed' : 'watching';
void openSidecar(run.session_id, kind);
}
}
dispatch(openWorkflowsApp({ workflowId: toast.workflowId }));
dispatch(dismissRunningToast());
}, [toast, dispatch, openSidecar]);
}, [toast, dispatch]);
return (
<Snackbar
@@ -1,776 +0,0 @@
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 Fade from '@mui/material/Fade';
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,
DEFAULT_CARD_H,
DEFAULT_CARD_W,
placeCard,
setWorkflowsHubPosition,
setWorkflowsHubSize,
} from '@/shared/state/dashboardLayoutSlice';
import { openWorkflowCard, createWorkflow, fetchWorkflows, fetchPausedState, fetchRuns, setCardSidecar, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice';
import { fetchSession } from '@/shared/state/agentsSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { store } from '@/shared/state/store';
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 AddToSchedulePopover from './AddToSchedulePopover';
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid, isWorkflowSchedulable, stepsSignature } from './scheduleUtils';
import { isRealTitle } from './workflowVisuals';
import { Typewriter } from '@/app/components/feedback/Animated';
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 } },
];
function findRunForOccurrence(runs: WorkflowRun[], fireAt: Date): WorkflowRun | null {
const target = fireAt.getTime();
if (!Number.isFinite(target)) return null;
let best: WorkflowRun | null = null;
let bestDelta = Number.POSITIVE_INFINITY;
for (const run of runs) {
if (!run.scheduled_for) continue;
const scheduledAt = new Date(run.scheduled_for).getTime();
if (!Number.isFinite(scheduledAt)) continue;
const delta = Math.abs(scheduledAt - target);
if (delta < bestDelta) {
best = run;
bestDelta = delta;
}
}
return best && bestDelta <= 60_000 ? best : null;
}
function viewForRun(run: WorkflowRun): 'running' | 'completed' | 'failed' | 'history_detail' {
if (run.status === 'running') return 'running';
if (run.status === 'success' || run.status === 'ran_late') return 'completed';
if (run.status === 'failure') return 'failed';
return 'history_detail';
}
function sidecarKindForRun(run: WorkflowRun): 'watching' | 'viewing-completed' | 'viewing-error' | null {
if (run.status === 'running') return 'watching';
if (run.status === 'success' || run.status === 'ran_late') return 'viewing-completed';
if (run.status === 'failure') return 'viewing-error';
return null;
}
interface Props {
dashboardId: 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: 'workflows-hub', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'workflows-hub') => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
onBringToFront?: (id: string, type: 'workflows-hub') => void;
}
type CalendarView = 'Week' | 'Month' | 'List';
const WorkflowsHubCard: React.FC<Props> = ({
dashboardId,
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
isSelected = false, isHighlighted = false, multiDragDelta = null,
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const workflows = useAppSelector((s) => s.workflows.items);
const paused = useAppSelector((s) => s.workflows.paused);
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]);
// The hub is the signal "user is looking at workflows now", so load them
// eagerly here instead of waiting on the dashboard's deferred idle fetch
// (which left the calendar blank for ~2s). The thunk's !loading condition
// dedups against that idle dispatch.
useEffect(() => { dispatch(fetchWorkflows(dashboardId)); }, [dashboardId, dispatch]);
const togglePaused = useCallback(() => {
dispatch(setPausedAll(!paused));
}, [dispatch, paused]);
const [view, setView] = useState<CalendarView>('List');
const [viewOpen, setViewOpen] = useState(false);
const [refDate, setRefDate] = useState(new Date());
// The hub card lives on the canvas for days at a time, so a refDate frozen
// at mount leaves the calendar stuck on the day it was opened (e.g. still
// showing "yesterday" past midnight). Roll it forward when the day flips,
// but only if the user was parked on today, so manual navigation is left be.
const refDateRef = useRef(refDate);
refDateRef.current = refDate;
useEffect(() => {
let lastToday = new Date();
const id = window.setInterval(() => {
const now = new Date();
if (sameDay(now, lastToday)) return;
if (sameDay(refDateRef.current, lastToday)) setRefDate(now);
lastToday = now;
}, 60000);
return () => window.clearInterval(id);
}, []);
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), []);
// Anchored off an Unscheduled row's "+" icon: opens the scheduler.
const [schedulePopover, setSchedulePopover] = useState<{ anchorEl: HTMLElement; workflow: Workflow } | null>(null);
const closeSchedulePopover = useCallback(() => setSchedulePopover(null), []);
// "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.
// Hide brand-new "+ New" workflows that the user is still building and
// hasn't saved yet; commit (Save) clears `unsaved` and they appear.
const saved = useMemo(() => Object.values(workflows).filter((w) => !w.unsaved), [workflows]);
const scheduled = useMemo(() => saved.filter((w) => isWorkflowSchedulable(w)), [saved]);
const unscheduled = useMemo(() => saved.filter((w) => !isWorkflowSchedulable(w)), [saved]);
const monthLabel = refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
const onSelectWorkflow = useCallback(async (wid: string, fireAt?: Date) => {
dispatch(addWorkflowCard({ workflowId: wid }));
dispatch(openWorkflowCard({ workflowId: wid, view: 'saved' }));
if (!fireAt) return;
let runs = store.getState().workflows.runs[wid] || [];
let clickedRun = findRunForOccurrence(runs, fireAt);
if (!clickedRun) {
const result = await dispatch(fetchRuns(wid));
if (fetchRuns.fulfilled.match(result)) runs = result.payload.runs;
clickedRun = findRunForOccurrence(runs, fireAt);
}
if (!clickedRun) return;
const runView = viewForRun(clickedRun);
dispatch(openWorkflowCard({
workflowId: wid,
view: runView,
runId: clickedRun.id,
historyRunId: runView === 'history_detail' ? clickedRun.id : null,
}));
if (!clickedRun.session_id) return;
const sid = clickedRun.session_id;
if (!store.getState().agents.sessions[sid]) {
try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* fall back to the run card */ }
}
if (!store.getState().agents.sessions[sid]) return;
const wfCard = store.getState().dashboardLayout.workflowCards[wid];
if (!store.getState().dashboardLayout.cards[sid]) {
dispatch(placeCard({
sessionId: sid,
x: wfCard ? wfCard.x + wfCard.width + 60 : cardX + cardWidth + 60,
y: wfCard ? wfCard.y : cardY,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
expandedSessionIds: store.getState().agents.expandedSessionIds,
}));
}
dispatch(setPendingFocusAgentId(sid));
dispatch(setCardSidecar({ workflowId: wid, sessionId: sid, kind: sidecarKindForRun(clickedRun) }));
}, [cardWidth, cardX, cardY, dispatch]);
// A from-scratch workflow has no steps to convert, so skip the chat->workflow
// PreviewView (Schedule prompt / blank bullet) and drop straight into the
// Edit Agent build chat: the user describes it, the agent writes the steps.
// The workflow is created server-side first so the embedded edit-agent
// session has a real id to attach to; an abandoned (still 0-step) one is
// cleaned up on card close (WorkflowCard.onClose).
const onNew = useCallback(async () => {
// Clean up any abandoned empty drafts first so "New" always starts fresh
// instead of leaving a half-built 0-step workflow lingering on the canvas.
Object.values(workflows)
.filter((w) => w.unsaved && (w.steps?.length ?? 0) === 0)
.forEach((w) => dispatch(deleteWorkflow(w.id)));
// Build on the user's chosen default model (not a hardcoded one) so the
// Edit Agent, and the scheduled runs, use the model they actually intend.
const result = await dispatch(createWorkflow({ title: 'New workflow', steps: [], unsaved: true, model: defaultModel }));
if (!createWorkflow.fulfilled.match(result)) return;
const wf = result.payload;
dispatch(addWorkflowCard({ workflowId: wf.id }));
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'edit_agent' }));
}, [dispatch, workflows, defaultModel]);
// ---- 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 justDraggedRef = 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);
onDragStart?.('workflows-hub', 'workflows-hub');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, onDragStart]);
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;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
});
onDragMove?.(dx, dy, e.clientX, e.clientY);
}, [onDragMove]);
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) {
justDraggedRef.current = true;
setTimeout(() => { justDraggedRef.current = false; }, 0);
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 }));
}
onDragEnd?.(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, onDragEnd]);
// ---- 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 mdDx = (!isDragging && !isResizing && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && !isResizing && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const dx = (localResize?.x ?? localDragPos?.x ?? cardX) + mdDx;
const dy = (localResize?.y ?? localDragPos?.y ?? cardY) + mdDy;
const dw = localResize?.w ?? cardWidth;
const dh = localResize?.h ?? cardHeight;
const border = isHighlighted
? `2px solid ${c.accent.primary}`
: isSelected
? '2px solid #3b82f6'
: `1px solid ${c.border.strong}`;
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.sm;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
return (
<Box
data-select-type="workflows-hub-card"
data-select-id="workflows-hub"
data-select-meta={JSON.stringify({ name: 'Workflows calendar' })}
onPointerDownCapture={(e: React.PointerEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag]')) return;
onBringToFront?.('workflows-hub', 'workflows-hub');
}}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag]')) return;
onCardSelect?.('workflows-hub', 'workflows-hub', e.shiftKey);
}}
sx={{
position: 'absolute',
contain: 'layout style',
willChange: 'transform',
left: dx,
top: dy,
width: dw,
height: dh,
bgcolor: c.bg.surface,
border,
borderRadius: `${c.radius.lg}px`,
boxShadow: shadow,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s 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.5,
bgcolor: c.bg.elevated,
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: 600, fontSize: '0.95rem', 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.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* ===== Toolbar row (matches Figma image #8 header) ===== */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.65, px: 1.5, py: 0.55, bgcolor: c.bg.elevated, 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.surface, 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 },
}}
>
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.82rem', fontWeight: 600,
color: c.text.secondary,
bgcolor: paused ? c.bg.elevated : 'transparent',
border: `1px solid ${paused ? c.border.medium : c.border.subtle}`,
px: 1, py: 0.35, 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, 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>
</Box>
<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>
<Fade in={viewOpen} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<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 }}>
{(['List', 'Week', 'Month'] 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>
</Fade>
</Box>
</Box>
{/* ===== Body: sidebar + main calendar ===== */}
<Box sx={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* Sidebar */}
{sidebarOpen && (
<Box sx={{ width: 210, flexShrink: 0, bgcolor: c.bg.elevated, borderRight: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ px: 1.25, pt: 1, pb: 0.6 }}>
<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.25, pb: 1.25 }}>
<SidebarSection title="Scheduled" items={scheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
<SidebarSection title="Unscheduled" items={unscheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} onSchedule={(wf, el) => setSchedulePopover({ anchorEl: el, workflow: wf })} />
</Box>
</Box>
)}
{/* Main calendar area */}
<Box sx={{ flex: 1, minWidth: 0, overflow: 'auto', px: 1.25, pt: 0, pb: 1.25, bgcolor: c.bg.surface }}>
<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({
id: sidebarCtxMenu.workflow.id,
signature: stepsSignature(sidebarCtxMenu.workflow.steps),
}));
closeSidebarCtxMenu();
}}>Run now</MenuItem>
{sidebarCtxMenu && isWorkflowSchedulable(sidebarCtxMenu.workflow) && (
<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_agent' }));
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>
{/* "+" on an Unscheduled row -> open the scheduler */}
<AddToSchedulePopover
anchorEl={schedulePopover?.anchorEl ?? null}
workflow={schedulePopover?.workflow ?? null}
onClose={closeSchedulePopover}
/>
{/* 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.25, pb: 0.75, 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, onSchedule }: {
title: string;
items: Workflow[];
onPick: (id: string) => void;
scheduled: boolean;
onContext: (workflow: Workflow, e: React.MouseEvent) => void;
// Only the Unscheduled section wires this: clicking the "+" opens the
// schedule creation popover anchored to the icon.
onSchedule?: (workflow: Workflow, anchorEl: HTMLElement) => void;
}) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const allPaused = useAppSelector((s) => s.workflows.paused);
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: c.radius.sm, 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>
) : (
<Tooltip title="Add to schedule">
<Box
onClick={(e) => { e.stopPropagation(); onSchedule?.(w, e.currentTarget); }}
sx={{
width: 16, height: 16, borderRadius: c.radius.sm, flexShrink: 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: c.text.muted, cursor: 'pointer',
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
}}>
<AddIcon sx={{ fontSize: 13 }} />
</Box>
</Tooltip>
)}
{/* Retype the title letter-by-letter when it auto-renames, matching
the workflow card. Gated on a real (non-placeholder) title so it
never animates on first appearance or for already-named rows. */}
<Typewriter value={w.title} enabled={isRealTitle(w.title)}>
{(t) => (
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: scheduled && (!w.schedule.enabled || allPaused) ? 0.7 : 1 }}>{t}</Typography>
)}
</Typewriter>
{scheduled && (!w.schedule.enabled || allPaused) && (
<Box sx={{ flexShrink: 0, px: 0.6, py: 0.1, borderRadius: c.radius.sm, bgcolor: c.bg.elevated, color: c.text.muted, fontSize: '0.62rem', fontWeight: 600, lineHeight: 1.5, letterSpacing: '0.02em' }}>Paused</Box>
)}
</Box>
))}
</Box>
);
}
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,198 @@
import React, { useMemo, useRef, useEffect } from 'react';
import type { CSSProperties } from 'react';
import { useAppSelector } from '@/shared/hooks';
import { fireTimesWithin, startOfWeek, startOfMonthGrid, addDays, sameDay } from '@/app/pages/Workflows/scheduleUtils';
import { colorForId, WC } from './uiKit';
import type { AppNav } from './types';
interface Occ { wfId: string; title: string; at: Date; }
const DOW = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
function miniTime(d: Date): string {
let h = d.getHours();
const m = d.getMinutes();
const ap = h < 12 ? 'am' : 'pm';
h = h % 12 === 0 ? 12 : h % 12;
return `${h}:${String(m).padStart(2, '0')}${ap}`;
}
function hourLabel(h: number): string {
if (h === 0) return '12 AM';
if (h < 12) return `${h} AM`;
if (h === 12) return '12 PM';
return `${h - 12} PM`;
}
const tabBtn = (active: boolean): CSSProperties => ({
padding: '5px 13px', borderRadius: 7, border: 'none', cursor: 'pointer', fontSize: 12.5, fontWeight: 600,
background: active ? WC.paper : 'transparent', color: active ? WC.ink : WC.muted,
boxShadow: active ? '0 1px 3px rgba(33,30,27,0.10)' : 'none',
});
const CalendarView: React.FC<{ nav: AppNav }> = ({ nav }) => {
const items = useAppSelector((s) => s.workflows.items);
const now = new Date();
const ref = nav.refDate;
// Window of occurrences spanning the visible month grid (covers week too).
const occ = useMemo<Occ[]>(() => {
const from = startOfMonthGrid(ref);
const to = addDays(from, 42);
const out: Occ[] = [];
for (const wf of Object.values(items)) {
if (wf.unsaved) continue;
for (const at of fireTimesWithin(wf, from, to, 200)) {
out.push({ wfId: wf.id, title: wf.title || 'Untitled', at });
}
}
return out.sort((a, b) => a.at.getTime() - b.at.getTime());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, ref]);
const occByDay = useMemo(() => {
const map = new Map<string, Occ[]>();
for (const o of occ) {
const key = `${o.at.getFullYear()}-${o.at.getMonth()}-${o.at.getDate()}`;
const arr = map.get(key) || [];
arr.push(o);
map.set(key, arr);
}
return map;
}, [occ]);
const dayKey = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const title = ref.toLocaleDateString([], { month: 'long', year: 'numeric' });
const step = (dir: number) => {
if (nav.calView === 'week') nav.setRefDate(addDays(ref, dir * 7));
else nav.setRefDate(new Date(ref.getFullYear(), ref.getMonth() + dir, 1));
};
return (
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.paper }}>
<div style={{ flex: 'none', padding: '14px 26px', borderBottom: `1px solid ${WC.line}`, display: 'flex', alignItems: 'center', gap: 14 }}>
<button onClick={() => nav.setRefDate(new Date())} style={{ background: WC.paper, border: '1px solid rgba(33,30,27,0.14)', borderRadius: 8, padding: '6px 14px', fontSize: 13, fontWeight: 600, color: WC.ink, cursor: 'pointer' }}>Today</button>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => step(-1)} style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid rgba(33,30,27,0.12)', background: WC.paper, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.ink3 }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M15 6l-6 6 6 6" /></svg></button>
<button onClick={() => step(1)} style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid rgba(33,30,27,0.12)', background: WC.paper, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.ink3 }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 6l6 6-6 6" /></svg></button>
</div>
<h1 style={{ margin: 0, fontFamily: "'Newsreader',serif", fontSize: 22, fontWeight: 600, color: WC.ink, letterSpacing: '-0.01em' }}>{title}</h1>
<div style={{ flex: 1 }} />
<div style={{ display: 'flex', background: WC.inset, border: `1px solid ${WC.line}`, borderRadius: 9, padding: 3, gap: 2 }}>
<button onClick={() => nav.setCalView('week')} style={tabBtn(nav.calView === 'week')}>Week</button>
<button onClick={() => nav.setCalView('month')} style={tabBtn(nav.calView === 'month')}>Month</button>
</div>
</div>
{nav.calView === 'month'
? <MonthGrid ref0={ref} now={now} occByDay={occByDay} dayKey={dayKey} onSelect={nav.selectWorkflow} />
: <WeekGrid ref0={ref} now={now} occByDay={occByDay} dayKey={dayKey} onSelect={nav.selectWorkflow} />}
</div>
);
};
interface GridProps {
ref0: Date; now: Date;
occByDay: Map<string, Occ[]>;
dayKey: (d: Date) => string;
onSelect: (id: string) => void;
}
const MonthGrid: React.FC<GridProps> = ({ ref0, now, occByDay, dayKey, onSelect }) => {
const start = startOfMonthGrid(ref0);
const cells = Array.from({ length: 42 }, (_, i) => addDays(start, i));
const month = ref0.getMonth();
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', flex: 'none', borderBottom: `1px solid ${WC.line}` }}>
{DOW.map((d) => <div key={d} style={{ textAlign: 'center', padding: '9px 0', fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, letterSpacing: '0.06em', color: WC.muted2 }}>{d}</div>)}
</div>
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gridAutoRows: '1fr', minHeight: 0 }}>
{cells.map((d, i) => {
const inMonth = d.getMonth() === month;
const isToday = sameDay(d, now);
const runs = occByDay.get(dayKey(d)) || [];
const shown = runs.slice(0, 4);
return (
<div key={i} style={{ borderRight: '1px solid rgba(33,30,27,0.06)', borderBottom: '1px solid rgba(33,30,27,0.06)', padding: '6px 8px', background: isToday ? 'rgba(194,90,54,0.05)' : (inMonth ? WC.paper : '#F1EFE9'), display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<div style={{ display: 'flex', marginBottom: 3 }}>
<div style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none', background: isToday ? WC.accent : 'transparent' }}>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 12, fontWeight: isToday ? 700 : 500, lineHeight: 1, color: isToday ? '#fff' : (inMonth ? WC.ink3 : WC.faint) }}>{d.getDate()}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, overflow: 'hidden' }}>
{shown.map((r, ri) => (
<div key={ri} onClick={() => onSelect(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', borderRadius: 4, padding: '1px 3px' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: colorForId(r.wfId), flex: 'none' }} />
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, color: '#6B655C', flex: 'none' }}>{miniTime(r.at)}</span>
<span style={{ fontSize: 11, color: WC.ink2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
</div>
))}
{runs.length > 4 && <span style={{ fontSize: 10.5, color: WC.muted2, paddingLeft: 3 }}>+{runs.length - 4} more</span>}
</div>
</div>
);
})}
</div>
</div>
);
};
const WeekGrid: React.FC<GridProps> = ({ ref0, now, occByDay, dayKey, onSelect }) => {
const start = startOfWeek(ref0);
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
const scrollRef = useRef<HTMLDivElement | null>(null);
const ROW_H = 54;
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = Math.max(0, (now.getHours() - 2) * ROW_H);
}, [now, start]);
const nowFrac = (now.getHours() * 60 + now.getMinutes()) / 60 % 1;
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '56px repeat(7,1fr)', flex: 'none', borderBottom: `1px solid ${WC.line}`, paddingRight: 9 }}>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 7, fontFamily: "'JetBrains Mono',monospace", fontSize: 9.5, color: WC.muted2 }} />
{days.map((d, i) => {
const isToday = sameDay(d, now);
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0 7px', borderLeft: '1px solid rgba(33,30,27,0.06)' }}>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, letterSpacing: '0.04em', color: WC.muted2 }}>{DOW[d.getDay()]}</span>
<div style={{ width: 30, height: 30, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 3, flex: 'none', background: isToday ? WC.accent : 'transparent' }}>
<span style={{ fontFamily: "'Newsreader',serif", fontSize: 18, fontWeight: 500, lineHeight: 1, color: isToday ? '#fff' : WC.ink }}>{d.getDate()}</span>
</div>
</div>
);
})}
</div>
<div ref={scrollRef} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{Array.from({ length: 24 }, (_, h) => (
<div key={h} style={{ display: 'grid', gridTemplateColumns: '56px repeat(7,1fr)', borderBottom: '1px solid rgba(33,30,27,0.05)', minHeight: ROW_H }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '5px 8px 0 0', fontFamily: "'JetBrains Mono',monospace", fontSize: 10, color: WC.muted2 }}>{hourLabel(h)}</div>
{days.map((d, di) => {
const runs = (occByDay.get(dayKey(d)) || []).filter((r) => r.at.getHours() === h);
const isNow = sameDay(d, now) && now.getHours() === h;
return (
<div key={di} style={{ position: 'relative', borderLeft: '1px solid rgba(33,30,27,0.06)', padding: '3px 4px', display: 'flex', flexDirection: 'column', gap: 3 }}>
{isNow && <>
<div style={{ position: 'absolute', left: -4, top: nowFrac * ROW_H - 4, width: 8, height: 8, borderRadius: '50%', background: WC.accent, zIndex: 4 }} />
<div style={{ position: 'absolute', left: 0, right: 0, top: nowFrac * ROW_H, height: 2, background: WC.accent, zIndex: 3 }} />
</>}
{runs.slice(0, 3).map((r, ri) => (
<div key={ri} onClick={() => onSelect(r.wfId)} style={{ display: 'flex', alignItems: 'center', background: colorForId(r.wfId), color: '#fff', borderRadius: 999, padding: '2px 9px', fontSize: 10.5, fontWeight: 600, cursor: 'pointer', lineHeight: 1.35, overflow: 'hidden' }}>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.85, flex: 'none' }}>{miniTime(r.at)}</span>
</div>
))}
{runs.length > 3 && <span style={{ fontSize: 10, color: WC.muted2, paddingLeft: 2 }}>{runs.length - 3} more</span>}
</div>
);
})}
</div>
))}
</div>
</div>
);
};
export default CalendarView;
@@ -0,0 +1,130 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createWorkflow, updateWorkflow } from '@/shared/state/workflowsSlice';
import { defaultSchedule, stepsSignature, needsScheduleTestWarning } from '@/app/pages/Workflows/scheduleUtils';
import { runWorkflowTest } from '@/app/pages/Workflows/runWorkflowTest';
import AgentChat from '@/app/pages/AgentChat/AgentChat';
import { WC } from './uiKit';
import { useEditAgentSession } from './useEditAgentSession';
import { useWorkflowPatch } from './useWorkflowPatch';
import ScheduleCard from './ScheduleCard';
import StepsCard from './StepsCard';
import SaveGuard from './SaveGuard';
import type { AppNav } from './types';
const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
const dispatch = useAppDispatch();
const patch = useWorkflowPatch();
const [draftId, setDraftId] = useState<string | null>(null);
const [name, setName] = useState('');
const [testing, setTesting] = useState(false);
const [guardOpen, setGuardOpen] = useState(false);
const created = useRef(false);
const workflow = useAppSelector((s) => (draftId ? s.workflows.items[draftId] : undefined));
// One unsaved draft per visit to "New". The backend hides unsaved drafts from
// lists, so an abandoned one stays out of the way until GC.
useEffect(() => {
if (created.current) return;
created.current = true;
(async () => {
try {
const wf = await dispatch(createWorkflow({ unsaved: true, title: 'Untitled workflow', steps: [], schedule: defaultSchedule() })).unwrap();
setDraftId(wf.id);
setName(wf.title || '');
} catch { /* surfaced by the empty state */ }
})();
}, [dispatch]);
const sessionId = useEditAgentSession(draftId ?? '', 'build');
if (!workflow) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', background: WC.paper }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid rgba(33,30,27,0.15)', borderTopColor: WC.accent, animation: 'os-spin 0.7s linear infinite' }} />
<span style={{ fontFamily: "'Newsreader',serif", fontStyle: 'italic', fontSize: 14, color: '#6B655C' }}>Setting up your workflow</span>
</div>
</div>
);
}
const tested = workflow.steps.length > 0 && stepsSignature(workflow.steps) === (workflow.tested_signature ?? '');
const commitName = () => { const t = name.trim(); if (t && t !== workflow.title) patch(workflow, { title: t, auto_named: false }); };
const doTest = async () => {
if (testing || workflow.steps.length === 0) return;
setTesting(true);
try { await runWorkflowTest(workflow.id, workflow.steps, async () => {}); }
finally { setTesting(false); }
};
const finalizeSave = () => {
dispatch(updateWorkflow({ id: workflow.id, patch: { unsaved: false }, ifMatch: workflow.updated_at }));
nav.selectWorkflow(workflow.id);
};
const onSave = () => {
if (needsScheduleTestWarning(workflow)) { setGuardOpen(true); return; }
finalizeSave();
};
return (
<>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.paper, position: 'relative' }}>
<div style={{ flex: 'none', padding: '15px 28px', borderBottom: '1px solid rgba(33,30,27,0.06)', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 15, height: 15, borderRadius: 4, background: WC.accent, boxShadow: '0 0 0 1px rgba(33,30,27,0.14)', flex: 'none' }} />
<input
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commitName}
placeholder="Untitled workflow"
style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', fontFamily: "'Newsreader',serif", fontSize: 21, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}
/>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 500, color: WC.muted, background: 'rgba(33,30,27,0.07)', padding: '4px 10px', borderRadius: 999, flex: 'none' }}>Draft</span>
</div>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
{sessionId
? <AgentChat sessionId={sessionId} embedded autoFocus workflowEditId={workflow.id} />
: <div style={{ flex: 1 }} />}
</div>
{guardOpen && (
<SaveGuard
title={workflow.title || 'this workflow'}
onClose={() => setGuardOpen(false)}
onSaveAnyway={() => { setGuardOpen(false); finalizeSave(); }}
onRunTest={() => { setGuardOpen(false); doTest(); }}
/>
)}
</div>
<div style={{ width: 344, flex: 'none', borderLeft: `1px solid ${WC.line}`, background: WC.rail, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '18px 18px 22px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<ScheduleCard workflow={workflow} />
<StepsCard workflow={workflow} />
</div>
<div style={{ flex: 'none', borderTop: '1px solid rgba(33,30,27,0.08)', background: WC.rail, padding: '13px 18px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{!tested && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 7, fontSize: 11.5, lineHeight: 1.4, color: WC.muted }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="2" style={{ flex: 'none', marginTop: 1 }}><circle cx="12" cy="12" r="9" /><path d="M12 8v5" /><path d="M12 16h.01" /></svg>
<span>Not tested yet a test run grants the tool access this workflow needs.</span>
</div>
)}
<div style={{ display: 'flex', gap: 9 }}>
<button onClick={doTest} disabled={testing || workflow.steps.length === 0} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, flex: 'none', padding: '10px 15px', borderRadius: 9, border: '1px solid rgba(33,30,27,0.14)', background: WC.paper, color: testing || workflow.steps.length === 0 ? WC.muted2 : WC.ink, fontSize: 13, fontWeight: 600, cursor: testing || workflow.steps.length === 0 ? 'default' : 'pointer' }}>
{testing
? <div style={{ width: 12, height: 12, borderRadius: '50%', border: '2px solid rgba(140,133,122,0.3)', borderTopColor: WC.muted, animation: 'os-spin 0.7s linear infinite', flex: 'none' }} />
: <div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${WC.accent}`, flex: 'none' }} />}
<span>{testing ? 'Testing…' : tested ? 'Run again' : 'Test run'}</span>
</button>
<button onClick={onSave} style={{ flex: 1, background: WC.accent, color: '#fff', border: 'none', borderRadius: 9, padding: 10, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Save workflow</button>
</div>
</div>
</div>
</>
);
};
export default ComposeView;
@@ -0,0 +1,83 @@
import React, { useEffect, useState } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { runWorkflowNow } from '@/shared/state/workflowsSlice';
import { stepsSignature, isScheduleActive } from '@/app/pages/Workflows/scheduleUtils';
import AgentChat from '@/app/pages/AgentChat/AgentChat';
import { WC, colorForId, statusChip } from './uiKit';
import { isRunning } from './model';
import { useEditAgentSession } from './useEditAgentSession';
import { useWorkflowPatch } from './useWorkflowPatch';
import ScheduleCard from './ScheduleCard';
import StepsCard from './StepsCard';
import HistoryCard from './HistoryCard';
import type { AppNav } from './types';
const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId }) => {
const dispatch = useAppDispatch();
const patch = useWorkflowPatch();
const workflow = useAppSelector((s) => s.workflows.items[workflowId]);
const active = useAppSelector((s) => s.workflows.active);
const sessionId = useEditAgentSession(workflowId, 'modify');
const [name, setName] = useState(workflow?.title ?? '');
useEffect(() => { setName(workflow?.title ?? ''); }, [workflow?.title]);
if (!workflow) return <div style={{ flex: 1, background: WC.paper }} />;
const running = isRunning(workflow, active);
const enabled = isScheduleActive(workflow.schedule);
const status = running ? 'running' : enabled ? 'success' : 'paused';
const statusText = running ? 'Running' : enabled ? 'Active' : 'Paused';
const runNow = () => {
if (running) return;
dispatch(runWorkflowNow({ id: workflow.id, signature: stepsSignature(workflow.steps) }));
};
const commitName = () => {
const t = name.trim();
if (t && t !== workflow.title) patch(workflow, { title: t, auto_named: false });
};
return (
<>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.paper }}>
<div style={{ flex: 'none', padding: '20px 28px 16px', borderBottom: '1px solid rgba(33,30,27,0.06)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 13 }}>
<div style={{ width: 14, height: 14, borderRadius: 4, background: colorForId(workflow.id), boxShadow: '0 0 0 1px rgba(33,30,27,0.14)', flex: 'none' }} />
<input
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commitName}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', fontFamily: "'Newsreader',serif", fontSize: 25, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}
/>
<span style={statusChip(status)}>{statusText}</span>
<button onClick={runNow} disabled={running} style={{ display: 'flex', alignItems: 'center', gap: 8, background: running ? WC.inset : WC.ink, color: running ? WC.muted : WC.paper, border: 'none', borderRadius: 9, padding: '8px 15px', fontSize: 13, fontWeight: 600, cursor: running ? 'default' : 'pointer', flex: 'none' }}>
{running
? <div style={{ width: 12, height: 12, borderRadius: '50%', border: '2px solid rgba(140,133,122,0.3)', borderTopColor: WC.muted, animation: 'os-spin 0.7s linear infinite', flex: 'none' }} />
: <div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${WC.paper}`, flex: 'none' }} />}
<span>{running ? 'Running…' : 'Run now'}</span>
</button>
</div>
{workflow.description && <div style={{ fontSize: 13.5, color: WC.muted, marginTop: 7, paddingLeft: 27 }}>{workflow.description}</div>}
</div>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
{sessionId
? <AgentChat sessionId={sessionId} embedded workflowEditId={workflow.id} />
: <div style={{ flex: 1 }} />}
</div>
</div>
<div style={{ width: 344, flex: 'none', borderLeft: `1px solid ${WC.line}`, background: WC.rail, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '18px 18px 22px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<ScheduleCard workflow={workflow} />
<StepsCard workflow={workflow} />
<HistoryCard workflowId={workflow.id} title={workflow.title} />
</div>
</div>
</>
);
};
export default DetailView;
@@ -0,0 +1,38 @@
import React, { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchRuns } from '@/shared/state/workflowsSlice';
import { WC, FONT_SERIF, statusChip, statusDot, statusLabel } from './uiKit';
import { toRunRow, whenText } from './model';
const HistoryCard: React.FC<{ workflowId: string; title: string }> = ({ workflowId, title }) => {
const dispatch = useAppDispatch();
const runs = useAppSelector((s) => s.workflows.runs[workflowId]);
useEffect(() => { dispatch(fetchRuns(workflowId)); }, [workflowId, dispatch]);
const rows = (runs || []).slice(0, 8).map((r) => toRunRow(r, title));
const now = new Date();
return (
<div style={{ background: WC.paper, border: '1px solid rgba(33,30,27,0.08)', borderRadius: 13, padding: 16 }}>
<div style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink, marginBottom: 12 }}>History</div>
{rows.length === 0 && <div style={{ fontSize: 12.5, color: WC.muted2 }}>No runs yet.</div>}
<div style={{ display: 'flex', flexDirection: 'column' }}>
{rows.map((r) => (
<div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '9px 0', borderBottom: '1px solid rgba(33,30,27,0.05)' }}>
<div style={statusDot(r.status)} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, color: WC.ink2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.summary}</div>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, color: WC.muted2, marginTop: 2 }}>
{whenText(r.when, now)}{r.durationText ? ` · ${r.durationText}` : ''}
</div>
</div>
<span style={statusChip(r.status)}>{statusLabel(r.status)}</span>
</div>
))}
</div>
</div>
);
};
export default HistoryCard;
@@ -0,0 +1,141 @@
import React, { useMemo, useState } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { runMissedRuns } from '@/shared/state/missedRunsSlice';
import { fireTimesWithin } from '@/app/pages/Workflows/scheduleUtils';
import { colorForId, WC, statusChip, statusDot } from './uiKit';
import { clockOf, whenText } from './model';
import type { AppNav } from './types';
interface ComingRun { wfId: string; title: string; time: string; sortKey: number; steps: number; }
interface ComingGroup { key: string; dayNum: number; dow: string; runs: ComingRun[]; }
const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
const dispatch = useAppDispatch();
const items = useAppSelector((s) => s.workflows.items);
const allRuns = useAppSelector((s) => s.workflows.allRuns);
const missed = useAppSelector((s) => s.missedRuns.items);
const [missedExpanded, setMissedExpanded] = useState(false);
const now = new Date();
const todayLabel = now.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
const comingGroups = useMemo<ComingGroup[]>(() => {
const from = new Date(now); from.setHours(0, 0, 0, 0);
const to = new Date(from.getTime() + 7 * 86400000);
const byDay = new Map<string, ComingRun[]>();
for (const wf of Object.values(items)) {
if (wf.unsaved) continue;
const fires = fireTimesWithin(wf, now, to, 20);
for (const f of fires) {
const key = `${f.getFullYear()}-${f.getMonth()}-${f.getDate()}`;
const arr = byDay.get(key) || [];
arr.push({ wfId: wf.id, title: wf.title || 'Untitled', time: clockOf(f), sortKey: f.getTime(), steps: wf.steps.length });
byDay.set(key, arr);
}
}
const groups: ComingGroup[] = [];
for (let i = 0; i < 7; i += 1) {
const d = new Date(from.getTime() + i * 86400000);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const runs = (byDay.get(key) || []).sort((a, b) => a.sortKey - b.sortKey);
if (runs.length) groups.push({ key, dayNum: d.getDate(), dow: d.toLocaleDateString([], { weekday: 'short' }), runs });
}
return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items]);
const recents = useMemo(() => allRuns.slice(0, 8).map((r) => ({
id: r.id,
title: items[r.workflow_id]?.title || 'Workflow',
status: r.status,
summary: r.error || r.last_tool_label || (r.status === 'success' ? 'Completed' : r.status),
when: r.started_at ? new Date(r.started_at) : null,
})), [allRuns, items]);
const missedVisible = missedExpanded ? missed : missed.slice(0, 3);
const reRunAll = () => { if (missed.length) dispatch(runMissedRuns(missed.map((m) => m.id))); };
return (
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.paper }}>
<div style={{ flex: 'none', padding: '22px 30px 14px', borderBottom: '1px solid rgba(33,30,27,0.06)' }}>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, letterSpacing: '0.06em', textTransform: 'uppercase', color: WC.muted2 }}>{todayLabel}</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '0 30px 32px' }}>
{missed.length > 0 && (
<div style={{ marginTop: 22 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 11 }}>
<span style={{ fontFamily: "'Newsreader',serif", fontSize: 18, fontWeight: 500, color: WC.ink }}>Missed</span>
<span style={{ fontSize: 11, fontWeight: 600, color: WC.danger, background: WC.dangerBg, padding: '2px 8px', borderRadius: 999 }}>{missed.length}</span>
<div style={{ flex: 1 }} />
<button onClick={reRunAll} style={{ background: 'transparent', border: '1px solid rgba(33,30,27,0.14)', borderRadius: 8, padding: '6px 12px', fontSize: 12, fontWeight: 600, color: WC.ink3, cursor: 'pointer' }}>Re-run all</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, ...(missedExpanded ? { maxHeight: 306, overflowY: 'auto', paddingRight: 4 } : {}) }}>
{missedVisible.map((m) => (
<div key={m.id} style={{ display: 'flex', alignItems: 'center', gap: 12, background: '#FFFFFF', border: '1px solid rgba(194,72,58,0.20)', borderRadius: 11, padding: '10px 14px' }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: WC.danger, flex: 'none' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.workflow_title}</div>
<div style={{ fontSize: 11.5, color: WC.muted, marginTop: 1 }}>Missed while the app was closed</div>
</div>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.muted2, flex: 'none' }}>{whenText(new Date(m.scheduled_for), now)}</span>
<button onClick={() => dispatch(runMissedRuns([m.id]))} style={{ background: WC.ink, color: WC.paper, border: 'none', borderRadius: 8, padding: '6px 13px', fontSize: 12, fontWeight: 600, cursor: 'pointer', flex: 'none' }}>Re-run</button>
</div>
))}
</div>
{missed.length > 3 && (
<button onClick={() => setMissedExpanded((v) => !v)} style={{ marginTop: 10, background: 'transparent', border: 'none', color: WC.danger, fontSize: 12.5, fontWeight: 600, cursor: 'pointer', padding: '4px 2px' }}>
{missedExpanded ? 'Show less' : `Show all ${missed.length} missed`}
</button>
)}
</div>
)}
<div style={{ marginTop: 26 }}>
<div style={{ fontFamily: "'Newsreader',serif", fontSize: 18, fontWeight: 500, color: WC.ink, marginBottom: 12 }}>Coming up</div>
{comingGroups.length === 0 && (
<div style={{ fontSize: 13, color: WC.muted }}>Nothing scheduled in the next 7 days.</div>
)}
{comingGroups.map((g) => (
<div key={g.key} style={{ display: 'flex', gap: 18, padding: '4px 0 16px' }}>
<div style={{ width: 62, flex: 'none', textAlign: 'right', paddingTop: 2 }}>
<div style={{ fontFamily: "'Newsreader',serif", fontSize: 26, fontWeight: 500, color: WC.ink, lineHeight: 1 }}>{g.dayNum}</div>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, letterSpacing: '0.04em', textTransform: 'uppercase', color: WC.muted2, marginTop: 4 }}>{g.dow}</div>
</div>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.runs.map((r, i) => (
<div key={`${r.wfId}-${i}`} onClick={() => nav.selectWorkflow(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 13, background: '#FFFFFF', border: '1px solid rgba(33,30,27,0.08)', borderRadius: 11, padding: '12px 15px', cursor: 'pointer' }}>
<div style={{ width: 3, height: 30, borderRadius: 3, background: colorForId(r.wfId), flex: 'none' }} />
<span style={{ fontSize: 14, fontWeight: 600, color: WC.ink, flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
<span style={{ fontSize: 12, color: WC.muted }}>{r.steps} steps</span>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 12, color: WC.ink3, minWidth: 74, textAlign: 'right' }}>{r.time}</span>
</div>
))}
</div>
</div>
))}
</div>
<div style={{ marginTop: 18 }}>
<div style={{ fontFamily: "'Newsreader',serif", fontSize: 18, fontWeight: 500, color: WC.ink, marginBottom: 12 }}>Recents</div>
{recents.length === 0 && <div style={{ fontSize: 13, color: WC.muted }}>No runs yet.</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{recents.map((r) => (
<div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '11px 4px', borderBottom: '1px solid rgba(33,30,27,0.05)' }}>
<div style={statusDot(r.status)} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: WC.ink }}>{r.title}</div>
<div style={{ fontSize: 12, color: WC.muted, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.summary}</div>
</div>
<span style={statusChip(r.status)}>{r.status === 'failure' ? 'Failed' : r.status === 'success' ? 'Success' : r.status}</span>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.muted2, minWidth: 96, textAlign: 'right' }}>{whenText(r.when, now)}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default HomeView;
@@ -0,0 +1,118 @@
import React, { useMemo, useState } from 'react';
import type { CSSProperties } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { deleteWorkflow } from '@/shared/state/workflowsSlice';
import { isScheduleActive, describeSchedule } from '@/app/pages/Workflows/scheduleUtils';
import { colorForId, WC } from './uiKit';
import type { AppNav } from './types';
const navBase: CSSProperties = {
display: 'flex', alignItems: 'center', gap: 10, padding: '7px 9px',
borderRadius: 8, cursor: 'pointer', fontSize: 13.5,
};
const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
const dispatch = useAppDispatch();
const items = useAppSelector((s) => s.workflows.items);
const [query, setQuery] = useState('');
const workflows = useMemo(() => Object.values(items)
.filter((w) => !w.unsaved)
.sort((a, b) => a.title.localeCompare(b.title)), [items]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return workflows;
return workflows.filter((w) => w.title.toLowerCase().includes(q));
}, [workflows, query]);
const activeCount = workflows.filter((w) => isScheduleActive(w.schedule)).length;
const onDelete = (id: string, title: string) => {
// Hard delete: the backend has no soft-delete/restore, so confirm here
// rather than imply a recoverable trash that doesn't exist.
if (!window.confirm(`Delete "${title}"? This can't be undone.`)) return;
dispatch(deleteWorkflow(id));
if (nav.selectedId === id) nav.goHome();
};
const homeStyle: CSSProperties = nav.mode === 'home'
? { ...navBase, background: WC.selBg, color: WC.ink, fontWeight: 600 }
: { ...navBase, color: WC.ink3 };
const calStyle: CSSProperties = nav.mode === 'calendar'
? { ...navBase, background: WC.selBg, color: WC.ink, fontWeight: 600 }
: { ...navBase, color: WC.ink3 };
const newStyle: CSSProperties = nav.mode === 'new'
? { ...navBase, background: WC.accent, color: '#fff', fontWeight: 600 }
: { ...navBase, color: WC.accent, fontWeight: 600 };
return (
<div style={{ width: 248, flex: 'none', borderRight: `1px solid ${WC.line}`, background: WC.rail, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ padding: '14px 12px 10px', flex: 'none' }}>
<div style={{ height: 30, borderRadius: 8, background: WC.paper, border: '1px solid rgba(33,30,27,0.08)', display: 'flex', alignItems: 'center', gap: 7, padding: '0 9px', color: WC.muted, fontSize: 12.5 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4-4" /></svg>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
style={{ flex: 1, border: 'none', background: 'transparent', fontSize: 12.5, color: WC.ink }}
/>
</div>
</div>
<div style={{ padding: '4px 8px', flex: 'none', display: 'flex', flexDirection: 'column', gap: 1 }}>
<div onClick={nav.goHome} style={homeStyle}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9"><path d="M3 11l9-7 9 7M5 10v9h5v-6h4v6h5v-9" /></svg>
<span>Home</span>
</div>
<div onClick={nav.goCalendar} style={calStyle}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9"><rect x="3" y="4.5" width="18" height="16" rx="2.5" /><path d="M3 9h18M8 2.5v4M16 2.5v4" /></svg>
<span>Calendar</span>
</div>
<div onClick={nav.goNew} style={newStyle}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14" /></svg>
<span>New Workflow</span>
</div>
</div>
<div style={{ padding: '14px 16px 6px', flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, letterSpacing: '0.08em', textTransform: 'uppercase', color: WC.muted2 }}>Workflows</span>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, color: WC.muted2 }}>{activeCount}</span>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 8px', minHeight: 0 }}>
{filtered.map((w) => {
const active = isScheduleActive(w.schedule);
const isSel = nav.mode === 'detail' && w.id === nav.selectedId;
return (
<div
key={w.id}
onClick={() => nav.selectWorkflow(w.id)}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 9px', borderRadius: 9, cursor: 'pointer', background: isSel ? WC.selBg : 'transparent' }}
>
<div style={{ width: 8, height: 8, borderRadius: '50%', flex: 'none', background: colorForId(w.id), opacity: active ? 1 : 0.35 }} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: active ? WC.ink : WC.muted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.title || 'Untitled workflow'}</div>
<div style={{ fontSize: 11, color: WC.muted2, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{active ? describeSchedule(w.schedule) : 'Paused'}
</div>
</div>
<div
onClick={(e) => { e.stopPropagation(); onDelete(w.id, w.title || 'this workflow'); }}
style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }}
aria-label="Delete workflow"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13" /></svg>
</div>
</div>
);
})}
{filtered.length === 0 && (
<div style={{ padding: '18px 10px', fontSize: 12.5, color: WC.muted2 }}>No workflows yet.</div>
)}
</div>
</div>
);
};
export default LeftRail;
@@ -0,0 +1,36 @@
import React from 'react';
import { WC } from './uiKit';
// Test-first nudge before scheduling: a test run grants the tool access the
// workflow needs, so unattended runs don't stall reaching for them.
const SaveGuard: React.FC<{
title: string;
onClose: () => void;
onSaveAnyway: () => void;
onRunTest: () => void;
}> = ({ title, onClose, onSaveAnyway, onRunTest }) => (
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(33,30,27,0.34)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 28 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 430, maxWidth: '100%', background: WC.paper, borderRadius: 16, boxShadow: '0 30px 70px -22px rgba(0,0,0,0.45)', overflow: 'hidden' }}>
<div style={{ padding: '24px 24px 18px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 30, height: 30, borderRadius: 9, background: 'rgba(185,138,46,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="2"><path d="M12 3l9 16H3z" /><path d="M12 10v4" /><path d="M12 17h.01" /></svg>
</div>
<h3 style={{ margin: 0, fontFamily: "'Newsreader',serif", fontSize: 20, fontWeight: 500, color: WC.ink }}>Test run recommended</h3>
</div>
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: '#6B655C' }}>
You havent tested {title} yet. A quick test run confirms the steps work and grants the tool access it needs before it goes on a schedule.
</p>
</div>
<div style={{ padding: '0 24px 22px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onSaveAnyway} style={{ background: 'transparent', border: '1px solid rgba(33,30,27,0.16)', borderRadius: 9, padding: '9px 16px', fontSize: 13, fontWeight: 600, color: WC.ink3, cursor: 'pointer' }}>Save anyway</button>
<button onClick={onRunTest} style={{ display: 'flex', alignItems: 'center', gap: 7, background: WC.accent, color: '#fff', border: 'none', borderRadius: 9, padding: '9px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
<div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: '8px solid #fff', flex: 'none' }} />
<span>Run test now</span>
</button>
</div>
</div>
</div>
);
export default SaveGuard;
@@ -0,0 +1,127 @@
import React from 'react';
import type { CSSProperties } from 'react';
import type { Workflow, ScheduleConfig } from '@/shared/state/workflowsSlice';
import { describeSchedule } from '@/app/pages/Workflows/scheduleUtils';
import { WC, FONT_SERIF, track, knob } from './uiKit';
import {
freqOf, patchForFreq, intervalMinutes, timeInputValue, parseTimeInput, ordinal, nextRunText, type Freq,
} from './model';
import { useWorkflowPatch } from './useWorkflowPatch';
const FREQS: Array<[Freq, string]> = [['daily', 'Daily'], ['weekly', 'Weekly'], ['monthly', 'Monthly'], ['interval', 'Interval']];
const DAY_LABELS: Array<[string, number]> = [['S', 0], ['M', 1], ['T', 2], ['W', 3], ['T', 4], ['F', 5], ['S', 6]];
const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
const patch = useWorkflowPatch();
const sched = workflow.schedule;
const freq = freqOf(sched);
const enabled = sched.enabled;
const patchSched = (p: Partial<ScheduleConfig>) => patch(workflow, { schedule: { ...sched, ...p } });
const freqBtn = (active: boolean): CSSProperties => ({
flex: 1, padding: '6px 2px', borderRadius: 7, border: 'none', cursor: 'pointer', fontSize: 11.5, fontWeight: 600,
background: active ? WC.paper : 'transparent', color: active ? WC.ink : WC.muted,
boxShadow: active ? '0 1px 3px rgba(33,30,27,0.10)' : 'none',
});
const pillBtn = (active: boolean): CSSProperties => ({
flex: 1, height: 30, borderRadius: 7, border: `1px solid ${active ? WC.accent : 'rgba(33,30,27,0.12)'}`,
cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: active ? WC.accent : '#FFFFFF', color: active ? '#fff' : WC.muted,
});
const toggleDay = (d: number) => {
const on = sched.on_days.includes(d);
const next = on ? sched.on_days.filter((x) => x !== d) : [...sched.on_days, d];
patchSched({ on_days: next.sort((a, b) => a - b) });
};
const intervalMins = intervalMinutes(sched);
const intervalUnit: 'min' | 'hour' = sched.repeat_unit === 'hour' ? 'hour' : 'min';
const intervalValue = intervalUnit === 'hour' ? Math.max(1, Math.round(intervalMins / 60)) : intervalMins;
const dom = sched.day_of_month ?? 1;
return (
<div style={{ background: WC.paper, border: '1px solid rgba(33,30,27,0.08)', borderRadius: 13, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 13 }}>
<span style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink }}>Schedule</span>
<div onClick={() => patchSched({ enabled: !enabled })} style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: enabled ? WC.accent : WC.muted }}>{enabled ? 'On' : 'Off'}</span>
<div style={track(enabled)}><div style={knob(enabled)} /></div>
</div>
</div>
<div style={{ display: 'flex', background: WC.inset, border: `1px solid ${WC.line}`, borderRadius: 9, padding: 3, gap: 2, marginBottom: 12 }}>
{FREQS.map(([k, label]) => (
<button key={k} onClick={() => patchSched(patchForFreq(sched, k))} style={freqBtn(freq === k)}>{label}</button>
))}
</div>
{freq === 'weekly' && (
<div style={{ display: 'flex', gap: 5, marginBottom: 12 }}>
{DAY_LABELS.map(([label, d], i) => (
<button key={i} onClick={() => toggleDay(d)} style={pillBtn(sched.on_days.includes(d))}>{label}</button>
))}
</div>
)}
{freq === 'monthly' && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 12 }}>
<span style={{ fontSize: 13, color: WC.ink3 }}>Day of month</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button onClick={() => patchSched({ day_of_month: dom <= 1 ? 31 : dom - 1 })} style={{ width: 26, height: 26, background: '#FFFFFF', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 7, color: WC.ink3, fontSize: 15, cursor: 'pointer' }}></button>
<span style={{ minWidth: 62, textAlign: 'center', fontFamily: "'JetBrains Mono',monospace", fontWeight: 500, fontSize: 13, color: WC.ink }}>{ordinal(dom)}</span>
<button onClick={() => patchSched({ day_of_month: dom >= 31 ? 1 : dom + 1 })} style={{ width: 26, height: 26, background: '#FFFFFF', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 7, color: WC.ink3, fontSize: 15, cursor: 'pointer' }}>+</button>
</div>
</div>
)}
{freq !== 'interval' ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<span style={{ fontSize: 13, color: WC.ink3 }}>Run at</span>
<input
type="time"
value={timeInputValue(sched)}
onChange={(e) => { const t = parseTimeInput(e.target.value); if (t) patchSched(t); }}
style={{ background: '#FFFFFF', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 8, padding: '6px 9px', fontSize: 13, fontFamily: "'JetBrains Mono',monospace", color: WC.ink }}
/>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<span style={{ fontSize: 13, color: WC.ink3 }}>Run every</span>
<div style={{ display: 'flex', gap: 6 }}>
<input
type="number" min={1} value={intervalValue}
onChange={(e) => {
const v = Math.max(1, parseInt(e.target.value, 10) || 1);
patchSched({ repeat_every: intervalUnit === 'hour' ? v : Math.max(15, v) });
}}
style={{ width: 56, background: '#FFFFFF', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 8, padding: '6px 9px', fontSize: 13, fontFamily: "'JetBrains Mono',monospace", color: WC.ink, textAlign: 'right' }}
/>
<select
value={intervalUnit}
onChange={(e) => {
if (e.target.value === 'hour') patchSched({ repeat_unit: 'hour', repeat_every: Math.max(1, Math.round(intervalMins / 60)) });
else patchSched({ repeat_unit: 'minute', repeat_every: Math.max(15, intervalMins) });
}}
style={{ background: '#FFFFFF', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 8, padding: '6px 8px', fontSize: 13, color: WC.ink, cursor: 'pointer' }}
>
<option value="min">minutes</option>
<option value="hour">hours</option>
</select>
</div>
</div>
)}
<div style={{ marginTop: 13, paddingTop: 13, borderTop: `1px solid ${WC.line}`, display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={WC.muted} strokeWidth="1.8" style={{ flex: 'none' }}><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
<span style={{ fontSize: 12.5, color: '#6B655C' }}>{describeSchedule(sched)}</span>
</div>
<div style={{ marginTop: 7, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 14, display: 'flex', justifyContent: 'center', flex: 'none' }}><div style={{ width: 6, height: 6, borderRadius: '50%', background: WC.accent }} /></div>
<span style={{ fontSize: 12.5, color: '#6B655C' }}>Next run {nextRunText(workflow)}</span>
</div>
</div>
);
};
export default ScheduleCard;
@@ -0,0 +1,120 @@
import React, { useEffect, useState } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { commitDraft, discardDraft } from '@/shared/state/workflowsSlice';
import type { Workflow, WorkflowStep } from '@/shared/state/workflowsSlice';
import { stepsSignature } from '@/app/pages/Workflows/scheduleUtils';
import { WC, FONT_SERIF, FONT_SANS } from './uiKit';
import { useWorkflowPatch } from './useWorkflowPatch';
interface LocalStep { id: string; label: string; text: string; open: boolean; }
function toLocal(steps: WorkflowStep[]): LocalStep[] {
return steps.map((s) => ({ id: s.id, label: s.label || s.text.slice(0, 48), text: s.text, open: false }));
}
function newStepId(): string {
return `step-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
}
const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
const dispatch = useAppDispatch();
const patch = useWorkflowPatch();
const [local, setLocal] = useState<LocalStep[]>(() => toLocal(workflow.steps));
const [draft, setDraft] = useState('');
const sig = stepsSignature(workflow.steps);
// Reseed when the server steps change underneath us (commit, agent edit,
// another surface) but not on our own in-progress keystrokes.
useEffect(() => {
setLocal((prev) => {
const openIds = new Set(prev.filter((s) => s.open).map((s) => s.id));
return toLocal(workflow.steps).map((s) => ({ ...s, open: openIds.has(s.id) }));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sig]);
const commit = (next: LocalStep[]) => {
patch(workflow, { steps: next.map((s) => ({ id: s.id, text: s.text, label: s.label })) });
};
const update = (id: string, p: Partial<LocalStep>) => setLocal((prev) => prev.map((s) => (s.id === id ? { ...s, ...p } : s)));
const onAdd = () => {
const t = draft.trim();
if (!t) return;
const next = [...local, { id: newStepId(), label: t, text: t, open: false }];
setLocal(next);
setDraft('');
commit(next);
};
const onDelete = (id: string) => {
const next = local.filter((s) => s.id !== id);
setLocal(next);
commit(next);
};
return (
<div style={{ background: WC.paper, border: '1px solid rgba(33,30,27,0.08)', borderRadius: 13, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 13 }}>
<span style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink }}>Steps</span>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.muted2 }}>{local.length} step{local.length === 1 ? '' : 's'}</span>
</div>
{workflow.has_draft && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 11, padding: '9px 11px', background: 'rgba(185,138,46,0.10)', border: '1px solid rgba(185,138,46,0.30)', borderRadius: 9 }}>
<span style={{ flex: 1, fontSize: 12, color: '#8A6418', fontWeight: 600 }}>The build agent proposed step changes.</span>
<button onClick={() => dispatch(commitDraft(workflow.id))} style={{ background: WC.ink, color: WC.paper, border: 'none', borderRadius: 7, padding: '5px 10px', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>Apply</button>
<button onClick={() => dispatch(discardDraft(workflow.id))} style={{ background: 'transparent', border: '1px solid rgba(33,30,27,0.16)', borderRadius: 7, padding: '5px 10px', fontSize: 12, fontWeight: 600, color: WC.ink3, cursor: 'pointer' }}>Discard</button>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{local.map((s, i) => (
<div key={s.id} style={{ border: `1px solid ${s.open ? 'rgba(33,30,27,0.16)' : 'rgba(33,30,27,0.10)'}`, borderRadius: 10, background: s.open ? '#FFFFFF' : WC.paper, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '9px 9px 9px 11px' }}>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.faint, width: 13, flex: 'none' }}>{i + 1}</span>
<input
value={s.label}
onChange={(e) => update(s.id, { label: e.target.value })}
onBlur={() => commit(local)}
placeholder="Step title"
style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', padding: 0, fontSize: 13, fontWeight: 600, color: WC.ink }}
/>
<div onClick={() => update(s.id, { open: !s.open })} style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.muted, flex: 'none' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" style={{ transform: s.open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}><path d="M6 9l6 6 6-6" /></svg>
</div>
<div onClick={() => onDelete(s.id)} style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }} aria-label="Delete step">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14" /></svg>
</div>
</div>
{s.open && (
<div style={{ padding: '0 11px 12px 34px' }}>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 9.5, letterSpacing: '0.05em', textTransform: 'uppercase', color: WC.muted2, marginBottom: 6 }}>Prompt</div>
<textarea
value={s.text}
onChange={(e) => update(s.id, { text: e.target.value })}
onBlur={() => commit(local)}
placeholder="What should this step do?"
style={{ width: '100%', boxSizing: 'border-box', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 8, background: WC.paper, padding: '9px 11px', fontSize: 12.5, lineHeight: 1.5, color: WC.ink2, resize: 'vertical', minHeight: 76, fontFamily: FONT_SANS }}
/>
</div>
)}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 7, marginTop: 11 }}>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onAdd(); } }}
placeholder="Add a step…"
style={{ flex: 1, background: '#FFFFFF', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 8, padding: '8px 11px', fontSize: 13, color: WC.ink }}
/>
<button onClick={onAdd} style={{ background: WC.ink, color: WC.paper, border: 'none', borderRadius: 8, width: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flex: 'none' }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><path d="M12 5v14M5 12h14" /></svg>
</button>
</div>
</div>
);
};
export default StepsCard;
@@ -0,0 +1,131 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Dialog from '@mui/material/Dialog';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { closeWorkflowsApp, clearWorkflowsAppTarget } from '@/shared/state/dashboardLayoutSlice';
import {
fetchWorkflows, fetchAllRuns, fetchPausedState, fetchActiveRuns,
} from '@/shared/state/workflowsSlice';
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
import { WC, FONT_SANS } from './uiKit';
import type { AppMode, CalView, AppNav } from './types';
import LeftRail from './LeftRail';
import HomeView from './HomeView';
import CalendarView from './CalendarView';
import DetailView from './DetailView';
import ComposeView from './ComposeView';
const FONTS_HREF = 'https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap';
// The design leans on three webfonts plus a spinner keyframe. Inject both once,
// lazily, so the rest of the app never pays for them unless the window opens.
function ensureAssets(): void {
if (typeof document === 'undefined') return;
if (document.getElementById('workflows-app-fonts')) return;
const link = document.createElement('link');
link.id = 'workflows-app-fonts';
link.rel = 'stylesheet';
link.href = FONTS_HREF;
document.head.appendChild(link);
const style = document.createElement('style');
style.id = 'workflows-app-keyframes';
style.textContent = '@keyframes os-spin { to { transform: rotate(360deg); } }';
document.head.appendChild(style);
}
const WorkflowsApp: React.FC = () => {
const dispatch = useAppDispatch();
const open = useAppSelector((s) => s.dashboardLayout.workflowsAppOpen);
const target = useAppSelector((s) => s.dashboardLayout.workflowsAppTarget);
const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined;
const [mode, setMode] = useState<AppMode>('home');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [calView, setCalView] = useState<CalView>('month');
const [refDate, setRefDate] = useState<Date>(() => new Date());
useEffect(() => { if (open) ensureAssets(); }, [open]);
// Pull every surface's data the moment the window opens; the thunks dedupe.
useEffect(() => {
if (!open) return;
dispatch(fetchWorkflows(dashboardId));
dispatch(fetchAllRuns(200));
dispatch(fetchPausedState());
dispatch(fetchActiveRuns());
dispatch(fetchMissedRuns());
}, [open, dashboardId, dispatch]);
// A deep-link target (from history/notifications/calendar) jumps straight to
// that workflow's detail, then clears so a manual Home nav isn't overridden.
useEffect(() => {
if (open && target) {
setSelectedId(target);
setMode('detail');
dispatch(clearWorkflowsAppTarget());
}
}, [open, target, dispatch]);
const close = useCallback(() => dispatch(closeWorkflowsApp()), [dispatch]);
const nav: AppNav = useMemo(() => ({
mode, selectedId, calView, refDate,
goHome: () => { setMode('home'); },
goCalendar: () => { setMode('calendar'); },
goNew: () => { setSelectedId(null); setMode('new'); },
selectWorkflow: (id: string) => { setSelectedId(id); setMode('detail'); },
setCalView: (v: CalView) => setCalView(v),
setRefDate: (d: Date) => setRefDate(d),
}), [mode, selectedId, calView, refDate]);
if (!open) return null;
return (
<Dialog
open={open}
onClose={close}
maxWidth={false}
PaperProps={{
sx: {
width: 1320, maxWidth: '97vw', height: 854, maxHeight: '94vh',
m: 0, bgcolor: WC.paper, borderRadius: '15px', overflow: 'hidden',
border: `1px solid rgba(33,30,27,0.10)`,
boxShadow: '0 30px 80px -24px rgba(33,30,27,0.34), 0 8px 24px -12px rgba(33,30,27,0.18)',
},
}}
BackdropProps={{ sx: { bgcolor: 'rgba(33,30,27,0.28)' } }}
>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: FONT_SANS, color: WC.ink }}>
{/* TITLE BAR */}
<div style={{ height: 42, flex: 'none', display: 'flex', alignItems: 'center', padding: '0 16px', borderBottom: `1px solid ${WC.line}`, background: WC.panel, gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={WC.accent} strokeWidth="1.9">
<circle cx="6" cy="6" r="2.5" /><circle cx="6" cy="18" r="2.5" /><circle cx="18" cy="12" r="2.5" />
<path d="M8.2 7.1l7.6 3.8M8.2 16.9l7.6-3.8" />
</svg>
<span style={{ fontFamily: "'Newsreader',serif", fontSize: 14.5, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}>Workflows</span>
</div>
<div style={{ flex: 1 }} />
<div
role="button"
aria-label="Close"
onClick={close}
style={{ width: 24, height: 24, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.muted }}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 6l12 12M18 6L6 18" /></svg>
</div>
</div>
{/* THREE PANE */}
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<LeftRail nav={nav} />
{mode === 'home' && <HomeView nav={nav} />}
{mode === 'calendar' && <CalendarView nav={nav} />}
{mode === 'detail' && selectedId && <DetailView workflowId={selectedId} nav={nav} />}
{mode === 'new' && <ComposeView nav={nav} />}
</div>
</div>
</Dialog>
);
};
export default WorkflowsApp;
@@ -0,0 +1,25 @@
import { API_BASE, getAuthToken } from '@/shared/config';
function authHeaders(): Record<string, string> {
let tok = '';
try { tok = getAuthToken(); } catch { tok = ''; }
return { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) };
}
const base = `${API_BASE}/workflows`;
// Sticky single edit-agent session for a workflow. The backend snapshots steps
// into draft_steps when it first hands one out; reattaches on later calls.
export async function ensureEditAgentSession(workflowId: string): Promise<string | null> {
try {
const res = await fetch(`${base}/${encodeURIComponent(workflowId)}/edit-agent-session`, {
method: 'POST',
headers: authHeaders(),
});
if (!res.ok) return null;
const data = await res.json();
return (data?.session_id as string | undefined) ?? null;
} catch {
return null;
}
}
@@ -0,0 +1,148 @@
import type { Workflow, WorkflowRun, ScheduleConfig, ActiveRun } from '@/shared/state/workflowsSlice';
import { isScheduleActive, fireTimesWithin } from '@/app/pages/Workflows/scheduleUtils';
// The design speaks in four cadence buckets; the backend speaks in repeat_unit.
// These two functions are the only place the two vocabularies meet.
export type Freq = 'daily' | 'weekly' | 'monthly' | 'interval';
export function freqOf(sched: ScheduleConfig): Freq {
switch (sched.repeat_unit) {
case 'minute':
case 'hour':
return 'interval';
case 'day':
return 'daily';
case 'month':
return 'monthly';
default:
return 'weekly';
}
}
// Build the schedule patch for a cadence-button press, preserving the user's
// existing time/days where the new cadence still uses them.
export function patchForFreq(sched: ScheduleConfig, freq: Freq): Partial<ScheduleConfig> {
switch (freq) {
case 'daily':
return { repeat_unit: 'day', repeat_every: 1 };
case 'weekly':
return { repeat_unit: 'week', repeat_every: 1, on_days: sched.on_days.length ? sched.on_days : [1] };
case 'monthly':
return { repeat_unit: 'month', repeat_every: 1, day_of_month: sched.day_of_month ?? 1 };
case 'interval':
return { repeat_unit: 'minute', repeat_every: Math.max(15, sched.repeat_every || 30) };
default:
return {};
}
}
export function intervalMinutes(sched: ScheduleConfig): number {
if (sched.repeat_unit === 'hour') return Math.max(1, sched.repeat_every) * 60;
if (sched.repeat_unit === 'minute') return Math.max(15, sched.repeat_every);
return 30;
}
export function formatInterval(mins: number): string {
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'}`;
if (mins % 60 === 0) { const h = mins / 60; return `${h} hour${h === 1 ? '' : 's'}`; }
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
}
export function isRunning(wf: Workflow, active: ActiveRun[]): boolean {
return wf.last_run_status === 'running' || active.some((a) => a.workflow_id === wf.id);
}
export function previewNextRun(wf: Workflow): Date | null {
if (!isScheduleActive(wf.schedule)) return null;
const now = new Date();
const horizon = new Date(now.getTime() + 366 * 86400000);
const fires = fireTimesWithin(wf, now, horizon, 1);
return fires[0] ?? null;
}
const TIME_OPTS: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' };
export function clockOf(date: Date): string {
return date.toLocaleTimeString([], TIME_OPTS).toLowerCase().replace(' ', '');
}
// "today", "tomorrow", or "Mon Jun 23" — for next-run and coming-up labels.
export function relativeDayLabel(date: Date, now = new Date()): string {
const a = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const b = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const days = Math.round((a.getTime() - b.getTime()) / 86400000);
if (days === 0) return 'today';
if (days === 1) return 'tomorrow';
if (days === -1) return 'yesterday';
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
}
export function nextRunText(wf: Workflow): string {
if (!isScheduleActive(wf.schedule)) return '— paused';
const next = previewNextRun(wf);
if (!next) return '—';
return `${relativeDayLabel(next)} at ${clockOf(next)}`;
}
// time <input type=time> value (24h "HH:MM") <-> hour/minute
export function timeInputValue(sched: ScheduleConfig): string {
return `${String(sched.hour).padStart(2, '0')}:${String(sched.minute).padStart(2, '0')}`;
}
export function parseTimeInput(value: string): { hour: number; minute: number } | null {
const m = value.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
const hour = Math.max(0, Math.min(23, Number(m[1])));
const minute = Math.max(0, Math.min(59, Number(m[2])));
return { hour, minute };
}
export function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
export interface RunRow {
id: string;
status: WorkflowRun['status'];
summary: string;
when: Date | null;
durationText: string;
cost: number;
}
export function runSummary(run: WorkflowRun, fallbackTitle: string): string {
if (run.error) return run.error;
if (run.last_tool_label) return run.last_tool_label;
if (run.status === 'skipped') return 'Skipped';
if (run.status === 'running') return 'Running…';
return fallbackTitle;
}
export function runDuration(run: WorkflowRun): string {
if (!run.finished_at || !run.started_at) return '';
const ms = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
if (Number.isNaN(ms) || ms < 0) return '';
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
export function toRunRow(run: WorkflowRun, title: string): RunRow {
return {
id: run.id,
status: run.status,
summary: runSummary(run, title),
when: run.started_at ? new Date(run.started_at) : (run.scheduled_for ? new Date(run.scheduled_for) : null),
durationText: runDuration(run),
cost: run.cost_usd,
};
}
export function whenText(date: Date | null, now = new Date()): string {
if (!date) return '';
const rel = relativeDayLabel(date, now);
const cap = rel.charAt(0).toUpperCase() + rel.slice(1);
return `${cap}, ${clockOf(date)}`;
}
@@ -0,0 +1,17 @@
export type AppMode = 'home' | 'calendar' | 'detail' | 'new';
export type CalView = 'week' | 'month';
// Navigation + ephemeral UI state for the Workflows app window. Data lives in
// Redux; this is only "where am I looking right now".
export interface AppNav {
mode: AppMode;
selectedId: string | null;
calView: CalView;
refDate: Date;
goHome: () => void;
goCalendar: () => void;
goNew: () => void;
selectWorkflow: (id: string) => void;
setCalView: (v: CalView) => void;
setRefDate: (d: Date) => void;
}
@@ -0,0 +1,96 @@
import type { CSSProperties } from 'react';
// The Workflows app renders in its own warm-paper visual language (from the
// Claude design), deliberately separate from the MUI theme so the window reads
// as a focused app. All tokens live here so the panes stay consistent.
export const WC = {
accent: '#C25A36',
paper: '#FBFAF7',
panel: '#F4F2EC',
rail: '#F4F2EC',
inset: '#F0EEE7',
ink: '#211E1B',
ink2: '#2B2722',
ink3: '#4B463E',
muted: '#8C857A',
muted2: '#A39C92',
faint: '#B5AEA3',
line: 'rgba(33,30,27,0.07)',
line2: 'rgba(33,30,27,0.12)',
hover: 'rgba(33,30,27,0.045)',
selBg: 'rgba(33,30,27,0.06)',
success: '#2E7D5B',
successBg: 'rgba(46,125,91,0.12)',
danger: '#C2483A',
dangerBg: 'rgba(194,72,58,0.10)',
warn: '#B98A2E',
} as const;
export const FONT_SERIF = "'Newsreader', Georgia, serif";
export const FONT_SANS = "'Hanken Grotesk', system-ui, sans-serif";
export const FONT_MONO = "'JetBrains Mono', ui-monospace, monospace";
// Stable per-workflow color: the backend has no color field, so derive a
// vivid-but-deterministic swatch from the id. Same id always lands the same
// hue, so dots/bars stay consistent across panes without persistence.
export const WORKFLOW_PALETTE = [
'#C25A36', '#3F8E83', '#5B6CB8', '#9A5B86',
'#B5852E', '#C2483A', '#4B7A4B', '#4B463E',
];
export function colorForId(id: string): string {
let h = 0;
for (let i = 0; i < id.length; i += 1) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return WORKFLOW_PALETTE[h % WORKFLOW_PALETTE.length];
}
export type RunStatus = 'success' | 'failure' | 'ran_late' | 'running' | 'skipped' | 'paused';
export function statusChip(status: RunStatus): CSSProperties {
const map: Record<string, [string, string]> = {
success: [WC.success, WC.successBg],
ran_late: [WC.warn, 'rgba(185,138,46,0.14)'],
failure: [WC.danger, 'rgba(194,72,58,0.12)'],
skipped: [WC.muted, 'rgba(33,30,27,0.07)'],
running: [WC.accent, 'rgba(0,0,0,0.04)'],
paused: [WC.muted, 'rgba(33,30,27,0.07)'],
};
const [color, background] = map[status] || map.paused;
return {
fontSize: 11, fontWeight: 600, color, background,
padding: '3px 9px', borderRadius: 999, whiteSpace: 'nowrap', flex: 'none',
};
}
export function statusDot(status: RunStatus): CSSProperties {
const map: Record<string, string> = {
success: WC.success, ran_late: WC.warn, failure: WC.danger,
running: WC.accent, skipped: WC.faint, paused: WC.faint,
};
return { width: 8, height: 8, borderRadius: '50%', background: map[status] || WC.faint, flex: 'none' };
}
export function track(on: boolean): CSSProperties {
return {
width: 34, height: 20, borderRadius: 999, background: on ? WC.accent : '#D5D1C8',
position: 'relative', cursor: 'pointer', transition: 'background .15s', flex: 'none',
};
}
export function knob(on: boolean): CSSProperties {
return {
position: 'absolute', top: 2, left: on ? 16 : 2, width: 16, height: 16, borderRadius: '50%',
background: '#fff', transition: 'left .15s', boxShadow: '0 1px 2px rgba(0,0,0,.25)',
};
}
export function statusLabel(status: RunStatus): string {
switch (status) {
case 'success': return 'Success';
case 'failure': return 'Failed';
case 'ran_late': return 'Ran late';
case 'running': return 'Running';
case 'skipped': return 'Skipped';
default: return 'Paused';
}
}
@@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from 'react';
import { API_BASE, getAuthToken } from '@/shared/config';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchSession } from '@/shared/state/agentsSlice';
import { ensureEditAgentSession } from './api';
function tok(): string { try { return getAuthToken(); } catch { return ''; } }
// Boots (or reattaches) the sticky edit-agent session for a workflow and seeds
// the opener so the agent greets in the right mode. Returns the session id once
// ready. 'build' for a fresh workflow with no steps, 'modify' for an existing one.
export function useEditAgentSession(workflowId: string, seedMode: 'build' | 'modify'): string | null {
const dispatch = useAppDispatch();
const [sessionId, setSessionId] = useState<string | null>(null);
const didInit = useRef(false);
const seeded = useRef(false);
useEffect(() => {
didInit.current = false;
seeded.current = false;
setSessionId(null);
}, [workflowId]);
useEffect(() => {
if (!workflowId || didInit.current) return;
didInit.current = true;
let alive = true;
(async () => {
const sid = await ensureEditAgentSession(workflowId);
if (!sid || !alive) return;
try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* may hydrate later */ }
if (alive) setSessionId(sid);
})();
return () => { alive = false; };
}, [workflowId, dispatch]);
const session = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId] : undefined));
useEffect(() => {
if (!sessionId || !session || seeded.current) return;
if ((session.messages || []).length > 0) { seeded.current = true; return; }
seeded.current = true;
const seed = seedMode === 'build'
? 'Greet me briefly, then ask: "What should this workflow do?"'
: 'Greet me briefly, then ask: "How would you like to modify this workflow?"';
(async () => {
try {
await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(sessionId)}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(tok() ? { Authorization: `Bearer ${tok()}` } : {}) },
body: JSON.stringify({ prompt: seed, hidden: true }),
});
} catch { /* best-effort */ }
})();
}, [sessionId, session, seedMode]);
return sessionId;
}
@@ -0,0 +1,18 @@
import { useCallback } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { updateWorkflow, fetchWorkflows } from '@/shared/state/workflowsSlice';
import type { Workflow } from '@/shared/state/workflowsSlice';
// Patch a workflow with optimistic concurrency. The PATCH carries If-Match on
// updated_at; if the record changed underneath us (409 → 'stale'), resync from
// the server so the next edit starts from truth instead of stomping it.
export function useWorkflowPatch() {
const dispatch = useAppDispatch();
return useCallback((wf: Workflow, patch: Partial<Workflow>) => {
dispatch(updateWorkflow({ id: wf.id, patch, ifMatch: wf.updated_at }))
.unwrap()
.catch((err: { kind?: string } | undefined) => {
if (err?.kind === 'stale') dispatch(fetchWorkflows(wf.dashboard_id ?? undefined));
});
}, [dispatch]);
}
@@ -1,31 +0,0 @@
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;
}
@@ -1,121 +0,0 @@
// 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 hourlyMatch = t.match(/\bevery\s+(\d{1,2})\s+hours?\b/);
const isHourly = /\b(every hour|hourly|each hour|once an hour)\b/.test(t) || !!hourlyMatch;
const minutesMatch = t.match(/\bevery\s+(\d{1,3})\s+min(?:ute)?s?\b/);
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 && !isHourly && !minutesMatch) 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();
// Sub-day cadences are the most specific; match them before daily/weekly.
if (minutesMatch) {
const every = Math.max(15, parseInt(minutesMatch[1], 10) || 15);
return {
schedule: { ...base, enabled: true, repeat_unit: 'minute', repeat_every: every },
presetLabel: `Every ${every} minutes`,
};
}
if (isHourly) {
const every = Math.max(1, hourlyMatch ? parseInt(hourlyMatch[1], 10) || 1 : 1);
return {
schedule: { ...base, enabled: true, repeat_unit: 'hour', repeat_every: every, minute },
presetLabel: every === 1 ? 'Every hour' : `Every ${every} hours`,
};
}
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}`;
}
@@ -1,52 +0,0 @@
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: c.radius.full,
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>
);
}
@@ -156,6 +156,10 @@ export interface DashboardLayoutState {
pendingFocusMissedRuns: boolean;
/** Transient: signals Dashboard to pan/zoom to the singleton Workflows Hub on open. */
pendingFocusWorkflowsHub: boolean;
/** Whether the shell-level Workflows app window is open (screen-space singleton, not a canvas card). */
workflowsAppOpen: boolean;
/** Transient deep-link target: the app jumps to this workflow's detail on open, then clears it. */
workflowsAppTarget: string | null;
}
const initialState: DashboardLayoutState = {
@@ -182,6 +186,8 @@ const initialState: DashboardLayoutState = {
pendingFocusWorkflowId: null,
pendingFocusMissedRuns: false,
pendingFocusWorkflowsHub: false,
workflowsAppOpen: false,
workflowsAppTarget: null,
};
interface LayoutPayload {
@@ -979,6 +985,20 @@ const dashboardLayoutSlice = createSlice({
state.workflowsHub = null;
},
openWorkflowsApp(state, action: PayloadAction<{ workflowId?: string } | undefined>) {
state.workflowsAppOpen = true;
state.workflowsAppTarget = action.payload?.workflowId ?? null;
},
closeWorkflowsApp(state) {
state.workflowsAppOpen = false;
state.workflowsAppTarget = null;
},
clearWorkflowsAppTarget(state) {
state.workflowsAppTarget = null;
},
setWorkflowsHubPosition(state, action: PayloadAction<{ x: number; y: number }>) {
if (!state.workflowsHub) return;
state.workflowsHub.x = action.payload.x;
@@ -1481,6 +1501,9 @@ export const {
clearPendingFocusWorkflowId,
openWorkflowsHub,
closeWorkflowsHub,
openWorkflowsApp,
closeWorkflowsApp,
clearWorkflowsAppTarget,
setWorkflowsHubPosition,
setWorkflowsHubSize,
clearPendingFocusWorkflowsHub,
+2 -3
View File
@@ -24,7 +24,7 @@ import {
clearTurnLabel,
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
import { addBrowserCardFromBackend, markBrowserCardEnding, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, addWorkflowCard } from '../state/dashboardLayoutSlice';
import { addBrowserCardFromBackend, markBrowserCardEnding, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, openWorkflowsApp } from '../state/dashboardLayoutSlice';
import { upsertOutput } from '../state/outputsSlice';
import { displaySessionName } from '../state/sessionDisplay';
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard, upsertWorkflow, removeWorkflow } from '../state/workflowsSlice';
@@ -964,8 +964,7 @@ import { WS_BASE } from '@/shared/config';
return;
}
if (outcome === 'edit' || outcome === 'open') {
store.dispatch(addWorkflowCard({ workflowId }));
store.dispatch(openWorkflowCard({ workflowId, view: outcome === 'edit' ? 'edit' : 'saved', editFacet: outcome === 'edit' ? 'Schedule' : undefined }));
store.dispatch(openWorkflowsApp({ workflowId }));
return;
}
});