[aidan] feat/workflows: toast when an unattended scheduled run starts

This commit is contained in:
abccodes
2026-06-18 04:12:00 -07:00
parent bf00ff2ee0
commit 5350f3e6dc
3 changed files with 100 additions and 1 deletions
+2
View File
@@ -23,6 +23,7 @@ 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'));
@@ -469,6 +470,7 @@ const ThemedApp: React.FC = () => {
<DefaultModelGuard>
<UpdateListener>
<CrashRecoveryChip />
<ScheduledRunToast />
<ImportEntryPoint />
<DeepLinkListener>
<ErrorBoundary scope="routes">
@@ -0,0 +1,67 @@
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>
);
}
+31 -1
View File
@@ -156,6 +156,16 @@ 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 {
workflowId: string;
runId: string;
title: string;
nonce: number;
}
interface State {
items: Record<string, Workflow>;
runs: Record<string, WorkflowRun[]>;
@@ -167,10 +177,11 @@ interface State {
cloudSmsEnabled: boolean;
allRuns: WorkflowRun[];
allRunsLoading: boolean;
runStartSignal: RunStartSignal | null;
runControlPending: Record<string, WorkflowRunControlAction>;
}
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runControlPending: {} };
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runStartSignal: null, runControlPending: {} };
function mergeRunIntoState(state: State, r: WorkflowRun) {
const arr = state.runs[r.workflow_id] || [];
@@ -202,6 +213,21 @@ 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 = {
workflowId: r.workflow_id,
runId: r.id,
title: state.items[r.workflow_id]?.title || 'Workflow',
nonce: (state.runStartSignal?.nonce ?? 0) + 1,
};
}
if (card) {
const fromRunnable = card.view === 'saved' || card.view === 'running';
if (r.status === 'running' && fromRunnable) {
@@ -431,6 +457,9 @@ const slice = createSlice({
delete state.runs[action.payload];
state.allRuns = state.allRuns.filter((r) => r.workflow_id !== action.payload);
},
clearRunStartSignal(state) {
state.runStartSignal = null;
},
},
extraReducers: (builder) => {
builder
@@ -516,5 +545,6 @@ export const {
clearFixSeed,
upsertWorkflow,
removeWorkflow,
clearRunStartSignal,
} = slice.actions;
export default slice.reducer;