From e97552b04edd09fea22a57968f7cdfa652361e21 Mon Sep 17 00:00:00 2001 From: Moltar Date: Fri, 17 Apr 2026 08:56:16 -0400 Subject: [PATCH 1/3] feat(monitor): add auto-refresh and non-idle activity filters --- web/src/pages/TaskManagerOverview.jsx | 127 ++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 8 deletions(-) diff --git a/web/src/pages/TaskManagerOverview.jsx b/web/src/pages/TaskManagerOverview.jsx index 0ed94cd..58d9916 100644 --- a/web/src/pages/TaskManagerOverview.jsx +++ b/web/src/pages/TaskManagerOverview.jsx @@ -21,6 +21,9 @@ import { useSchedulerStore } from '../stores/schedulerStore'; import { getCronJobs, getSchedulerStats } from '../api/client'; import logger from '../utils/logger'; import { classNames, formatTokens } from '../utils/helpers'; + +const MONITOR_REFRESH_INTERVAL_MS = 15000; + const SESSION_TYPES = [ { id: 'main', label: 'Agent' }, { id: 'subagent', label: 'Subagent' }, @@ -45,7 +48,10 @@ export default function TaskManagerOverview() { const [activeTab, setActiveTab] = useState('live'); const [filterTypes, setFilterTypes] = useState([]); const [filterAgents, setFilterAgents] = useState([]); + const [activityFilter, setActivityFilter] = useState('all'); // all | non-idle | running | active const [groupBy, setGroupBy] = useState('kind'); // 'agent', 'kind', or 'none' + const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true); + const refreshInFlightRef = useRef(false); // Deep-link support: ?sessionKey= auto-selects and opens the session detail panel const [searchParams, setSearchParams] = useSearchParams(); @@ -261,13 +267,24 @@ export default function TaskManagerOverview() { } }, [deepLinkSessionKey, sessions, sessionsLoaded, setSearchParams]); + const refreshOverview = useCallback(async () => { + if (refreshInFlightRef.current) return; + refreshInFlightRef.current = true; + + try { + await Promise.all([ + fetchSessions(), + loadRecentActivity(), + fetchTodaySummary(), + loadSchedulerStats(), + ]); + } finally { + refreshInFlightRef.current = false; + } + }, [fetchSessions, loadRecentActivity, fetchTodaySummary, loadSchedulerStats]); + const handleRefresh = async () => { - await Promise.all([ - fetchSessions(), - loadRecentActivity(), - fetchTodaySummary(), - loadSchedulerStats(), - ]); + await refreshOverview(); }; const handleSessionClick = useCallback((session) => { @@ -283,14 +300,39 @@ export default function TaskManagerOverview() { (session) => { const sessionKind = session.kind || 'main'; const sessionAgent = session.agent || session.agentId || null; + const sessionStatus = (session.status || 'idle').toLowerCase(); const typeMatch = filterTypes.length === 0 || filterTypes.includes(sessionKind); const agentMatch = filterAgents.length === 0 || (sessionAgent && filterAgents.includes(sessionAgent)); - return typeMatch && agentMatch; + const activityMatch = + activityFilter === 'all' + ? true + : activityFilter === 'non-idle' + ? sessionStatus !== 'idle' + : sessionStatus === activityFilter; + return typeMatch && agentMatch && activityMatch; }, - [filterTypes, filterAgents], + [filterTypes, filterAgents, activityFilter], ); + useEffect(() => { + if (!autoRefreshEnabled) return undefined; + + const runIfVisible = () => { + if (document.visibilityState === 'visible') { + refreshOverview(); + } + }; + + const interval = setInterval(runIfVisible, MONITOR_REFRESH_INTERVAL_MS); + document.addEventListener('visibilitychange', runIfVisible); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', runIfVisible); + }; + }, [autoRefreshEnabled, refreshOverview]); + // All live sessions (running + active + idle) passing current filters const liveSessions = useMemo(() => sessions.filter(passesFilters), [sessions, passesFilters]); const filteredRecentActivitySessions = useMemo( @@ -467,6 +509,59 @@ export default function TaskManagerOverview() { {/* Grouping toggle — pinned to the right on md+ */}
+ Activity +
+ + + + +
+ +
Group by
@@ -507,6 +602,22 @@ export default function TaskManagerOverview() { None
+ +
From c475fda760c4f0ee94131f1254d59042e38f22a2 Mon Sep 17 00:00:00 2001 From: Moltar Date: Fri, 17 Apr 2026 10:18:55 -0400 Subject: [PATCH 2/3] fix(workspace): add clipboard fallback for copy workspace path --- web/src/components/WorkspaceExplorer.jsx | 37 ++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/web/src/components/WorkspaceExplorer.jsx b/web/src/components/WorkspaceExplorer.jsx index bc67953..388170d 100644 --- a/web/src/components/WorkspaceExplorer.jsx +++ b/web/src/components/WorkspaceExplorer.jsx @@ -33,6 +33,7 @@ import { isAbsoluteWorkspacePath, } from '../utils/workspacePaths'; import { useAgentStore } from '../stores/agentStore'; +import logger from '../utils/logger'; /** * Normalize a URL path segment to a workspace path. @@ -734,14 +735,44 @@ export default function WorkspaceExplorer({ return stripped || '/'; }; + const copyTextToClipboard = async (text) => { + if (!text) return false; + + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + + // Fallback for non-secure contexts where navigator.clipboard is unavailable. + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + return ok; + }; + const handleCopyPath = async (file) => { - const workspaceRelativePath = toWorkspaceRelativePath(file?.path); + const sourcePath = file?.path || file?.fullPath; + const workspaceRelativePath = toWorkspaceRelativePath(sourcePath); if (!workspaceRelativePath) return; try { - await navigator.clipboard.writeText(workspaceRelativePath); + const copied = await copyTextToClipboard(workspaceRelativePath); + if (!copied) throw new Error('Copy command failed'); showToast('Workspace path copied to clipboard', 'success'); - } catch { + } catch (error) { + logger.warn('Workspace path copy failed', { + path: sourcePath, + secureContext: window.isSecureContext, + hasClipboardApi: !!navigator?.clipboard?.writeText, + error: error?.message, + }); showToast('Failed to copy workspace path', 'error'); } }; From 8fb893a4867cd22c74289477803a4a15850462f6 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Fri, 17 Apr 2026 10:24:39 -0400 Subject: [PATCH 3/3] fix: address monitor review feedback --- web/src/pages/TaskManagerOverview.jsx | 23 ++- web/src/pages/TaskManagerOverview.test.jsx | 163 +++++++++++++++++++++ 2 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 web/src/pages/TaskManagerOverview.test.jsx diff --git a/web/src/pages/TaskManagerOverview.jsx b/web/src/pages/TaskManagerOverview.jsx index 58d9916..b1c3a44 100644 --- a/web/src/pages/TaskManagerOverview.jsx +++ b/web/src/pages/TaskManagerOverview.jsx @@ -61,10 +61,21 @@ export default function TaskManagerOverview() { // Recent cron/heartbeat activity const [recentJobs, setRecentJobs] = useState([]); const [jobsLoaded, setJobsLoaded] = useState(false); + const isMountedRef = useRef(false); + + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + }; + }, []); const loadRecentActivity = useCallback(async () => { try { const jobs = await getCronJobs(); + // Auto-refresh can finish after navigation; ignore late local state writes. + if (!isMountedRef.current) return; // Filter to jobs that have actually run, sorted by lastRunAt descending const ranJobs = (jobs || []) .filter((j) => j.lastRunAt) @@ -326,6 +337,7 @@ export default function TaskManagerOverview() { const interval = setInterval(runIfVisible, MONITOR_REFRESH_INTERVAL_MS); document.addEventListener('visibilitychange', runIfVisible); + runIfVisible(); return () => { clearInterval(interval); @@ -343,6 +355,8 @@ export default function TaskManagerOverview() { const runningCount = liveSessions.filter((s) => s.status === 'running').length; const activeCount = liveSessions.filter((s) => s.status === 'active').length; const idleCount = liveSessions.filter((s) => s.status === 'idle').length; + const hasActiveFilters = + filterTypes.length > 0 || filterAgents.length > 0 || activityFilter !== 'all'; if (!sessionsLoaded && sessions.length === 0) { return ( @@ -489,7 +503,7 @@ export default function TaskManagerOverview() { {/* Clear — only visible when filters are active */} - {(filterTypes.length > 0 || filterAgents.length > 0) && ( + {hasActiveFilters && ( <>
+ + ), +})); + +vi.mock('../components/StatCard', () => ({ + default: ({ label, value }) => ( +
+ {label}: {value} +
+ ), +})); + +vi.mock('../components/SessionList', () => ({ + default: ({ sessions, title, emptyMessage }) => ( +
+ {sessions.length === 0 ? ( +

{emptyMessage}

+ ) : ( + sessions.map((session) =>
{session.label || session.id}
) + )} +
+ ), +})); + +vi.mock('../components/SessionDetailPanel', () => ({ + default: () => null, +})); + +describe('TaskManagerOverview', () => { + const mockFetchSessions = vi.fn(); + const mockFetchTodaySummary = vi.fn(); + const mockSetAttention = vi.fn(); + + const renderOverview = async () => { + let result; + + await act(async () => { + result = render( + + + , + ); + }); + + return result; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + + mockFetchSessions.mockResolvedValue([]); + mockFetchTodaySummary.mockResolvedValue(); + mockSetAttention.mockReturnValue(); + getCronJobs.mockResolvedValue([]); + getSchedulerStats.mockResolvedValue({ errors: 0, missed: 0 }); + + const botState = { + sessions: [ + { id: 'running-session', label: 'Running Session', status: 'running', kind: 'main' }, + { id: 'idle-session', label: 'Idle Session', status: 'idle', kind: 'main' }, + ], + sessionsLoaded: true, + sessionsError: null, + fetchSessions: mockFetchSessions, + }; + + const agentState = { + agents: [], + getAgentById: vi.fn(), + }; + + const usageState = { + todaySummary: { totalTokensInput: 0, totalTokensOutput: 0, totalCostUsd: 0 }, + fetchTodaySummary: mockFetchTodaySummary, + }; + + const schedulerState = { + setAttention: mockSetAttention, + }; + + useBotStore.mockImplementation((selector) => selector(botState)); + useAgentStore.mockImplementation((selector) => selector(agentState)); + useUsageStore.mockImplementation((selector) => selector(usageState)); + useSchedulerStore.mockImplementation((selector) => selector(schedulerState)); + }); + + it('runs an immediate visible refresh when auto-refresh is enabled', async () => { + await renderOverview(); + + await waitFor(() => { + expect(mockFetchSessions).toHaveBeenCalledTimes(2); + }); + expect(getCronJobs).toHaveBeenCalledTimes(2); + expect(mockFetchTodaySummary).toHaveBeenCalledTimes(2); + expect(getSchedulerStats).toHaveBeenCalledTimes(2); + }); + + it('clears the activity filter with the rest of the filters', async () => { + const user = userEvent.setup(); + getCronJobs.mockImplementation(() => new Promise(() => {})); + + await renderOverview(); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Running' })); + }); + + expect(screen.getByText('Running Session')).toBeInTheDocument(); + expect(screen.queryByText('Idle Session')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Clear/ })).toBeInTheDocument(); + + await act(async () => { + await user.click(screen.getByRole('button', { name: /Clear/ })); + }); + + expect(screen.getByText('Running Session')).toBeInTheDocument(); + expect(screen.getByText('Idle Session')).toBeInTheDocument(); + }); +});