mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[aidan] feat/workflows: add scheduled-run "running now" toast with click-to-view (#97)
This commit is contained in:
@@ -23,7 +23,6 @@ import AppShell from './components/Layout/AppShell';
|
||||
import ImportEntryPoint from './components/share/ImportEntryPoint';
|
||||
import DashboardSelection from './pages/DashboardSelection/DashboardSelection';
|
||||
import ErrorBoundary from './components/feedback/ErrorBoundary';
|
||||
import ScheduledRunToast from './components/feedback/ScheduledRunToast';
|
||||
import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboardingProgressSlice';
|
||||
|
||||
const Skills = React.lazy(() => import('./pages/Skills/Skills'));
|
||||
@@ -333,7 +332,7 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
open={!!warning}
|
||||
autoHideDuration={8000}
|
||||
onClose={() => setWarning(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert
|
||||
severity="warning"
|
||||
@@ -470,7 +469,6 @@ const ThemedApp: React.FC = () => {
|
||||
<DefaultModelGuard>
|
||||
<UpdateListener>
|
||||
<CrashRecoveryChip />
|
||||
<ScheduledRunToast />
|
||||
<ImportEntryPoint />
|
||||
<DeepLinkListener>
|
||||
<ErrorBoundary scope="routes">
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Fade from '@mui/material/Fade';
|
||||
import BoltRoundedIcon from '@mui/icons-material/BoltRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { clearRunStartSignal, openWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
const VISIBLE_MS = 6000;
|
||||
|
||||
// Quiet bottom-center toast that confirms an unattended scheduled run kicked
|
||||
// off, so the user isn't blind to it the way the completion notification used
|
||||
// to be the only signal. Click to jump straight to the live run.
|
||||
export default function ScheduledRunToast() {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const signal = useAppSelector((s) => s.workflows.runStartSignal);
|
||||
const [show, setShow] = useState(false);
|
||||
// Keep the last payload so the exit fade renders the same content instead of
|
||||
// blanking mid-animation once the signal is cleared.
|
||||
const snapshot = useRef<typeof signal>(null);
|
||||
if (signal) snapshot.current = signal;
|
||||
|
||||
useEffect(() => {
|
||||
if (!signal) return;
|
||||
setShow(true);
|
||||
const t = setTimeout(() => setShow(false), VISIBLE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [signal?.nonce]);
|
||||
|
||||
const display = snapshot.current;
|
||||
if (!display) return null;
|
||||
|
||||
const onOpen = () => {
|
||||
dispatch(addWorkflowCard({ workflowId: display.workflowId }));
|
||||
dispatch(openWorkflowCard({ workflowId: display.workflowId, view: 'running', runId: display.runId }));
|
||||
setShow(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fade in={show} timeout={{ enter: 200, exit: 220 }} unmountOnExit onExited={() => dispatch(clearRunStartSignal())}>
|
||||
<Box
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
sx={{
|
||||
position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 1500,
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
|
||||
boxShadow: c.shadow.md, borderRadius: `${c.radius.lg}px`,
|
||||
px: 1.75, py: 1,
|
||||
cursor: 'pointer', maxWidth: 360,
|
||||
'&:hover': { borderColor: c.accent.primary },
|
||||
}}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', width: 18, height: 18, color: c.accent.primary, animation: 'srtPulse 1.4s ease-in-out infinite' }}>
|
||||
<BoltRoundedIcon sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box sx={{ fontSize: '0.85rem', color: c.text.primary, lineHeight: 1.4 }}>
|
||||
<b>{display.title}</b> started running
|
||||
</Box>
|
||||
<Box sx={{ fontSize: '0.78rem', color: c.accent.primary, fontWeight: 700, ml: 0.5 }}>Open</Box>
|
||||
<style>{'@keyframes srtPulse{0%,100%{opacity:1}50%{opacity:0.4}}'}</style>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import DashboardToolbar from '../DashboardToolbar';
|
||||
import CanvasControls from '../controls/CanvasControls';
|
||||
import CardSearchPalette from '../controls/CardSearchPalette';
|
||||
import DirectionHints from '../controls/DirectionHints';
|
||||
import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast';
|
||||
import type { AgentSession } from '@/shared/state/agentsSlice';
|
||||
import type {
|
||||
CardPosition,
|
||||
@@ -144,6 +145,9 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
|
||||
browserCards={browserCards}
|
||||
sessions={sessions}
|
||||
/>
|
||||
|
||||
{/* Scheduled-run nudge: "your {workflow} is running now" + jump-to-canvas */}
|
||||
<WorkflowRunningToast />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,8 +46,6 @@ function stopViewingSidecar(dispatch: AppDispatch, workflowId: string, sessionId
|
||||
// dashboard draws an arrow chip between the two cards.
|
||||
export function useOpenSidecar(workflowId: string) {
|
||||
const dispatch = useAppDispatch();
|
||||
const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflowId]);
|
||||
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
|
||||
return React.useCallback(async (sessionId: string, kind: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing') => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
@@ -55,6 +53,7 @@ export function useOpenSidecar(workflowId: string) {
|
||||
if (!store.getState().agents.sessions[sessionId]) {
|
||||
try { await dispatch(fetchSession(sessionId)).unwrap(); } catch { /* not fatal */ }
|
||||
}
|
||||
const wfCardPos = store.getState().dashboardLayout.workflowCards[workflowId];
|
||||
if (!store.getState().dashboardLayout.cards[sessionId] && wfCardPos) {
|
||||
dispatch(placeCard({
|
||||
sessionId,
|
||||
@@ -62,13 +61,13 @@ export function useOpenSidecar(workflowId: string) {
|
||||
y: wfCardPos.y,
|
||||
width: DEFAULT_CARD_W,
|
||||
height: DEFAULT_CARD_H,
|
||||
expandedSessionIds,
|
||||
expandedSessionIds: store.getState().agents.expandedSessionIds,
|
||||
}));
|
||||
}
|
||||
dispatch(setPendingFocusAgentId(sessionId));
|
||||
} catch { /* best-effort */ }
|
||||
dispatch(setCardSidecar({ workflowId, sessionId, kind }));
|
||||
}, [dispatch, workflowId, wfCardPos, expandedSessionIds]);
|
||||
}, [dispatch, workflowId]);
|
||||
}
|
||||
|
||||
type ViewMode = 'card' | 'sidecar-linked';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Clickable "your {workflow} is running now" nudge for scheduled runs that
|
||||
// fire while the user isn't looking. Detection lives in the upsertRun reducer
|
||||
// (it owns the into-running edge); this just renders the redux toast state and,
|
||||
// on View, jumps the canvas to the workflow, opening its live conversation if
|
||||
// it wasn't already on screen.
|
||||
|
||||
import React from 'react';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { store } from '@/shared/state/store';
|
||||
import { dismissRunningToast, openWorkflowCard, type OpenCard } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useOpenSidecar } from './WorkflowCardLiveViews';
|
||||
|
||||
export default function WorkflowRunningToast() {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const toast = useAppSelector((s) => s.workflows.runningToast);
|
||||
const openSidecar = useOpenSidecar(toast?.workflowId || '');
|
||||
|
||||
const onView = React.useCallback(() => {
|
||||
if (!toast) return;
|
||||
const { workflowId, runId } = toast;
|
||||
const st = store.getState();
|
||||
const alreadyOpen = Boolean(st.dashboardLayout.workflowCards[workflowId]);
|
||||
// addWorkflowCard pans the canvas to the card whether it already exists or
|
||||
// gets created here (both set pendingFocusWorkflowId for the lifecycle hook).
|
||||
dispatch(addWorkflowCard({ workflowId }));
|
||||
if (!alreadyOpen) {
|
||||
const run = st.workflows.runs[workflowId]?.find((r) => r.id === runId);
|
||||
const status = run?.status;
|
||||
const view: OpenCard['view'] = status === 'failure' ? 'failed'
|
||||
: (status === 'success' || status === 'ran_late') ? 'completed' : 'running';
|
||||
dispatch(openWorkflowCard({ workflowId, view, runId }));
|
||||
if (run?.session_id) {
|
||||
const kind = status === 'failure' ? 'viewing-error'
|
||||
: (status === 'success' || status === 'ran_late') ? 'viewing-completed' : 'watching';
|
||||
void openSidecar(run.session_id, kind);
|
||||
}
|
||||
}
|
||||
dispatch(dismissRunningToast());
|
||||
}, [toast, dispatch, openSidecar]);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={Boolean(toast)}
|
||||
autoHideDuration={10000}
|
||||
onClose={(_, reason) => { if (reason !== 'clickaway') dispatch(dismissRunningToast()); }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert
|
||||
icon={false}
|
||||
severity="info"
|
||||
onClose={() => dispatch(dismissRunningToast())}
|
||||
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={onView} sx={{ color: c.accent.primary, fontWeight: 700 }}>
|
||||
View
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{toast ? `${toast.workflowTitle} is running now` : ''}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -162,14 +162,10 @@ export interface OpenCard {
|
||||
fixSeed?: { runId: string; stepIdx: number; stepLabel: string; error: string } | null;
|
||||
}
|
||||
|
||||
// Transient ping for the global "a scheduled run just started" toast. nonce
|
||||
// bumps on every fresh scheduled start so the toast re-fires even when the
|
||||
// same workflow runs again. Cleared once the toast finishes fading out.
|
||||
interface RunStartSignal {
|
||||
export interface RunningToast {
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
title: string;
|
||||
nonce: number;
|
||||
workflowTitle: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -183,11 +179,11 @@ interface State {
|
||||
cloudSmsEnabled: boolean;
|
||||
allRuns: WorkflowRun[];
|
||||
allRunsLoading: boolean;
|
||||
runStartSignal: RunStartSignal | null;
|
||||
runningToast: RunningToast | null;
|
||||
runControlPending: Record<string, WorkflowRunControlAction>;
|
||||
}
|
||||
|
||||
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runStartSignal: null, runControlPending: {} };
|
||||
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null, runControlPending: {} };
|
||||
|
||||
function mergeRunIntoState(state: State, r: WorkflowRun) {
|
||||
const arr = state.runs[r.workflow_id] || [];
|
||||
@@ -219,19 +215,15 @@ function mergeRunIntoState(state: State, r: WorkflowRun) {
|
||||
// Only nudge from views that the user hasn't actively navigated away
|
||||
// from (saved / running). Edit, history, scheduling etc. stay put.
|
||||
const card = state.openCards[r.workflow_id];
|
||||
// Ping the global toast when an unattended scheduled run begins, so the
|
||||
// user notices even when they aren't looking at the card. Fire once per
|
||||
// run (first running event has no prior, later step bumps do), skip if
|
||||
// they're already watching the card live, and skip manual runs they
|
||||
// just clicked themselves.
|
||||
const watching = !!card && (card.view === 'running' || card.view === 'saved');
|
||||
const startedFresh = (!prev || prev.status !== 'running') && r.status === 'running';
|
||||
if (startedFresh && r.triggered_by === 'schedule' && !watching) {
|
||||
state.runStartSignal = {
|
||||
// A scheduled run flipping into 'running' fired unattended, so nudge the
|
||||
// user with a clickable toast. Only on the into-running edge (not every
|
||||
// tool-label/step bump), and only for schedule (manual runs they kicked
|
||||
// off themselves don't need a "surprise, it's running" popup).
|
||||
if (r.status === 'running' && r.triggered_by === 'schedule' && (!prev || prev.status !== 'running')) {
|
||||
state.runningToast = {
|
||||
workflowId: r.workflow_id,
|
||||
runId: r.id,
|
||||
title: state.items[r.workflow_id]?.title || 'Workflow',
|
||||
nonce: (state.runStartSignal?.nonce ?? 0) + 1,
|
||||
workflowTitle: state.items[r.workflow_id]?.title || 'Workflow',
|
||||
};
|
||||
}
|
||||
if (card) {
|
||||
@@ -473,8 +465,8 @@ const slice = createSlice({
|
||||
delete state.runs[action.payload];
|
||||
state.allRuns = state.allRuns.filter((r) => r.workflow_id !== action.payload);
|
||||
},
|
||||
clearRunStartSignal(state) {
|
||||
state.runStartSignal = null;
|
||||
dismissRunningToast(state) {
|
||||
state.runningToast = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
@@ -561,6 +553,6 @@ export const {
|
||||
clearFixSeed,
|
||||
upsertWorkflow,
|
||||
removeWorkflow,
|
||||
clearRunStartSignal,
|
||||
dismissRunningToast,
|
||||
} = slice.actions;
|
||||
export default slice.reducer;
|
||||
|
||||
Reference in New Issue
Block a user