feat(#52): Implement Active Projects & Agent Chains widget #301
@@ -43,6 +43,31 @@ export interface WidgetCalendarItem {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface WidgetProjectItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
lastActivity: string;
|
||||
taskCount: number;
|
||||
eventCount: number;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export interface WidgetAgentSessionItem {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for fetching widget data from various sources
|
||||
*/
|
||||
@@ -595,4 +620,76 @@ export class WidgetDataService {
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active projects data
|
||||
*/
|
||||
async getActiveProjectsData(workspaceId: string): Promise<WidgetProjectItem[]> {
|
||||
const projects = await this.prisma.project.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: ProjectStatus.ACTIVE,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { tasks: true, events: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
status: project.status,
|
||||
lastActivity: project.updatedAt.toISOString(),
|
||||
taskCount: project._count.tasks,
|
||||
eventCount: project._count.events,
|
||||
color: project.color,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent chains data (active agent sessions)
|
||||
*/
|
||||
async getAgentChainsData(workspaceId: string): Promise<WidgetAgentSessionItem[]> {
|
||||
const sessions = await this.prisma.agentSession.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
agent: {
|
||||
select: {
|
||||
name: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
startedAt: "desc",
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
return sessions.map((session) => ({
|
||||
id: session.id,
|
||||
sessionKey: session.sessionKey,
|
||||
label: session.label,
|
||||
channel: session.channel,
|
||||
agentName: session.agent?.name ?? null,
|
||||
agentStatus: session.agent?.status ?? null,
|
||||
status: session.isActive ? ("active" as const) : ("ended" as const),
|
||||
startedAt: session.startedAt.toISOString(),
|
||||
lastMessageAt: session.lastMessageAt ? session.lastMessageAt.toISOString() : null,
|
||||
runtimeMs: now.getTime() - session.startedAt.getTime(),
|
||||
messageCount: session.messageCount,
|
||||
contextSummary: session.contextSummary,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,4 +100,30 @@ export class WidgetsController {
|
||||
}
|
||||
return this.widgetDataService.getCalendarPreviewData(workspaceId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/widgets/data/active-projects
|
||||
* Get active projects widget data
|
||||
*/
|
||||
@Post("data/active-projects")
|
||||
async getActiveProjectsData(@Request() req: AuthenticatedRequest) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getActiveProjectsData(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/widgets/data/agent-chains
|
||||
* Get agent chains widget data (active agent sessions)
|
||||
*/
|
||||
@Post("data/agent-chains")
|
||||
async getAgentChainsData(@Request() req: AuthenticatedRequest) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getAgentChainsData(workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { TasksWidget } from "./TasksWidget";
|
||||
import { CalendarWidget } from "./CalendarWidget";
|
||||
import { QuickCaptureWidget } from "./QuickCaptureWidget";
|
||||
import { AgentStatusWidget } from "./AgentStatusWidget";
|
||||
import { ActiveProjectsWidget } from "./ActiveProjectsWidget";
|
||||
|
||||
export interface WidgetDefinition {
|
||||
name: string;
|
||||
@@ -71,6 +72,17 @@ export const widgetRegistry: Record<string, WidgetDefinition> = {
|
||||
minHeight: 2,
|
||||
maxWidth: 3,
|
||||
},
|
||||
ActiveProjectsWidget: {
|
||||
name: "ActiveProjectsWidget",
|
||||
displayName: "Active Projects & Agent Chains",
|
||||
description: "View active projects and running agent sessions",
|
||||
component: ActiveProjectsWidget,
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 3,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* ActiveProjectsWidget Component Tests
|
||||
* Following TDD principles
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { ActiveProjectsWidget } from "../ActiveProjectsWidget";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// Mock fetch for API calls
|
||||
global.fetch = vi.fn() as typeof global.fetch;
|
||||
|
||||
describe("ActiveProjectsWidget", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render loading state initially", (): void => {
|
||||
vi.mocked(global.fetch).mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
// Intentionally empty - creates a never-resolving promise for loading state
|
||||
})
|
||||
);
|
||||
|
||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||
|
||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/loading agents/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render active projects list", async (): Promise<void> => {
|
||||
const mockProjects = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Website Redesign",
|
||||
status: "ACTIVE",
|
||||
lastActivity: new Date().toISOString(),
|
||||
taskCount: 12,
|
||||
eventCount: 3,
|
||||
color: "#3B82F6",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Mobile App",
|
||||
status: "ACTIVE",
|
||||
lastActivity: new Date().toISOString(),
|
||||
taskCount: 8,
|
||||
eventCount: 1,
|
||||
color: "#10B981",
|
||||
},
|
||||
];
|
||||
|
||||
const mockAgentSessions: never[] = [];
|
||||
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
if (urlString.includes("active-projects")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProjects),
|
||||
} as Response);
|
||||
} else if (urlString.includes("agent-chains")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAgentSessions),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error("Unknown URL"));
|
||||
});
|
||||
|
||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Website Redesign")).toBeInTheDocument();
|
||||
expect(screen.getByText("Mobile App")).toBeInTheDocument();
|
||||
expect(screen.getByText("12 tasks")).toBeInTheDocument();
|
||||
expect(screen.getByText("8 tasks")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render agent sessions list", async (): Promise<void> => {
|
||||
const mockProjects: never[] = [];
|
||||
const mockAgentSessions = [
|
||||
{
|
||||
id: "1",
|
||||
sessionKey: "session-123",
|
||||
label: "Code Review Agent",
|
||||
channel: "slack",
|
||||
agentName: "ReviewBot",
|
||||
agentStatus: "WORKING",
|
||||
status: "active" as const,
|
||||
startedAt: new Date(Date.now() - 120000).toISOString(), // 2 minutes ago
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
runtimeMs: 120000,
|
||||
messageCount: 5,
|
||||
contextSummary: "Reviewing PR #123",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
if (urlString.includes("active-projects")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProjects),
|
||||
} as Response);
|
||||
} else if (urlString.includes("agent-chains")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAgentSessions),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error("Unknown URL"));
|
||||
});
|
||||
|
||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Code Review Agent")).toBeInTheDocument();
|
||||
expect(screen.getByText("5 msgs")).toBeInTheDocument();
|
||||
expect(screen.getByText(/running/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty states", async (): Promise<void> => {
|
||||
vi.mocked(global.fetch).mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response)
|
||||
);
|
||||
|
||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no active projects/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no running agents/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should expand agent session details on click", async (): Promise<void> => {
|
||||
const mockAgentSession = {
|
||||
id: "1",
|
||||
sessionKey: "session-123",
|
||||
label: "Test Agent",
|
||||
channel: null,
|
||||
agentName: null,
|
||||
agentStatus: "WORKING",
|
||||
status: "active" as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
runtimeMs: 60000,
|
||||
messageCount: 3,
|
||||
contextSummary: "Working on task XYZ",
|
||||
};
|
||||
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
if (urlString.includes("agent-chains")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([mockAgentSession]),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to expand
|
||||
const agentElement = screen.getByText("Test Agent");
|
||||
await userEvent.click(agentElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Working on task XYZ")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should format runtime correctly", async (): Promise<void> => {
|
||||
const mockAgentSession = {
|
||||
id: "1",
|
||||
sessionKey: "session-123",
|
||||
label: "Test Agent",
|
||||
channel: null,
|
||||
agentName: null,
|
||||
agentStatus: "WORKING",
|
||||
status: "active" as const,
|
||||
startedAt: new Date(Date.now() - 3660000).toISOString(), // 1 hour 1 minute ago
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
runtimeMs: 3660000, // 1h 1m
|
||||
messageCount: 10,
|
||||
contextSummary: null,
|
||||
};
|
||||
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
if (urlString.includes("agent-chains")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([mockAgentSession]),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1h 1m/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display project count badges", async (): Promise<void> => {
|
||||
const mockProjects = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Project 1",
|
||||
status: "ACTIVE",
|
||||
lastActivity: new Date().toISOString(),
|
||||
taskCount: 5,
|
||||
eventCount: 2,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Project 2",
|
||||
status: "ACTIVE",
|
||||
lastActivity: new Date().toISOString(),
|
||||
taskCount: 3,
|
||||
eventCount: 1,
|
||||
color: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
if (urlString.includes("active-projects")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProjects),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("(2)")).toBeInTheDocument(); // Project count badge
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/jwoltje/src/mosaic-stack/apps/web/src/components/widgets/ActiveProjectsWidget.tsx
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-03 19:17:02
|
||||
|
||||
## Status
|
||||
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-widgets-ActiveProjectsWidget.tsx_20260203-1917_1_remediation_needed.md"
|
||||
```
|
||||
113
docs/scratchpads/52-active-projects-widget.md
Normal file
113
docs/scratchpads/52-active-projects-widget.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Issue #52: Active Projects & Agent Chains Widget
|
||||
|
||||
## Objective
|
||||
|
||||
Implement a HUD widget that displays all active projects and their agent chains for easy visual tracking.
|
||||
|
||||
## Approach
|
||||
|
||||
### Backend (API)
|
||||
|
||||
1. **Added new widget data response types** in `widget-data.service.ts`:
|
||||
- `WidgetProjectItem` - for active project data
|
||||
- `WidgetAgentSessionItem` - for agent session data
|
||||
|
||||
2. **Created two new service methods** in `WidgetDataService`:
|
||||
- `getActiveProjectsData()` - fetches projects where `status = 'ACTIVE'`
|
||||
- `getAgentChainsData()` - fetches agent sessions where `isActive = true`
|
||||
|
||||
3. **Added two new controller endpoints** in `widgets.controller.ts`:
|
||||
- `POST /api/widgets/data/active-projects`
|
||||
- `POST /api/widgets/data/agent-chains`
|
||||
|
||||
### Frontend (Web)
|
||||
|
||||
1. **Created `ActiveProjectsWidget.tsx`** component with:
|
||||
- Active Projects panel showing:
|
||||
- Project name with color indicator
|
||||
- Last activity timestamp
|
||||
- Task and event counts
|
||||
- Status indicator
|
||||
- Agent Chains panel showing:
|
||||
- Agent label/name
|
||||
- Status indicator with icon animation
|
||||
- Runtime duration (formatted)
|
||||
- Message count and channel
|
||||
- Expandable context summary
|
||||
- Real-time updates (projects: 30s, agents: 10s)
|
||||
- PDA-friendly language ("Running" vs "URGENT")
|
||||
|
||||
2. **Registered widget** in `WidgetRegistry.tsx`:
|
||||
- Name: `ActiveProjectsWidget`
|
||||
- Display name: "Active Projects & Agent Chains"
|
||||
- Default size: 2x3 grid units
|
||||
- Configurable size: 2x2 to 4x4
|
||||
|
||||
### Testing
|
||||
|
||||
Created comprehensive test suite in `ActiveProjectsWidget.test.tsx`:
|
||||
|
||||
- ✅ Loading state rendering
|
||||
- ✅ Active projects list rendering
|
||||
- ✅ Agent sessions list rendering
|
||||
- ✅ Empty state handling
|
||||
- ✅ Expandable session details
|
||||
- ✅ Runtime formatting
|
||||
- ✅ Count badge display
|
||||
|
||||
**Test Results**: 7/7 tests passing
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Backend API endpoints
|
||||
- [x] Frontend widget component
|
||||
- [x] Widget registration
|
||||
- [x] Tests written and passing
|
||||
- [x] Lint clean
|
||||
- [x] Type-check clean
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Status Indicators Used
|
||||
|
||||
Following PDA-friendly design principles:
|
||||
|
||||
- 🟢 Active (green) - not "CRITICAL" or demanding
|
||||
- 🔵 Running (blue) - for active agents
|
||||
- ⚪ Ended (gray) - for completed sessions
|
||||
|
||||
### Real-time Updates
|
||||
|
||||
- Projects: Refresh every 30 seconds
|
||||
- Agent Chains: Refresh every 10 seconds (more frequent due to dynamic nature)
|
||||
- Future enhancement: WebSocket support for live updates
|
||||
|
||||
### Expandable Details
|
||||
|
||||
Agent sessions can be clicked to reveal:
|
||||
|
||||
- Context summary (if available)
|
||||
- Provides quick insight without cluttering the view
|
||||
|
||||
## Notes
|
||||
|
||||
- Pre-existing build error in `federation/guards/capability.guard.ts` (not related to this issue)
|
||||
- Pre-existing test failures in federation module (not related to this issue)
|
||||
- All widget-specific tests pass
|
||||
- No lint errors in changed files
|
||||
- Type-checking clean for widget files
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `apps/api/src/widgets/widget-data.service.ts` - Added new data fetching methods
|
||||
2. `apps/api/src/widgets/widgets.controller.ts` - Added new endpoints
|
||||
3. `apps/web/src/components/widgets/ActiveProjectsWidget.tsx` - New widget component
|
||||
4. `apps/web/src/components/widgets/WidgetRegistry.tsx` - Registered widget
|
||||
5. `apps/web/src/components/widgets/__tests__/ActiveProjectsWidget.test.tsx` - New tests
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Create PR for review
|
||||
- Manual QA testing in browser
|
||||
- Verify real-time updates work correctly
|
||||
- Test with various data scenarios (empty, many projects, many agents)
|
||||
Reference in New Issue
Block a user