diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 6064e4df..2f47693b 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -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 = () => { + + + + ( 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( New Chat - { - // 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 }, - }}> - - Schedule - )} ( 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} @@ -824,7 +782,7 @@ const DashboardToolbar = React.forwardRef( role="button" aria-label="Workflows" tabIndex={0} - onClick={() => dispatch(workflowsHubOpen ? closeWorkflowsHub() : openWorkflowsHub({ expandedSessionIds: [] }))} + onClick={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())} sx={{ display: 'flex', alignItems: 'center', diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 2bcf6641..fa3da9c9 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -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 = ({ onBringToFront={onBringToFront} /> ))} - {workflowsHub && ( - - )} - {missedRunsCard && ( - - )} - {Object.values(workflowCards).map((wc) => ( - - ))} - {Object.values(configurePanels).map((p) => ( - - ))} + {/* Workflows now live in the shell-level Workflows app, not on the canvas. */} {/* Marquee selection rectangle */} {selection.marquee && (
= ({ 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(null); const autoOpenedWorkflowIdsRef = useRef>(new Set()); @@ -525,10 +525,9 @@ const AgentCard: React.FC = ({ 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(null); // Ref so ResizeObserver sees latest value without re-attaching when active flips. @@ -1096,29 +1095,6 @@ const AgentCard: React.FC = ({ onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }} > - {showSourceWorkflowSchedule && ( - - 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)' }, - }} - > - - Schedule Workflow - - - )} = ({ )} - {showConvertToWorkflow && ( - - 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 }, - }} - > - - {converting ? 'Converting...' : 'Convert to workflow'} - - - )} @@ -1420,80 +1347,6 @@ const AgentCard: React.FC = ({ ) : null} )} - - { - 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)', - }} - > - 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, - }} - > - - Would you like to make this a workflow? - - - I can open a workflow draft from this chat. You can review the steps and choose the schedule there. - - {workflowSuggestion?.cadence && ( - - Suggested cadence: {workflowSuggestion.cadence} - - )} - - - - - - - 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 ( - - - Do you want to prevent the agent from taking actions that weren't used in the original workflow? - - - - - - - Do you want to freeze the actions available to the Agent so this flow always works even if you change your settings? - - - - - - {/* 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 && ( - - - {configuring ? '⚙ Configuring…' : '⚙ Configure'} - - - )} - - - - ); -} - -// 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 ( - - - - Saved permissions this workflow reuses on later runs. - - 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 - - - {entries.map(([tool, answer]) => ( - - - {prettyName(tool)} - - - {answer === 'allow' ? 'Allowed' : 'Blocked'} - - 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 } }}> - ✕ - - - ))} - - ); -} diff --git a/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx b/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx deleted file mode 100644 index 0c744bc1..00000000 --- a/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx +++ /dev/null @@ -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 ( - - - NEEDS SCHEDULE - - - - - Make a schedule - This workflow does not have a schedule yet. Choose when it should run. - - - - ); -} diff --git a/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx b/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx deleted file mode 100644 index 2db4947f..00000000 --- a/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx +++ /dev/null @@ -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 ( - - {/* Drag handle + close X strip across the top. Stays slim so the - full Action Library underneath gets the vertical space. */} - - - Action Library - 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 } }}> - - - - {/* Body: the real Action Library, exact same component as /actions. */} - - - - {/* SE resize handle. */} - - - ); -} diff --git a/frontend/src/app/pages/Workflows/EditAgentSavePopovers.tsx b/frontend/src/app/pages/Workflows/EditAgentSavePopovers.tsx deleted file mode 100644 index 3982e274..00000000 --- a/frontend/src/app/pages/Workflows/EditAgentSavePopovers.tsx +++ /dev/null @@ -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 ( - - {label} - {hint} - - ); -} - -// '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) => ( - - {text} - - ); - return ( - - {phase === 'ask-test' && ( - <> - {heading('BEFORE YOU SAVE')} - - Want to test the workflow before finishing your edits? - - - - - )} - {phase === 'confirm-discard' && ( - <> - {heading('DISCARD CHANGES')} - - Throw away every edit from this session? This can't be undone. - - - - - )} - - ); -} diff --git a/frontend/src/app/pages/Workflows/EditAgentView.tsx b/frontend/src/app/pages/Workflows/EditAgentView.tsx deleted file mode 100644 index 4148baf5..00000000 --- a/frontend/src/app/pages/Workflows/EditAgentView.tsx +++ /dev/null @@ -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(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('idle'); - const [saveAnchorEl, setSaveAnchorEl] = useState(null); - const [testSessionId, setTestSessionId] = useState(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) => { - 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) => { - 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 ( - - {/* 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. */} - - - 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 }, - }}> - - Workflow ({draftSteps.length} step{draftSteps.length === 1 ? '' : 's'}) - - {hasChanges && ( - · unsaved - )} - - {allowDiscard && ( - - Discard - - )} - - Save - - - {stepsOpen && ( - - - {isFixMode && fixSeed && ( - - setFixPrefixExpanded((x) => !x)} /> - - )} - - )} - - 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). */} - - {editSessionId ? ( - - ) : ( - - Starting the Edit Agent... - - )} - - - ); -} - -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 ( - - - - - - - - Fixing Step {seed.stepIdx + 1}: {seed.stepLabel} - - {needsExpand && ( - - )} - - - {shown} - - - - ); -} diff --git a/frontend/src/app/pages/Workflows/GeneralFacet.tsx b/frontend/src/app/pages/Workflows/GeneralFacet.tsx deleted file mode 100644 index d1fbee8f..00000000 --- a/frontend/src/app/pages/Workflows/GeneralFacet.tsx +++ /dev/null @@ -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 ( - - - 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 }} - /> - - - 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 }} - /> - - - - - {!draft.use_synced_prompt && ( - 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 }} - /> - )} - - Workflow - {sourceSessionId && ( - - - Edit - - )} - - - {draft.steps.map((s, idx) => ( - - {idx + 1} - { - 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 }} - /> - 1 ? 'Remove step' : 'Workflow needs at least one step'}> - - { - 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, - }, - }} - > - - - - - - ))} - - - ); -} diff --git a/frontend/src/app/pages/Workflows/MissedRunsCard.tsx b/frontend/src/app/pages/Workflows/MissedRunsCard.tsx deleted file mode 100644 index dcce4b5d..00000000 --- a/frontend/src/app/pages/Workflows/MissedRunsCard.tsx +++ /dev/null @@ -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 = ({ - 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>(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(); - 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 ( - { - 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) */} - - - - Missed while you were away - - {items.length} run{items.length === 1 ? '' : 's'} didn't fire. Run the ones you still want. - - - { e.stopPropagation(); closeAndDismissRest(); }} - onPointerDown={(e) => e.stopPropagation()} - sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }} - > - - - - - {/* Scrollable list grouped by workflow */} - - {groups.map((g) => { - const checkedCount = g.runs.filter((r) => !unchecked.has(r.id)).length; - const allChecked = checkedCount === g.runs.length; - return ( - - 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 } }} - > - 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 } }} - /> - {g.runs.length} - {g.title} - - {g.runs.map((m) => ( - 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 }, - }} - > - toggle(m.id)} - onClick={(e) => e.stopPropagation()} - sx={{ p: 0.25, color: c.text.muted, '&.Mui-checked': { color: c.accent.primary } }} - /> - {formatWhen(m.scheduled_for)} - - ))} - - ); - })} - - - {/* Footer actions */} - - {confirming && ( - - That's {selectedIds.length} real runs. - - )} - {!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'} - - - {runLabel} - - - - ); -}; - -export default MissedRunsCard; diff --git a/frontend/src/app/pages/Workflows/MissedRunsToast.tsx b/frontend/src/app/pages/Workflows/MissedRunsToast.tsx index 7e750d69..9e8b1db0 100644 --- a/frontend/src/app/pages/Workflows/MissedRunsToast.tsx +++ b/frontend/src/app/pages/Workflows/MissedRunsToast.tsx @@ -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]); diff --git a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx deleted file mode 100644 index d4a3c573..00000000 --- a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx +++ /dev/null @@ -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 } { - const [info, setInfo] = useState({ 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) => { - 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) => { - 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 ( - - {/* Master on/off. */} - - setSched({ enabled: e.target.checked })} /> - - {s.enabled ? 'Schedule is on' : 'Schedule is off'} - - - - {s.enabled && ( - - )} - - {/* Section: When should this workflow run? */} - - - When should this workflow run? - - - Repeat every - { - // 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 }} - /> - - - {s.repeat_unit === 'week' && ( - - ↳ on - {WEEKDAY_LABEL.map((label, idx) => { - const active = s.on_days.includes(idx); - return ( - 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} - ); - })} - - )} - {s.repeat_unit === 'month' && ( - - ↳ on day - 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 }} - /> - - )} - {/* 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' && ( - - At - : - - past the hour - - )} - {(s.repeat_unit === 'day' || s.repeat_unit === 'week' || s.repeat_unit === 'month') && ( - - At - - : - - - {friendlyTzLabel(s.timezone)} - - )} - {nextPreview && s.enabled && ( - - Next run: {formatNextRun(nextPreview)} - - )} - - Runs - - {endKind === 'on_date' && ( - { - 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' && ( - - 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 }} - /> - runs ({s.runs_count} so far) - - )} - - {(() => { - if (endKind === 'on_date' && s.ends_at) { - const ends = new Date(s.ends_at).getTime(); - if (!Number.isNaN(ends) && ends <= Date.now()) { - return ( - - This date is in the past. The schedule will turn itself off. - - ); - } - } - if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) { - return ( - - This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm. - - ); - } - return null; - })()} - - - {/* Section: What can the agent do? */} - - - What can the agent do? - - - - - {/* Section: How should the agent ask for your permission? */} - - - How should the agent ask for your permission? - - {(draft.permissions || []).map((tier, idx) => ( - setTier(idx, patch)} - onRemove={idx === 0 ? undefined : () => removeTier(idx)} - /> - ))} - {canAddBackup && ( - + Escalate if I don't respond - )} - - - ); -} - -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 ( - - - - {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.`)} - - {!good && ( - - Always-on - - )} - - ); -} - -function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: { - idx: number; - tier: PermissionTier; - cloudSmsEnabled: boolean; - onChange: (p: Partial) => void; - onRemove?: () => void; -}) { - const c = useClaudeTokens(); - if (idx === 0) { - return ( - - ); - } - const unitLabel = tier.kind === 'call' ? 'hour' : 'minutes'; - return ( - - - after - 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 }} - /> - {unitLabel} - - - - at - 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 && ( - × - )} - - {!cloudSmsEnabled && ( - - Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge. - - )} - - ); -} diff --git a/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx deleted file mode 100644 index acb659d4..00000000 --- a/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx +++ /dev/null @@ -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; -}; - -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: