Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions web/src/components/WorkspaceExplorer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
}
};
Expand Down
150 changes: 137 additions & 13 deletions web/src/pages/TaskManagerOverview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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=<key> auto-selects and opens the session detail panel
const [searchParams, setSearchParams] = useSearchParams();
Expand All @@ -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)
Expand Down Expand Up @@ -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(),
Comment thread
kalinon marked this conversation as resolved.
]);
} finally {
refreshInFlightRef.current = false;
}
}, [fetchSessions, loadRecentActivity, fetchTodaySummary, loadSchedulerStats]);

const handleRefresh = async () => {
await Promise.all([
fetchSessions(),
loadRecentActivity(),
fetchTodaySummary(),
loadSchedulerStats(),
]);
await refreshOverview();
};

const handleSessionClick = useCallback((session) => {
Expand All @@ -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);
Comment thread
kalinon marked this conversation as resolved.
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(
Expand All @@ -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 (
Expand Down Expand Up @@ -447,14 +503,15 @@ export default function TaskManagerOverview() {
</div>

{/* Clear — only visible when filters are active */}
{(filterTypes.length > 0 || filterAgents.length > 0) && (
{hasActiveFilters && (
<>
<div className="hidden md:block w-px h-6 bg-dark-600 flex-shrink-0" aria-hidden />
<button
type="button"
onClick={() => {
setFilterTypes([]);
setFilterAgents([]);
setActivityFilter('all');
}}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-sm font-medium text-dark-400 hover:text-dark-200 transition-colors rounded-lg hover:bg-dark-700 flex-shrink-0"
>
Expand All @@ -467,6 +524,59 @@ export default function TaskManagerOverview() {

{/* Grouping toggle — pinned to the right on md+ */}
<div className="flex items-center gap-2 md:ml-auto">
<span className="text-xs font-medium text-dark-500 flex-shrink-0">Activity</span>
<div className="flex items-center gap-1 bg-dark-700 rounded-lg p-0.5">
<button
type="button"
onClick={() => setActivityFilter('all')}
className={classNames(
'px-2 py-1 text-xs font-medium rounded transition-colors',
activityFilter === 'all'
? 'bg-primary-600 text-white'
: 'text-dark-400 hover:text-dark-200',
Comment thread
kalinon marked this conversation as resolved.
)}
>
All
</button>
<button
type="button"
onClick={() => setActivityFilter('non-idle')}
className={classNames(
'px-2 py-1 text-xs font-medium rounded transition-colors',
activityFilter === 'non-idle'
? 'bg-primary-600 text-white'
: 'text-dark-400 hover:text-dark-200',
)}
>
Non-idle
</button>
<button
type="button"
onClick={() => setActivityFilter('running')}
className={classNames(
'px-2 py-1 text-xs font-medium rounded transition-colors',
activityFilter === 'running'
? 'bg-primary-600 text-white'
: 'text-dark-400 hover:text-dark-200',
)}
>
Running
</button>
<button
type="button"
onClick={() => setActivityFilter('active')}
className={classNames(
'px-2 py-1 text-xs font-medium rounded transition-colors',
activityFilter === 'active'
? 'bg-primary-600 text-white'
: 'text-dark-400 hover:text-dark-200',
)}
>
Active
</button>
</div>

<div className="hidden md:block w-px h-6 bg-dark-600 flex-shrink-0" aria-hidden />
<Squares2X2Icon className="w-4 h-4 text-dark-500 flex-shrink-0" aria-hidden />
<span className="text-xs font-medium text-dark-500 flex-shrink-0">Group by</span>
<div className="flex items-center gap-1 bg-dark-700 rounded-lg p-0.5">
Expand Down Expand Up @@ -507,6 +617,22 @@ export default function TaskManagerOverview() {
None
</button>
</div>

<button
type="button"
onClick={() => setAutoRefreshEnabled((v) => !v)}
className={classNames(
'px-2.5 py-1.5 text-xs font-medium rounded-lg border transition-colors',
autoRefreshEnabled
? 'bg-emerald-600/20 text-emerald-300 border-emerald-500/40 hover:bg-emerald-600/30'
: 'bg-dark-700 text-dark-400 border-dark-600 hover:text-dark-200 hover:border-dark-500',
)}
title={`Auto-refresh ${autoRefreshEnabled ? 'enabled' : 'disabled'} (${Math.round(
MONITOR_REFRESH_INTERVAL_MS / 1000,
)}s)`}
>
Auto {autoRefreshEnabled ? 'On' : 'Off'}
</button>
</div>
</div>
</div>
Expand Down Expand Up @@ -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}
Expand All @@ -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'
}
Expand Down
Loading
Loading