[aidan] feat/history-popover: add chat history and scheduled tasks run log tabs

This commit is contained in:
abccodes
2026-06-17 23:21:21 -07:00
parent 72dd5208a6
commit 10bbe3dbea
6 changed files with 116 additions and 11 deletions
+10
View File
@@ -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()
+8
View File
@@ -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)
@@ -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<HTMLDivElement, Props>(
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<HTMLDivElement, Props>(
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<HTMLDivElement, Props>(
}
setViewPickerOpen(false);
setViewSearch('');
setPopoverMode('schedule');
setPopoverMode('search');
setHistoryOpen(true);
}, [historyOpen]);
@@ -403,6 +406,12 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
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<HTMLDivElement, Props>(
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<HTMLDivElement>}
onHistoryScroll={handleHistoryScroll}
/>
@@ -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<HTMLDivElement>;
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' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, pt: 1, pb: 0.5, flexShrink: 0 }}>
{([['search', 'Chat history'], ['runs', 'Scheduled tasks']] as const).map(([m, label]) => (
<Box key={m} onClick={() => 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}</Box>
))}
</Box>
)}
<Box sx={{ flex: 1, position: 'relative', minHeight: 0 }}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={mode}
@@ -166,6 +185,18 @@ export default function SchedulePopover({
</Box>
)}
{mode === 'runs' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, py: 1, borderTop: `1px solid ${c.border.subtle}`, minHeight: 0 }}>
{allRunsLoading && allRuns.length === 0 ? (
<Typography sx={{ px: 0.5, py: 2.5, fontSize: '0.82rem', color: c.text.muted, textAlign: 'center' }}>Loading runs...</Typography>
) : (
<HistoryList runs={allRuns} onOpen={onRunOpen} showWorkflow workflowTitleFor={workflowTitleFor} />
)}
</Box>
</Box>
)}
{mode === 'schedule' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, pt: 1, pb: 0.5, flexShrink: 0 }}>
@@ -202,6 +233,7 @@ export default function SchedulePopover({
)}
</motion.div>
</AnimatePresence>
</Box>
</Box>
</Box>
);
@@ -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<string | null>(null);
// Filter chips: all / failures / late. Power-users debugging a flaky
@@ -705,7 +705,14 @@ export function HistoryList({ runs, onOpen }: { runs: WorkflowRun[]; onOpen: (r:
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: 0.75, minWidth: 64, textAlign: 'center' }}>
{labelForStatus(r.status)}
</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, flex: 1 }}>{formatRunDate(r.started_at)}</Typography>
{showWorkflow && workflowTitleFor ? (
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{workflowTitleFor(r.workflow_id)}</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost }}>{formatRunDate(r.started_at)}</Typography>
</Box>
) : (
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, flex: 1 }}>{formatRunDate(r.started_at)}</Typography>
)}
{dur && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>{dur}</Typography>}
{r.cost_usd > 0 && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>${r.cost_usd.toFixed(4)}</Typography>}
{/* Chevron makes the row read as expandable instead of
+25 -1
View File
@@ -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; })