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'); } }; diff --git a/web/src/pages/TaskManagerOverview.jsx b/web/src/pages/TaskManagerOverview.jsx index 0ed94cd..b1c3a44 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(); @@ -55,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) @@ -261,13 +278,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 +311,40 @@ 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); + 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( @@ -301,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 ( @@ -447,7 +503,7 @@ export default function TaskManagerOverview() { {/* Clear — only visible when filters are active */} - {(filterTypes.length > 0 || filterAgents.length > 0) && ( + {hasActiveFilters && ( <>
+ + + +
+ +
Group by
@@ -507,6 +617,22 @@ export default function TaskManagerOverview() { None
+ +
@@ -551,9 +677,7 @@ export default function TaskManagerOverview() { sessions={liveSessions} title="Sessions" emptyMessage={ - filterTypes.length > 0 || filterAgents.length > 0 - ? 'No sessions match the current filters' - : 'No sessions' + hasActiveFilters ? 'No sessions match the current filters' : 'No sessions' } onSessionClick={handleSessionClick} groupBy={groupBy} @@ -574,7 +698,7 @@ export default function TaskManagerOverview() { sessions={filteredRecentActivitySessions} title="Recent Activity" emptyMessage={ - filterTypes.length > 0 || filterAgents.length > 0 + hasActiveFilters ? 'No recent activity matches the current filters' : 'No recent cron or heartbeat activity' } diff --git a/web/src/pages/TaskManagerOverview.test.jsx b/web/src/pages/TaskManagerOverview.test.jsx new file mode 100644 index 0000000..61b2535 --- /dev/null +++ b/web/src/pages/TaskManagerOverview.test.jsx @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import TaskManagerOverview from './TaskManagerOverview'; +import { getCronJobs, getSchedulerStats } from '../api/client'; +import { useBotStore } from '../stores/botStore'; +import { useAgentStore } from '../stores/agentStore'; +import { useUsageStore } from '../stores/usageStore'; +import { useSchedulerStore } from '../stores/schedulerStore'; + +vi.mock('../api/client', () => ({ + getCronJobs: vi.fn(), + getSchedulerStats: vi.fn(), +})); + +vi.mock('../stores/botStore', () => ({ + useBotStore: vi.fn(), +})); + +vi.mock('../stores/agentStore', () => ({ + useAgentStore: vi.fn(), +})); + +vi.mock('../stores/usageStore', () => ({ + useUsageStore: vi.fn(), +})); + +vi.mock('../stores/schedulerStore', () => ({ + useSchedulerStore: vi.fn(), +})); + +vi.mock('../components/Header', () => ({ + default: ({ title, onRefresh }) => ( +
+

{title}

+ +
+ ), +})); + +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(); + }); +});