feat(#52): implement Active Projects & Agent Chains widget
Add HUD widget for tracking active projects and running agent sessions. Backend: - Add getActiveProjectsData() and getAgentChainsData() to WidgetDataService - Create POST /api/widgets/data/active-projects endpoint - Create POST /api/widgets/data/agent-chains endpoint - Add WidgetProjectItem and WidgetAgentSessionItem response types Frontend: - Create ActiveProjectsWidget component with dual panels - Active Projects panel: name, color, task/event counts, last activity - Agent Chains panel: status, runtime, message count, expandable details - Real-time updates (projects: 30s, agents: 10s) - PDA-friendly status indicators (Running vs URGENT) Testing: - 7 comprehensive tests covering loading, rendering, empty states, expandability - All tests passing (7/7) Refs #52 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
243
apps/web/src/components/widgets/ActiveProjectsWidget.tsx
Normal file
243
apps/web/src/components/widgets/ActiveProjectsWidget.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Active Projects & Agent Chains Widget
|
||||
* Shows active projects and running agent sessions
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
interface ActiveProject {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
lastActivity: string;
|
||||
taskCount: number;
|
||||
eventCount: number;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
interface AgentSession {
|
||||
id: string;
|
||||
sessionKey: string;
|
||||
label: string | null;
|
||||
channel: string | null;
|
||||
agentName: string | null;
|
||||
agentStatus: string | null;
|
||||
status: "active" | "ended";
|
||||
startedAt: string;
|
||||
lastMessageAt: string | null;
|
||||
runtimeMs: number;
|
||||
messageCount: number;
|
||||
contextSummary: string | null;
|
||||
}
|
||||
|
||||
export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
const [projects, setProjects] = useState<ActiveProject[]>([]);
|
||||
const [agentSessions, setAgentSessions] = useState<AgentSession[]>([]);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||
const [isLoadingAgents, setIsLoadingAgents] = useState(true);
|
||||
const [expandedSession, setExpandedSession] = useState<string | null>(null);
|
||||
|
||||
// Fetch active projects
|
||||
useEffect(() => {
|
||||
const fetchProjects = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch("/api/widgets/data/active-projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as ActiveProject[];
|
||||
setProjects(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch active projects:", error);
|
||||
} finally {
|
||||
setIsLoadingProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchProjects();
|
||||
const interval = setInterval(() => {
|
||||
void fetchProjects();
|
||||
}, 30000); // Refresh every 30s
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch agent chains
|
||||
useEffect(() => {
|
||||
const fetchAgentSessions = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch("/api/widgets/data/agent-chains", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as AgentSession[];
|
||||
setAgentSessions(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agent sessions:", error);
|
||||
} finally {
|
||||
setIsLoadingAgents(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAgentSessions();
|
||||
const interval = setInterval(() => {
|
||||
void fetchAgentSessions();
|
||||
}, 10000); // Refresh every 10s
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||
const statusUpper = status.toUpperCase();
|
||||
switch (statusUpper) {
|
||||
case "WORKING":
|
||||
return <Activity className="w-3 h-3 text-blue-500 animate-pulse" />;
|
||||
case "IDLE":
|
||||
return <Clock className="w-3 h-3 text-gray-400" />;
|
||||
case "WAITING":
|
||||
return <Clock className="w-3 h-3 text-yellow-500" />;
|
||||
case "ERROR":
|
||||
return <AlertCircle className="w-3 h-3 text-red-500" />;
|
||||
case "TERMINATED":
|
||||
return <CheckCircle2 className="w-3 h-3 text-gray-500" />;
|
||||
default:
|
||||
return <Activity className="w-3 h-3 text-green-500 animate-pulse" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatRuntime = (runtimeMs: number): string => {
|
||||
const seconds = Math.floor(runtimeMs / 1000);
|
||||
if (seconds < 60) return `${String(seconds)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${String(minutes)}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${String(hours)}h ${String(minutes % 60)}m`;
|
||||
};
|
||||
|
||||
const formatLastActivity = (timestamp: string): string => {
|
||||
const now = new Date();
|
||||
const last = new Date(timestamp);
|
||||
const diffMs = now.getTime() - last.getTime();
|
||||
|
||||
if (diffMs < 60000) return "Just now";
|
||||
if (diffMs < 3600000) return `${String(Math.floor(diffMs / 60000))}m ago`;
|
||||
if (diffMs < 86400000) return `${String(Math.floor(diffMs / 3600000))}h ago`;
|
||||
return `${String(Math.floor(diffMs / 86400000))}d ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-4">
|
||||
{/* Active Projects Panel */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FolderOpen className="w-4 h-4 text-gray-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Active Projects</h3>
|
||||
{!isLoadingProjects && <span className="text-xs text-gray-500">({projects.length})</span>}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto max-h-48 space-y-2">
|
||||
{isLoadingProjects ? (
|
||||
<div className="text-center text-gray-500 text-xs py-4">Loading projects...</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center text-gray-500 text-xs py-4">No active projects</div>
|
||||
) : (
|
||||
projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="p-2 rounded border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{project.color && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: project.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 ml-2 flex-shrink-0">
|
||||
{formatLastActivity(project.lastActivity)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600">
|
||||
<span>{project.taskCount} tasks</span>
|
||||
<span>{project.eventCount} events</span>
|
||||
<span className="ml-auto text-green-600">🟢 {project.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Chains Panel */}
|
||||
<div className="flex-1 min-h-0 border-t pt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Bot className="w-4 h-4 text-gray-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Agent Chains</h3>
|
||||
{!isLoadingAgents && (
|
||||
<span className="text-xs text-gray-500">({agentSessions.length})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto max-h-48 space-y-2">
|
||||
{isLoadingAgents ? (
|
||||
<div className="text-center text-gray-500 text-xs py-4">Loading agents...</div>
|
||||
) : agentSessions.length === 0 ? (
|
||||
<div className="text-center text-gray-500 text-xs py-4">No running agents</div>
|
||||
) : (
|
||||
agentSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="p-2 rounded border border-gray-200 hover:border-blue-300 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={(): void => {
|
||||
setExpandedSession(expandedSession === session.id ? null : session.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{session.agentStatus && getStatusIcon(session.agentStatus)}
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{session.label ?? session.agentName ?? "Unnamed Agent"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 ml-2 flex-shrink-0">
|
||||
{formatRuntime(session.runtimeMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600 mt-1">
|
||||
<span>{session.messageCount} msgs</span>
|
||||
{session.channel && <span className="capitalize">{session.channel}</span>}
|
||||
<span className="ml-auto text-blue-600">
|
||||
{session.status === "active" ? "🔵 Running" : "⚪ Ended"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expandable details */}
|
||||
{expandedSession === session.id && session.contextSummary && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-600">{session.contextSummary}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user