[aidan] feat/workflows: add scheduled-run "running now" toast with click-to-view

This commit is contained in:
abccodes
2026-06-18 20:54:04 -07:00
parent 5648db5b77
commit 7814baf2c3
5 changed files with 105 additions and 6 deletions
+1 -1
View File
@@ -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' }}
>
<Alert
severity="warning"
@@ -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>
);
}
+23 -1
View File
@@ -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<string, Workflow>;
runs: Record<string, WorkflowRun[]>;
@@ -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;