diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 28e564f4..a2948153 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -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 = () => { + diff --git a/frontend/src/app/components/feedback/ScheduledRunToast.tsx b/frontend/src/app/components/feedback/ScheduledRunToast.tsx new file mode 100644 index 00000000..58c2adc7 --- /dev/null +++ b/frontend/src/app/components/feedback/ScheduledRunToast.tsx @@ -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(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 ( + dispatch(clearRunStartSignal())}> + + + + + + {display.title} started running + + Open + + + + ); +} diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index ca588700..360a2c76 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -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; runs: Record; @@ -167,10 +177,11 @@ interface State { cloudSmsEnabled: boolean; allRuns: WorkflowRun[]; allRunsLoading: boolean; + runStartSignal: RunStartSignal | null; runControlPending: Record; } -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;