diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 454fc8ba..32028713 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -6,6 +6,7 @@ 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, @@ -19,6 +20,7 @@ import { type WorkflowsHubPosition, type ConfigurePanelPosition, } from '@/shared/state/dashboardLayoutSlice'; +import { useAppSelector } from '@/shared/hooks'; import type { Output } from '@/shared/state/outputsSlice'; import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection'; @@ -98,6 +100,9 @@ const DashboardCardLayer: React.FC = ({ onBranch, onMeasuredHeight, }) => { + // Ephemeral singleton, not part of the saved layout, so read it straight + // from the store rather than threading it through the selector chain. + const missedRunsCard = useAppSelector((s) => s.dashboardLayout.missedRunsCard); return ( <> @@ -283,6 +288,26 @@ const DashboardCardLayer: React.FC = ({ onBringToFront={onBringToFront} /> )} + {missedRunsCard && ( + + )} {Object.values(workflowCards).map((wc) => ( = ({ {/* Scheduled-run nudge: "your {workflow} is running now" + jump-to-canvas */} + + {/* Launch nudge when scheduled runs elapsed while the app was closed */} + ); }; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index e56d6d34..68af7c92 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -17,17 +17,23 @@ import { clearPendingFocusBrowserId, clearPendingFocusWorkflowId, clearPendingFocusWorkflowsHub, + clearPendingFocusMissedRuns, type ViewCardPosition, } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs, type Output } from '@/shared/state/outputsSlice'; import { generateDashboardName } from '@/shared/state/dashboardsSlice'; import { fetchWorkflows } from '@/shared/state/workflowsSlice'; +import { fetchMissedRuns } from '@/shared/state/missedRunsSlice'; import { dashboardWs } from '@/shared/ws/WebSocketManager'; import { initBrowserCommandHandler } from '@/shared/browserCommandHandler'; import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { API_BASE } from '@/shared/config'; import type { CanvasActions } from '../interaction/useCanvasControls'; +// Module-level so the missed-runs review pops exactly once per app launch, +// not again on every dashboard switch. +let missedRunsCheckedThisSession = false; + interface UseDashboardLifecycleArgs { isActive: boolean; dashboardId: string; @@ -64,8 +70,18 @@ export function useDashboardLifecycle({ const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId); const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId); const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId); + const pendingFocusMissedRuns = useAppSelector((state) => state.dashboardLayout.pendingFocusMissedRuns); const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub); + // Once per app launch: if scheduled fires elapsed while we were closed, fetch + // them. The slice flips its toast flag on fulfilled, so a bottom-left nudge + // shows instead of a card popping unrequested; the user opens the card from it. + useEffect(() => { + if (!isActive || missedRunsCheckedThisSession) return; + missedRunsCheckedThisSession = true; + dispatch(fetchMissedRuns()); + }, [isActive, dispatch]); + // Track dashboard engagement time useEffect(() => { if (!dashboardId) return; @@ -248,6 +264,24 @@ export function useDashboardLifecycle({ }, 200); }, [isActive, pendingFocusWorkflowId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]); + // Same pan/highlight choreography when the missed-runs card opens from its toast. + useEffect(() => { + if (!isActive) return; + if (!pendingFocusMissedRuns || !layoutInitialized) return; + dispatch(clearPendingFocusMissedRuns()); + setTimeout(() => { + const card = store.getState().dashboardLayout.missedRunsCard; + if (card) { + canvasActions.fitToCards( + [{ x: card.x, y: card.y, width: card.width, height: card.height }], + 1.15, + true, + ); + handleHighlightCard('missed-runs'); + } + }, 200); + }, [isActive, pendingFocusMissedRuns, layoutInitialized, dispatch, canvasActions, handleHighlightCard]); + // Pan/zoom to Workflows Hub on Expand; chained rAFs ensure fit runs after the hub div lands at its new coords. useEffect(() => { if (!isActive) return; diff --git a/frontend/src/app/pages/Workflows/MissedRunsCard.tsx b/frontend/src/app/pages/Workflows/MissedRunsCard.tsx new file mode 100644 index 00000000..4860adec --- /dev/null +++ b/frontend/src/app/pages/Workflows/MissedRunsCard.tsx @@ -0,0 +1,331 @@ +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; + }); + }, []); + + 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) => ( + + + {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 the rest'} + + + {runLabel} + + + + ); +}; + +export default MissedRunsCard; diff --git a/frontend/src/app/pages/Workflows/MissedRunsToast.tsx b/frontend/src/app/pages/Workflows/MissedRunsToast.tsx new file mode 100644 index 00000000..7e750d69 --- /dev/null +++ b/frontend/src/app/pages/Workflows/MissedRunsToast.tsx @@ -0,0 +1,63 @@ +// 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. + +import React from 'react'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +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'; + +export default function MissedRunsToast() { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const open = useAppSelector((s) => s.missedRuns.toastOpen); + const count = useAppSelector((s) => s.missedRuns.items.length); + + const onReview = React.useCallback(() => { + dispatch(openMissedRunsCard(undefined)); + dispatch(hideMissedRunsToast()); + }, [dispatch]); + + return ( + 0} + autoHideDuration={null} + onClose={() => dispatch(hideMissedRunsToast())} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + + + dispatch(hideMissedRunsToast())} + sx={{ color: c.text.muted, ml: 0.25, '&:hover': { color: c.text.primary } }} + > + + + + } + > + {`${count} scheduled run${count === 1 ? '' : 's'} ${count === 1 ? 'was' : 'were'} missed while you were away`} + + + ); +} diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 7068096f..e6c207a8 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -22,12 +22,14 @@ export const DEFAULT_WORKFLOW_CARD_W = 480; export const DEFAULT_WORKFLOW_CARD_H = 520; export const DEFAULT_WORKFLOWS_HUB_W = 1200; export const DEFAULT_WORKFLOWS_HUB_H = 640; +export const DEFAULT_MISSED_RUNS_W = 460; +export const DEFAULT_MISSED_RUNS_H = 420; export const EXPANDED_CARD_MIN_H = 620; export const GRID_GAP = 24; const GRID_ORIGIN = { x: 40, y: 100 }; const GRID_COLS_FALLBACK = 4; -export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub'; +export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' | 'missed_runs'; export interface CardPosition { session_id: string; @@ -89,6 +91,17 @@ export interface WorkflowsHubPosition { zOrder: number; } +// Ephemeral launch-time card listing scheduled fires missed while the app was +// closed. Singleton like workflowsHub, but deliberately NOT persisted to the +// saved layout: the launch hook decides each session whether to show it. +export interface MissedRunsCardPosition { + x: number; + y: number; + width: number; + height: number; + zOrder: number; +} + export type NoteColor = 'yellow' | 'pink' | 'blue' | 'green' | 'purple' | 'gray'; export interface NotePosition { @@ -120,6 +133,7 @@ export interface DashboardLayoutState { workflowCards: Record; configurePanels: Record; workflowsHub: WorkflowsHubPosition | null; + missedRunsCard: MissedRunsCardPosition | null; notes: Record; closedCardPositions: Record; glowingBrowserCards: Record; @@ -138,6 +152,8 @@ export interface DashboardLayoutState { /** Transient: id of the view card the user has clicked into; preload stops forwarding canvas gestures while set. */ activeViewCardId: string | null; pendingFocusWorkflowId: string | null; + /** Transient: signals Dashboard to pan/zoom to the missed-runs card on open. */ + pendingFocusMissedRuns: boolean; /** Transient: signals Dashboard to pan/zoom to the singleton Workflows Hub on open. */ pendingFocusWorkflowsHub: boolean; } @@ -149,6 +165,7 @@ const initialState: DashboardLayoutState = { workflowCards: {}, configurePanels: {}, workflowsHub: null, + missedRunsCard: null, notes: {}, closedCardPositions: {}, glowingBrowserCards: {}, @@ -163,6 +180,7 @@ const initialState: DashboardLayoutState = { endingBrowserCards: {}, activeViewCardId: null, pendingFocusWorkflowId: null, + pendingFocusMissedRuns: false, pendingFocusWorkflowsHub: false, }; @@ -278,6 +296,9 @@ function collectOccupiedRects( if (state.workflowsHub) { rects.push({ x: state.workflowsHub.x, y: state.workflowsHub.y, w: state.workflowsHub.width, h: state.workflowsHub.height }); } + if (state.missedRunsCard) { + rects.push({ x: state.missedRunsCard.x, y: state.missedRunsCard.y, w: state.missedRunsCard.width, h: state.missedRunsCard.height }); + } for (const n of Object.values(state.notes)) { rects.push({ x: n.x, y: n.y, w: n.width, h: n.height }); } @@ -454,7 +475,7 @@ const dashboardLayoutSlice = createSlice({ bringToFront( state, - action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' }>, + action: PayloadAction<{ id: string; type: CardType }>, ) { const { id, type } = action.payload; // Compute the current top zOrder across ALL card types so we can @@ -473,11 +494,13 @@ const dashboardLayoutSlice = createSlice({ for (const c of Object.values(state.workflowCards)) tally(c.zOrder); for (const n of Object.values(state.notes)) tally(n.zOrder); if (state.workflowsHub) tally(state.workflowsHub.zOrder); + if (state.missedRunsCard) tally(state.missedRunsCard.zOrder); if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0; else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0; else if (type === 'note') currentZ = state.notes[id]?.zOrder ?? 0; else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0; else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0; + else if (type === 'missed_runs') currentZ = state.missedRunsCard?.zOrder ?? 0; else currentZ = state.browserCards[id]?.zOrder ?? 0; if (currentZ >= maxZ) return; // Already on top: no-op. @@ -496,6 +519,8 @@ const dashboardLayoutSlice = createSlice({ if (card) card.zOrder = z; } else if (type === 'workflows-hub') { if (state.workflowsHub) state.workflowsHub.zOrder = z; + } else if (type === 'missed_runs') { + if (state.missedRunsCard) state.missedRunsCard.zOrder = z; } else { const card = state.browserCards[id]; if (card) card.zOrder = z; @@ -915,6 +940,37 @@ const dashboardLayoutSlice = createSlice({ state.pendingFocusWorkflowsHub = false; }, + openMissedRunsCard(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) { + state.pendingFocusMissedRuns = true; + if (state.missedRunsCard) { + state.missedRunsCard.zOrder = state.nextZOrder++; + return; + } + const rects = collectOccupiedRects(state, action.payload?.expandedSessionIds); + const pos = findOpenGridCell(rects, DEFAULT_MISSED_RUNS_W, DEFAULT_MISSED_RUNS_H); + state.missedRunsCard = { + x: pos.x, + y: pos.y, + width: DEFAULT_MISSED_RUNS_W, + height: DEFAULT_MISSED_RUNS_H, + zOrder: state.nextZOrder++, + }; + }, + + clearPendingFocusMissedRuns(state) { + state.pendingFocusMissedRuns = false; + }, + + closeMissedRunsCard(state) { + state.missedRunsCard = null; + }, + + setMissedRunsCardPosition(state, action: PayloadAction<{ x: number; y: number }>) { + if (!state.missedRunsCard) return; + state.missedRunsCard.x = action.payload.x; + state.missedRunsCard.y = action.payload.y; + }, + closeWorkflowsHub(state) { state.workflowsHub = null; }, @@ -1116,6 +1172,11 @@ const dashboardLayoutSlice = createSlice({ state.workflowsHub.x += dx; state.workflowsHub.y += dy; } + } else if (item.type === 'missed_runs') { + if (state.missedRunsCard) { + state.missedRunsCard.x += dx; + state.missedRunsCard.y += dy; + } } else { const card = state.browserCards[item.id]; if (card) { @@ -1246,6 +1307,7 @@ const dashboardLayoutSlice = createSlice({ state.workflowCards = {}; state.configurePanels = {}; state.workflowsHub = null; + state.missedRunsCard = null; state.notes = {}; state.closedCardPositions = {}; state.glowingBrowserCards = {}; @@ -1257,6 +1319,7 @@ const dashboardLayoutSlice = createSlice({ state.suspendedBrowserCards = {}; state.endingBrowserCards = {}; state.pendingFocusWorkflowId = null; + state.pendingFocusMissedRuns = false; }, }, @@ -1417,6 +1480,10 @@ export const { setWorkflowsHubPosition, setWorkflowsHubSize, clearPendingFocusWorkflowsHub, + openMissedRunsCard, + clearPendingFocusMissedRuns, + closeMissedRunsCard, + setMissedRunsCardPosition, addNote, setNotePosition, setNoteSize, diff --git a/frontend/src/shared/state/missedRunsSlice.ts b/frontend/src/shared/state/missedRunsSlice.ts new file mode 100644 index 00000000..ea99b3d6 --- /dev/null +++ b/frontend/src/shared/state/missedRunsSlice.ts @@ -0,0 +1,84 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { API_BASE } from '@/shared/config'; + +const API = `${API_BASE}/workflows`; + +export interface MissedRunItem { + id: string; + workflow_id: string; + workflow_title: string; + workflow_icon: string; + /** ISO instant the fire was supposed to happen. */ + scheduled_for: string; +} + +interface State { + items: MissedRunItem[]; + loading: boolean; + toastOpen: boolean; +} + +const initialState: State = { + items: [], + loading: false, + toastOpen: false, +}; + +export const fetchMissedRuns = createAsyncThunk('missedRuns/fetch', async () => { + const res = await fetch(`${API}/missed`); + const data = await res.json(); + return data.missed as MissedRunItem[]; +}); + +export const runMissedRuns = createAsyncThunk('missedRuns/run', async (ids: string[]) => { + await fetch(`${API}/missed/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }); + return ids; +}); + +export const dismissMissedRuns = createAsyncThunk('missedRuns/dismiss', async (ids: string[]) => { + await fetch(`${API}/missed/dismiss`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }); + return ids; +}); + +const missedRunsSlice = createSlice({ + name: 'missedRuns', + initialState, + reducers: { + hideMissedRunsToast(state) { + state.toastOpen = false; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchMissedRuns.pending, (state) => { + state.loading = true; + }) + .addCase(fetchMissedRuns.fulfilled, (state, action) => { + state.loading = false; + state.items = action.payload || []; + state.toastOpen = (action.payload?.length ?? 0) > 0; + }) + .addCase(fetchMissedRuns.rejected, (state) => { + state.loading = false; + }); + // Both run and dismiss remove the acted-on ids from the list. + for (const thunk of [runMissedRuns, dismissMissedRuns]) { + builder.addCase(thunk.fulfilled, (state, action) => { + const gone = new Set(action.payload); + state.items = state.items.filter((m) => !gone.has(m.id)); + }); + } + }, +}); + +export const { hideMissedRunsToast } = missedRunsSlice.actions; + +export default missedRunsSlice.reducer; diff --git a/frontend/src/shared/state/store.ts b/frontend/src/shared/state/store.ts index 30f5584b..11c54901 100644 --- a/frontend/src/shared/state/store.ts +++ b/frontend/src/shared/state/store.ts @@ -16,6 +16,7 @@ import modelsReducer from './modelsSlice'; import interactionReducer from './interactionSlice'; import subscriptionsReducer from './subscriptionsSlice'; import workflowsReducer from './workflowsSlice'; +import missedRunsReducer from './missedRunsSlice'; import onboardingProgressReducer from '@/shared/state/onboardingProgressSlice'; export const store = configureStore({ @@ -37,6 +38,7 @@ export const store = configureStore({ interaction: interactionReducer, subscriptions: subscriptionsReducer, workflows: workflowsReducer, + missedRuns: missedRunsReducer, onboardingProgress: onboardingProgressReducer, }, // Disable Redux Toolkit's dev-mode invariant middleware (serializable +