diff --git a/apps/api/src/widgets/widget-data.service.ts b/apps/api/src/widgets/widget-data.service.ts index 5bffcf8..4a01197 100644 --- a/apps/api/src/widgets/widget-data.service.ts +++ b/apps/api/src/widgets/widget-data.service.ts @@ -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 { + 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 { + 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, + })); + } } diff --git a/apps/api/src/widgets/widgets.controller.ts b/apps/api/src/widgets/widgets.controller.ts index 6fc9d1d..c90c33d 100644 --- a/apps/api/src/widgets/widgets.controller.ts +++ b/apps/api/src/widgets/widgets.controller.ts @@ -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); + } } diff --git a/apps/web/src/components/widgets/ActiveProjectsWidget.tsx b/apps/web/src/components/widgets/ActiveProjectsWidget.tsx new file mode 100644 index 0000000..383ef1f --- /dev/null +++ b/apps/web/src/components/widgets/ActiveProjectsWidget.tsx @@ -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([]); + const [agentSessions, setAgentSessions] = useState([]); + const [isLoadingProjects, setIsLoadingProjects] = useState(true); + const [isLoadingAgents, setIsLoadingAgents] = useState(true); + const [expandedSession, setExpandedSession] = useState(null); + + // Fetch active projects + useEffect(() => { + const fetchProjects = async (): Promise => { + 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 => { + 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 ; + case "IDLE": + return ; + case "WAITING": + return ; + case "ERROR": + return ; + case "TERMINATED": + return ; + default: + return ; + } + }; + + 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 ( +
+ {/* Active Projects Panel */} +
+
+ +

Active Projects

+ {!isLoadingProjects && ({projects.length})} +
+ +
+ {isLoadingProjects ? ( +
Loading projects...
+ ) : projects.length === 0 ? ( +
No active projects
+ ) : ( + projects.map((project) => ( +
+
+
+ {project.color && ( +
+ )} + + {project.name} + +
+ + {formatLastActivity(project.lastActivity)} + +
+
+ {project.taskCount} tasks + {project.eventCount} events + 🟢 {project.status} +
+
+ )) + )} +
+
+ + {/* Agent Chains Panel */} +
+
+ +

Agent Chains

+ {!isLoadingAgents && ( + ({agentSessions.length}) + )} +
+ +
+ {isLoadingAgents ? ( +
Loading agents...
+ ) : agentSessions.length === 0 ? ( +
No running agents
+ ) : ( + agentSessions.map((session) => ( +
+
{ + setExpandedSession(expandedSession === session.id ? null : session.id); + }} + > +
+ {session.agentStatus && getStatusIcon(session.agentStatus)} + + {session.label ?? session.agentName ?? "Unnamed Agent"} + +
+ + {formatRuntime(session.runtimeMs)} + +
+ +
+ {session.messageCount} msgs + {session.channel && {session.channel}} + + {session.status === "active" ? "🔵 Running" : "⚪ Ended"} + +
+ + {/* Expandable details */} + {expandedSession === session.id && session.contextSummary && ( +
+
{session.contextSummary}
+
+ )} +
+ )) + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/widgets/WidgetRegistry.tsx b/apps/web/src/components/widgets/WidgetRegistry.tsx index 1cfe759..9c62af0 100644 --- a/apps/web/src/components/widgets/WidgetRegistry.tsx +++ b/apps/web/src/components/widgets/WidgetRegistry.tsx @@ -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 = { 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, + }, }; /** diff --git a/apps/web/src/components/widgets/__tests__/ActiveProjectsWidget.test.tsx b/apps/web/src/components/widgets/__tests__/ActiveProjectsWidget.test.tsx new file mode 100644 index 0000000..ef0f7db --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/ActiveProjectsWidget.test.tsx @@ -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(); + + expect(screen.getByText(/loading projects/i)).toBeInTheDocument(); + expect(screen.getByText(/loading agents/i)).toBeInTheDocument(); + }); + + it("should render active projects list", async (): Promise => { + 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(); + + 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 => { + 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(); + + 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 => { + vi.mocked(global.fetch).mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve([]), + } as Response) + ); + + render(); + + 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 => { + 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(); + + 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 => { + 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(); + + await waitFor(() => { + expect(screen.getByText(/1h 1m/)).toBeInTheDocument(); + }); + }); + + it("should display project count badges", async (): Promise => { + 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(); + + await waitFor(() => { + expect(screen.getByText("(2)")).toBeInTheDocument(); // Project count badge + }); + }); +}); diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-widgets-ActiveProjectsWidget.tsx_20260203-1917_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-widgets-ActiveProjectsWidget.tsx_20260203-1917_1_remediation_needed.md new file mode 100644 index 0000000..d46d762 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-widgets-ActiveProjectsWidget.tsx_20260203-1917_1_remediation_needed.md @@ -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" +``` diff --git a/docs/scratchpads/52-active-projects-widget.md b/docs/scratchpads/52-active-projects-widget.md new file mode 100644 index 0000000..5b3bbe9 --- /dev/null +++ b/docs/scratchpads/52-active-projects-widget.md @@ -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)