mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 11:17:44 +02:00
[aidan] feat/history-popover: add chat history and scheduled tasks run log tabs
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; })
|
||||
|
||||
Reference in New Issue
Block a user