From 7814baf2c3bb4e23cf1bac8a788354edb829746c Mon Sep 17 00:00:00 2001 From: abccodes Date: Thu, 18 Jun 2026 20:54:04 -0700 Subject: [PATCH] [aidan] feat/workflows: add scheduled-run "running now" toast with click-to-view --- frontend/src/app/Main.tsx | 2 +- .../Dashboard/canvas/DashboardOverlays.tsx | 4 + .../pages/Workflows/WorkflowCardLiveViews.tsx | 7 +- .../pages/Workflows/WorkflowRunningToast.tsx | 74 +++++++++++++++++++ frontend/src/shared/state/workflowsSlice.ts | 24 +++++- 5 files changed, 105 insertions(+), 6 deletions(-) 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 28e564f4..97f55ed6 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -332,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' }} > = ({ 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 7fa2a708..2ed7bf52 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 5a3d8a8d..9da866ba 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -151,6 +151,12 @@ export interface OpenCard { fixSeed?: { runId: string; stepIdx: number; stepLabel: string; error: string } | null; } +export interface RunningToast { + workflowId: string; + runId: string; + workflowTitle: string; +} + interface State { items: Record; runs: Record; @@ -162,9 +168,10 @@ interface State { cloudSmsEnabled: boolean; allRuns: WorkflowRun[]; allRunsLoading: boolean; + runningToast: RunningToast | null; } -const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false }; +const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null }; export const fetchWorkflows = createAsyncThunk( 'workflows/fetch', @@ -370,6 +377,20 @@ const slice = createSlice({ else if (r.status === 'success' || r.status === 'ran_late') card.sidecarKind = 'viewing-completed'; } } + // 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, + workflowTitle: state.items[r.workflow_id]?.title || 'Workflow', + }; + } + }, + dismissRunningToast(state) { + state.runningToast = null; }, toggleExpandedStep(state, action: { payload: { workflowId: string; stepId: string } }) { const card = state.openCards[action.payload.workflowId]; @@ -447,5 +468,6 @@ export const { clearFixSeed, upsertWorkflow, removeWorkflow, + dismissRunningToast, } = slice.actions; export default slice.reducer;