From c31748ca5301d0115320c6ff2368b5904098b03e Mon Sep 17 00:00:00 2001 From: Aidan Date: Thu, 18 Jun 2026 21:11:05 -0700 Subject: [PATCH] [aidan] feat/workflows: add scheduled-run "running now" toast with click-to-view (#97) --- frontend/src/app/Main.tsx | 4 +- .../components/feedback/ScheduledRunToast.tsx | 67 ----------------- .../Dashboard/canvas/DashboardOverlays.tsx | 4 + .../pages/Workflows/WorkflowCardLiveViews.tsx | 7 +- .../pages/Workflows/WorkflowRunningToast.tsx | 74 +++++++++++++++++++ frontend/src/shared/state/workflowsSlice.ts | 36 ++++----- 6 files changed, 96 insertions(+), 96 deletions(-) delete mode 100644 frontend/src/app/components/feedback/ScheduledRunToast.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowRunningToast.tsx diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index a2948153..97f55ed6 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -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' }} > { - diff --git a/frontend/src/app/components/feedback/ScheduledRunToast.tsx b/frontend/src/app/components/feedback/ScheduledRunToast.tsx deleted file mode 100644 index 58c2adc7..00000000 --- a/frontend/src/app/components/feedback/ScheduledRunToast.tsx +++ /dev/null @@ -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(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/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 2a54af0b..99a6135a 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -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 = ({ browserCards={browserCards} sessions={sessions} /> + + {/* Scheduled-run nudge: "your {workflow} is running now" + jump-to-canvas */} + ); }; diff --git a/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx index 86ef2d5a..74a7ce37 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx @@ -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'; diff --git a/frontend/src/app/pages/Workflows/WorkflowRunningToast.tsx b/frontend/src/app/pages/Workflows/WorkflowRunningToast.tsx new file mode 100644 index 00000000..202e157c --- /dev/null +++ b/frontend/src/app/pages/Workflows/WorkflowRunningToast.tsx @@ -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 ( + { if (reason !== 'clickaway') dispatch(dismissRunningToast()); }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + dispatch(dismissRunningToast())} + sx={{ + bgcolor: c.bg.surface, + color: c.text.primary, + border: `1px solid ${c.border.medium}`, + '& .MuiAlert-action': { alignItems: 'center', pt: 0 }, + }} + action={ + + } + > + {toast ? `${toast.workflowTitle} is running now` : ''} + + + ); +} diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 85005fe8..52d820bd 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -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; } -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;