diff --git a/backend/apps/workflows/storage.py b/backend/apps/workflows/storage.py index 86d80352..96204c2c 100644 --- a/backend/apps/workflows/storage.py +++ b/backend/apps/workflows/storage.py @@ -147,6 +147,16 @@ def list_runs(wid: str, limit: int = 50) -> list[WorkflowRun]: return runs[-limit:][::-1] +def list_all_runs(limit: int = 200) -> list[WorkflowRun]: + if not _cache_loaded: + init() + flat: list[WorkflowRun] = [] + for arr in _runs_cache.values(): + flat.extend(arr) + flat.sort(key=lambda r: r.started_at, reverse=True) + return flat[:limit] + + def record_run(run: WorkflowRun) -> WorkflowRun: with _io_lock: _ensure_dirs() diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 43c29670..48729d99 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -499,6 +499,14 @@ async def get_run_escalation(run_id: str): return {"state": state} +@workflows.router.get("/runs/all") +async def list_all_runs(limit: int = 200): + """Flat, newest-first log of every workflow run across all workflows. + Backs the dashboard History popover's Scheduled tasks tab.""" + runs = storage.list_all_runs(limit=limit) + return {"runs": [r.model_dump(mode="json") for r in runs]} + + @workflows.router.get("/{workflow_id}") async def get_workflow(workflow_id: str): wf = storage.get_workflow(workflow_id) diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 8b163341..ed5d2e16 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -28,7 +28,7 @@ import { motion } from 'framer-motion'; import ChatInput from '@/app/pages/AgentChat/ChatInput'; import type { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import SchedulePopover from '@/app/pages/Workflows/SchedulePopover'; -import { openWorkflowCard } from '@/shared/state/workflowsSlice'; +import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice'; import { addWorkflowCard, openWorkflowsHub } from '@/shared/state/dashboardLayoutSlice'; import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -182,10 +182,13 @@ const DashboardToolbar = React.forwardRef( const [viewSearch, setViewSearch] = useState(''); const [historyOpen, setHistoryOpen] = useState(false); const [historyQuery, setHistoryQuery] = useState(''); - const [popoverMode, setPopoverMode] = useState<'search' | 'schedule'>('search'); + const [popoverMode, setPopoverMode] = useState<'search' | 'runs' | 'schedule'>('search'); const shortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut); const outputs = useAppSelector((s) => s.outputs.items); const historySearch = useAppSelector((s) => s.agents.historySearch); + const allRuns = useAppSelector((s) => s.workflows.allRuns); + const allRunsLoading = useAppSelector((s) => s.workflows.allRunsLoading); + const workflowItems = useAppSelector((s) => s.workflows.items); const outputList = useMemo(() => Object.values(outputs), [outputs]); const filteredOutputs = useMemo(() => { @@ -259,9 +262,9 @@ const DashboardToolbar = React.forwardRef( setViewSearch(''); }, [viewPickerOpen, dispatch]); - // Opens the schedule CALENDAR (scheduled workflows on a calendar). Chat - // search/resume lives in the global search palette (the OpenSwarm center - // at the top), not here, so this no longer dispatches a history search. + // Opens the History popover on Chat history, with a tab to the Scheduled + // tasks run log. The calendar is a separate destination reached via the + // Schedule pill, never from here. const handleOpenHistory = useCallback(() => { if (historyOpen) { setHistoryOpen(false); @@ -269,7 +272,7 @@ const DashboardToolbar = React.forwardRef( } setViewPickerOpen(false); setViewSearch(''); - setPopoverMode('schedule'); + setPopoverMode('search'); setHistoryOpen(true); }, [historyOpen]); @@ -403,6 +406,12 @@ const DashboardToolbar = React.forwardRef( return () => clearTimeout(timer); }, [historyQuery, historyOpen, dispatch, dashboardId]); + useEffect(() => { + if (historyOpen && popoverMode === 'runs') { + dispatch(fetchAllRuns(200)); + } + }, [historyOpen, popoverMode, dispatch]); + const handleHistoryScroll = useCallback(() => { const el = historyListRef.current; if (!el) return; @@ -539,6 +548,21 @@ const DashboardToolbar = React.forwardRef( dispatch(openWorkflowsHub({ expandedSessionIds: [] })); handleCloseHistory(); }} + allRuns={allRuns} + allRunsLoading={allRunsLoading} + workflowTitleFor={(wid) => workflowItems[wid]?.title || 'Workflow'} + onRunOpen={(run) => { + // Splice the clicked run in first so HistoryDetail finds it + // before fetchRuns resolves; avoids a "Run not found" flash. + dispatch(upsertRun(run)); + dispatch(addWorkflowCard({ workflowId: run.workflow_id })); + dispatch(openWorkflowCard({ + workflowId: run.workflow_id, + view: 'history_detail', + historyRunId: run.id, + })); + handleCloseHistory(); + }} historyScrollRef={historyListRef as React.RefObject} onHistoryScroll={handleHistoryScroll} /> diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx index 350b7f3a..b0cd2fa7 100644 --- a/frontend/src/app/pages/Workflows/SchedulePopover.tsx +++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx @@ -14,10 +14,12 @@ import AddIcon from '@mui/icons-material/Add'; import { AnimatePresence, motion } from 'framer-motion'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppSelector } from '@/shared/hooks'; +import type { WorkflowRun } from '@/shared/state/workflowsSlice'; import ScheduleCalendar from './ScheduleCalendar'; +import { HistoryList } from './WorkflowCardSubviews'; import { addDays, startOfWeek } from './scheduleUtils'; -type Mode = 'search' | 'schedule'; +type Mode = 'search' | 'runs' | 'schedule'; interface Props { mode: Mode; @@ -30,6 +32,10 @@ interface Props { onNewChat: () => void; onWorkflowSelect: (id: string) => void; onExpand: () => void; + allRuns: WorkflowRun[]; + allRunsLoading: boolean; + onRunOpen: (run: WorkflowRun) => void; + workflowTitleFor: (workflowId: string) => string; historyScrollRef?: React.RefObject; onHistoryScroll?: () => void; /** When true, hides the internal Search/Schedule chips + redundant "+ New" @@ -39,7 +45,9 @@ interface Props { export default function SchedulePopover({ mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange, - onHistorySelect, onNewChat, onWorkflowSelect, onExpand, historyScrollRef, onHistoryScroll, + onHistorySelect, onNewChat, onWorkflowSelect, onExpand, + allRuns, allRunsLoading, onRunOpen, workflowTitleFor, + historyScrollRef, onHistoryScroll, hideTopChrome = false, }: Props) { const c = useClaudeTokens(); @@ -114,6 +122,17 @@ export default function SchedulePopover({ flexDirection: 'column', position: 'relative', }}> + {/* History tabs. Hidden in schedule mode so the Schedule pill's + calendar stays untouched; toggles only Chat history <-> runs and + never reaches the calendar from here. */} + {mode !== 'schedule' && ( + + {([['search', 'Chat history'], ['runs', 'Scheduled tasks']] as const).map(([m, label]) => ( + onModeChange(m)} role="button" sx={{ fontSize: '0.85rem', fontWeight: mode === m ? 700 : 500, px: 0.75, pt: 0.4, pb: 0.55, color: mode === m ? c.text.primary : c.text.muted, borderBottom: `2px solid ${mode === m ? c.accent.primary : 'transparent'}`, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>{label} + ))} + + )} + )} + {mode === 'runs' && ( + + + {allRunsLoading && allRuns.length === 0 ? ( + Loading runs... + ) : ( + + )} + + + )} + {mode === 'schedule' && ( @@ -202,6 +233,7 @@ export default function SchedulePopover({ )} + ); diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 8bb86a4a..1e65c877 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -641,7 +641,7 @@ function groupKey(iso: string): string { } catch { return 'Earlier'; } } -export function HistoryList({ runs, onOpen }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void }) { +export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleFor }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void; showWorkflow?: boolean; workflowTitleFor?: (workflowId: string) => string }) { const c = useClaudeTokens(); const [expandedId, setExpandedId] = useState(null); // Filter chips: all / failures / late. Power-users debugging a flaky @@ -705,7 +705,14 @@ export function HistoryList({ runs, onOpen }: { runs: WorkflowRun[]; onOpen: (r: {labelForStatus(r.status)} - {formatRunDate(r.started_at)} + {showWorkflow && workflowTitleFor ? ( + + {workflowTitleFor(r.workflow_id)} + {formatRunDate(r.started_at)} + + ) : ( + {formatRunDate(r.started_at)} + )} {dur && {dur}} {r.cost_usd > 0 && ${r.cost_usd.toFixed(4)}} {/* Chevron makes the row read as expandable instead of diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index a775a498..79dcddee 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -160,9 +160,11 @@ interface State { paused: boolean; active: ActiveRun[]; cloudSmsEnabled: boolean; + allRuns: WorkflowRun[]; + allRunsLoading: boolean; } -const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false }; +const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false }; export const fetchWorkflows = createAsyncThunk( 'workflows/fetch', @@ -261,6 +263,15 @@ export const fetchRuns = createAsyncThunk( }, ); +export const fetchAllRuns = createAsyncThunk( + 'workflows/allRuns', + async (limit: number = 200) => { + const res = await fetch(`${API}/runs/all?limit=${limit}`); + const data = await res.json(); + return data.runs as WorkflowRun[]; + }, +); + export const fetchPausedState = createAsyncThunk('workflows/paused', async () => { const res = await fetch(`${API}/paused`); const data = await res.json(); @@ -323,6 +334,11 @@ const slice = createSlice({ const prev = idx >= 0 ? arr[idx] : null; if (idx >= 0) arr[idx] = r; else arr.unshift(r); state.runs[r.workflow_id] = arr.slice(0, 100); + // Keep the cross-workflow log (Scheduled tasks history tab) live without a refetch. + const aIdx = state.allRuns.findIndex((x) => x.id === r.id); + if (aIdx >= 0) state.allRuns[aIdx] = r; else state.allRuns.unshift(r); + state.allRuns.sort((a, b) => (a.started_at < b.started_at ? 1 : -1)); + state.allRuns = state.allRuns.slice(0, 200); const wf = state.items[r.workflow_id]; if (wf) { wf.last_run_at = r.finished_at || r.started_at; @@ -375,6 +391,7 @@ const slice = createSlice({ removeWorkflow(state, action: { payload: string }) { delete state.items[action.payload]; delete state.runs[action.payload]; + state.allRuns = state.allRuns.filter((r) => r.workflow_id !== action.payload); }, }, extraReducers: (builder) => { @@ -394,10 +411,17 @@ const slice = createSlice({ .addCase(deleteWorkflow.fulfilled, (state, action) => { delete state.items[action.payload]; delete state.runs[action.payload]; + state.allRuns = state.allRuns.filter((r) => r.workflow_id !== action.payload); }) .addCase(fetchRuns.fulfilled, (state, action) => { state.runs[action.payload.id] = action.payload.runs; }) + .addCase(fetchAllRuns.pending, (state) => { state.allRunsLoading = true; }) + .addCase(fetchAllRuns.fulfilled, (state, action) => { + state.allRunsLoading = false; + state.allRuns = action.payload; + }) + .addCase(fetchAllRuns.rejected, (state) => { state.allRunsLoading = false; }) .addCase(fetchPausedState.fulfilled, (state, action) => { state.paused = action.payload; }) .addCase(setPausedAll.fulfilled, (state, action) => { state.paused = action.payload; }) .addCase(fetchActiveRuns.fulfilled, (state, action) => { state.active = action.payload; })