[aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card

This commit is contained in:
abccodes
2026-06-19 18:00:33 -07:00
parent 650da6c730
commit 5c05fdc812
8 changed files with 612 additions and 2 deletions
@@ -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<DashboardCardLayerProps> = ({
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 (
<>
<AnimatePresence>
@@ -283,6 +288,26 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
onBringToFront={onBringToFront}
/>
)}
{missedRunsCard && (
<MissedRunsCard
cardX={missedRunsCard.x}
cardY={missedRunsCard.y}
cardWidth={missedRunsCard.width}
cardHeight={missedRunsCard.height}
cardZOrder={missedRunsCard.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
isSelected={selection.isSelected('missed-runs')}
isHighlighted={highlightedCardId === 'missed-runs'}
multiDragDelta={selection.isSelected('missed-runs') ? multiDragDelta : null}
onCardSelect={onCardSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onBringToFront={onBringToFront}
/>
)}
{Object.values(workflowCards).map((wc) => (
<WorkflowCard
key={`workflow-${wc.workflow_id}`}
@@ -5,6 +5,7 @@ import CanvasControls from '../controls/CanvasControls';
import CardSearchPalette from '../controls/CardSearchPalette';
import DirectionHints from '../controls/DirectionHints';
import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast';
import MissedRunsToast from '@/app/pages/Workflows/MissedRunsToast';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -148,6 +149,9 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
{/* Scheduled-run nudge: "your {workflow} is running now" + jump-to-canvas */}
<WorkflowRunningToast />
{/* Launch nudge when scheduled runs elapsed while the app was closed */}
<MissedRunsToast />
</>
);
};
@@ -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;
@@ -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<Props> = ({
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
isSelected = false, isHighlighted = false, multiDragDelta = null,
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const items = useAppSelector((s) => s.missedRuns.items);
// Unchecked ids; default is everything checked. Run acts on the checked set.
const [unchecked, setUnchecked] = useState<Set<string>>(new Set());
const [confirming, setConfirming] = useState(false);
const selectedIds = useMemo(
() => items.filter((m) => !unchecked.has(m.id)).map((m) => m.id),
[items, unchecked],
);
const groups = useMemo(() => {
const by = new Map<string, { title: string; runs: MissedRunItem[] }>();
for (const m of items) {
const g = by.get(m.workflow_id) || { title: m.workflow_title, runs: [] };
g.runs.push(m);
by.set(m.workflow_id, g);
}
return Array.from(by.values());
}, [items]);
// Once everything has been run or dismissed, the card has nothing left to say.
useEffect(() => {
if (items.length === 0) dispatch(closeMissedRunsCard());
}, [items.length, dispatch]);
const toggle = useCallback((id: string) => {
setConfirming(false);
setUnchecked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const runSelected = useCallback(() => {
if (selectedIds.length === 0) return;
if (selectedIds.length > CONFIRM_THRESHOLD && !confirming) {
setConfirming(true);
return;
}
setConfirming(false);
dispatch(runMissedRuns(selectedIds));
}, [dispatch, selectedIds, confirming]);
// Closing means "I'm done": drop whatever's still listed, logged as skipped.
const closeAndDismissRest = useCallback(() => {
const rest = items.map((m) => m.id);
if (rest.length) dispatch(dismissMissedRuns(rest));
dispatch(closeMissedRunsCard());
}, [dispatch, items]);
// ---- Card drag via header (mirrors WorkflowsHubCard) ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const onHeaderPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag], button, [role="button"], input')) return;
e.preventDefault();
e.stopPropagation();
dragState.current = {
startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY,
startPanX: panRef.current.panX, startPanY: panRef.current.panY,
};
didDrag.current = false;
setIsDragging(true);
onDragStart?.('missed-runs', 'missed_runs');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, onDragStart]);
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy });
onDragMove?.(dx, dy, e.clientX, e.clientY);
}, [onDragMove]);
const onHeaderPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
justDraggedRef.current = true;
setTimeout(() => { justDraggedRef.current = false; }, 0);
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setMissedRunsCardPosition({ x: finalX, y: finalY }));
}
onDragEnd?.(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, onDragEnd]);
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const dx = (localDragPos?.x ?? cardX) + mdDx;
const dy = (localDragPos?.y ?? cardY) + mdDy;
const border = isHighlighted
? `2px solid ${c.accent.primary}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.strong}`;
const shadow = isDragging ? c.shadow.lg : isSelected ? `0 0 0 1px #3b82f6, ${c.shadow.md}` : c.shadow.sm;
const runLabel = confirming
? `Run ${selectedIds.length} now?`
: selectedIds.length === items.length
? `Run all ${items.length}`
: `Run ${selectedIds.length} selected`;
return (
<Box
data-select-type="missed-runs-card"
data-select-id="missed-runs"
onPointerDownCapture={(e: React.PointerEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag]')) return;
onBringToFront?.('missed-runs', 'missed_runs');
}}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag]')) return;
onCardSelect?.('missed-runs', 'missed_runs', e.shiftKey);
}}
sx={{
position: 'absolute',
contain: 'layout style',
willChange: 'transform',
left: dx,
top: dy,
width: cardWidth,
height: cardHeight,
bgcolor: c.bg.surface,
border,
borderRadius: 3,
boxShadow: shadow,
display: 'flex',
flexDirection: 'column',
zIndex: isDragging ? 999999 : cardZOrder,
transition: isDragging ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease',
}}
>
{/* Title strip (drag handle) */}
<Box
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 0.6,
px: 1.5, py: 0.7,
borderBottom: `1px solid ${c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none', flexShrink: 0,
}}
>
<HistoryRoundedIcon sx={{ fontSize: 17, color: c.accent.primary }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 600, fontSize: '0.95rem', color: c.text.primary }}>Missed while you were away</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
{items.length} run{items.length === 1 ? '' : 's'} didn&apos;t fire. Run the ones you still want.
</Typography>
</Box>
<IconButton
size="small"
data-no-drag
onClick={(e) => { e.stopPropagation(); closeAndDismissRest(); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Scrollable list grouped by workflow */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 1, py: 0.5 }}>
{groups.map((g) => (
<Box key={g.title + g.runs[0].workflow_id} sx={{ mb: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 0.75, py: 0.4 }}>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 700, color: c.accent.primary, flexShrink: 0 }}>{g.runs.length}</Typography>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{g.title}</Typography>
</Box>
{g.runs.map((m) => (
<Box
key={m.id}
data-no-drag
onClick={() => toggle(m.id)}
sx={{
display: 'flex', alignItems: 'center', gap: 0.4,
pl: 1, pr: 0.75, py: 0.15, ml: 1.5,
borderRadius: `${c.radius.sm}px`, cursor: 'pointer',
'&:hover': { bgcolor: c.bg.elevated },
}}
>
<Checkbox
size="small"
checked={!unchecked.has(m.id)}
onChange={() => toggle(m.id)}
onClick={(e) => e.stopPropagation()}
sx={{ p: 0.25, color: c.text.muted, '&.Mui-checked': { color: c.accent.primary } }}
/>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>{formatWhen(m.scheduled_for)}</Typography>
</Box>
))}
</Box>
))}
</Box>
{/* Footer actions */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.25, py: 0.85, borderTop: `1px solid ${c.border.subtle}`, flexShrink: 0 }}>
{confirming && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, flex: 1 }}>
That&apos;s {selectedIds.length} real runs.
</Typography>
)}
{!confirming && <Box sx={{ flex: 1 }} />}
<Box
data-no-drag
role="button"
onClick={confirming ? () => setConfirming(false) : closeAndDismissRest}
sx={{ fontSize: '0.78rem', color: c.text.muted, cursor: 'pointer', px: 1, py: 0.5, '&:hover': { color: c.text.primary } }}
>
{confirming ? 'Cancel' : 'Skip the rest'}
</Box>
<Box
data-no-drag
role="button"
onClick={runSelected}
sx={{
fontSize: '0.78rem', fontWeight: 600,
color: selectedIds.length === 0 ? c.text.ghost : '#fff',
bgcolor: selectedIds.length === 0 ? c.bg.secondary : c.accent.primary,
cursor: selectedIds.length === 0 ? 'default' : 'pointer',
px: 1.25, py: 0.5, borderRadius: `${c.radius.md}px`,
'&:hover': { bgcolor: selectedIds.length === 0 ? c.bg.secondary : c.accent.hover },
}}
>
{runLabel}
</Box>
</Box>
</Box>
);
};
export default MissedRunsCard;
@@ -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 (
<Snackbar
open={open && count > 0}
autoHideDuration={null}
onClose={() => dispatch(hideMissedRunsToast())}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
icon={false}
severity="info"
sx={{
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.border.medium}`,
'& .MuiAlert-action': { alignItems: 'center', pt: 0 },
}}
action={
<>
<Button size="small" onClick={onReview} sx={{ color: c.accent.primary, fontWeight: 700 }}>
Review
</Button>
<IconButton
size="small"
aria-label="Dismiss"
onClick={() => dispatch(hideMissedRunsToast())}
sx={{ color: c.text.muted, ml: 0.25, '&:hover': { color: c.text.primary } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</>
}
>
{`${count} scheduled run${count === 1 ? '' : 's'} ${count === 1 ? 'was' : 'were'} missed while you were away`}
</Alert>
</Snackbar>
);
}
@@ -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<string, WorkflowCardPosition>;
configurePanels: Record<string, ConfigurePanelPosition>;
workflowsHub: WorkflowsHubPosition | null;
missedRunsCard: MissedRunsCardPosition | null;
notes: Record<string, NotePosition>;
closedCardPositions: Record<string, CardPosition>;
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
@@ -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,
@@ -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;
+2
View File
@@ -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 +