mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[aidan] feat: new scheduled task design ported
This commit is contained in:
@@ -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'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'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'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'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'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'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 }}>
|
||||
"{existing.title}" 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 haven’t 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user