mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card
This commit is contained in:
@@ -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'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'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;
|
||||
@@ -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 +
|
||||
|
||||
Reference in New Issue
Block a user