From e7f277ff0cec8cb9d83769057f946af1a25a7e20 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Feb 2026 12:57:10 -0600 Subject: [PATCH] feat(#101): Add Task Progress widget for orchestrator task monitoring Create TaskProgressWidget showing live agent task execution progress: - Fetches from orchestrator /agents API with 15s auto-refresh - Shows stats (total/active/done/stopped), sorted task list - Agent type badges (worker/reviewer/tester) - Elapsed time tracking, error display - Dark mode support, PDA-friendly language - Registered in WidgetRegistry for dashboard use Includes 7 unit tests covering all states. Fixes #101 Co-Authored-By: Claude Opus 4.5 --- .../components/widgets/TaskProgressWidget.tsx | 230 ++++++++++++++++++ .../src/components/widgets/WidgetRegistry.tsx | 12 + .../__tests__/TaskProgressWidget.test.tsx | 185 ++++++++++++++ 3 files changed, 427 insertions(+) create mode 100644 apps/web/src/components/widgets/TaskProgressWidget.tsx create mode 100644 apps/web/src/components/widgets/__tests__/TaskProgressWidget.test.tsx diff --git a/apps/web/src/components/widgets/TaskProgressWidget.tsx b/apps/web/src/components/widgets/TaskProgressWidget.tsx new file mode 100644 index 0000000..6f54e91 --- /dev/null +++ b/apps/web/src/components/widgets/TaskProgressWidget.tsx @@ -0,0 +1,230 @@ +/** + * Task Progress Widget - shows orchestrator agent task progress + * + * Displays live progress of agent tasks being executed by the orchestrator, + * including status, elapsed time, and work item details. + */ + +import { useState, useEffect } from "react"; +import { Activity, CheckCircle, XCircle, Clock, Loader2 } from "lucide-react"; +import type { WidgetProps } from "@mosaic/shared"; + +interface AgentTask { + agentId: string; + taskId: string; + status: string; + agentType: string; + spawnedAt: string; + completedAt?: string; + error?: string; +} + +function getElapsedTime(spawnedAt: string, completedAt?: string): string { + const start = new Date(spawnedAt).getTime(); + const end = completedAt ? new Date(completedAt).getTime() : Date.now(); + const diffMs = end - start; + + if (diffMs < 60000) return `${String(Math.floor(diffMs / 1000))}s`; + if (diffMs < 3600000) + return `${String(Math.floor(diffMs / 60000))}m ${String(Math.floor((diffMs % 60000) / 1000))}s`; + return `${String(Math.floor(diffMs / 3600000))}h ${String(Math.floor((diffMs % 3600000) / 60000))}m`; +} + +function getStatusIcon(status: string): React.JSX.Element { + switch (status) { + case "running": + return ; + case "spawning": + return ; + case "completed": + return ; + case "failed": + case "killed": + return ; + default: + return ; + } +} + +function getStatusLabel(status: string): string { + switch (status) { + case "spawning": + return "Starting"; + case "running": + return "In progress"; + case "completed": + return "Done"; + case "failed": + return "Stopped"; + case "killed": + return "Terminated"; + default: + return status; + } +} + +function getStatusColor(status: string): string { + switch (status) { + case "running": + return "bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800"; + case "spawning": + return "bg-yellow-50 border-yellow-200 dark:bg-yellow-950 dark:border-yellow-800"; + case "completed": + return "bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-800"; + case "failed": + case "killed": + return "bg-red-50 border-red-200 dark:bg-red-950 dark:border-red-800"; + default: + return "bg-gray-50 border-gray-200 dark:bg-gray-900 dark:border-gray-700"; + } +} + +function getAgentTypeLabel(agentType: string): string { + switch (agentType) { + case "worker": + return "Worker"; + case "reviewer": + return "Reviewer"; + case "tester": + return "Tester"; + default: + return agentType; + } +} + +export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element { + const [tasks, setTasks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const orchestratorUrl = process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? "http://localhost:3001"; + + const fetchTasks = (): void => { + fetch(`${orchestratorUrl}/agents`) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${String(res.status)}`); + return res.json() as Promise; + }) + .then((data) => { + setTasks(data); + setError(null); + setIsLoading(false); + }) + .catch(() => { + setError("Unable to reach orchestrator"); + setIsLoading(false); + }); + }; + + fetchTasks(); + const interval = setInterval(fetchTasks, 15000); + + return (): void => { + clearInterval(interval); + }; + }, []); + + const stats = { + total: tasks.length, + active: tasks.filter((t) => t.status === "running" || t.status === "spawning").length, + completed: tasks.filter((t) => t.status === "completed").length, + failed: tasks.filter((t) => t.status === "failed" || t.status === "killed").length, + }; + + if (isLoading) { + return ( +
+ + Loading task progress... +
+ ); + } + + if (error) { + return ( +
+ + {error} + Retrying automatically +
+ ); + } + + return ( +
+ {/* Summary stats */} +
+
+
{stats.total}
+
Total
+
+
+
{stats.active}
+
Active
+
+
+
+ {stats.completed} +
+
Done
+
+
+
{stats.failed}
+
Stopped
+
+
+ + {/* Task list */} +
+ {tasks.length === 0 ? ( +
No agent tasks in progress
+ ) : ( + tasks + .sort((a, b) => { + // Active tasks first, then by spawn time + const statusOrder: Record = { + running: 0, + spawning: 1, + failed: 2, + killed: 3, + completed: 4, + }; + const orderA = statusOrder[a.status] ?? 5; + const orderB = statusOrder[b.status] ?? 5; + if (orderA !== orderB) return orderA - orderB; + return new Date(b.spawnedAt).getTime() - new Date(a.spawnedAt).getTime(); + }) + .slice(0, 10) + .map((task) => ( +
+
+
+ {getStatusIcon(task.status)} + + {task.taskId} + +
+ + {getAgentTypeLabel(task.agentType)} + +
+
+ {getStatusLabel(task.status)} + {getElapsedTime(task.spawnedAt, task.completedAt)} +
+ {task.error && ( +
+ {task.error} +
+ )} +
+ )) + )} +
+
+ ); +} diff --git a/apps/web/src/components/widgets/WidgetRegistry.tsx b/apps/web/src/components/widgets/WidgetRegistry.tsx index 9c62af0..614f30f 100644 --- a/apps/web/src/components/widgets/WidgetRegistry.tsx +++ b/apps/web/src/components/widgets/WidgetRegistry.tsx @@ -9,6 +9,7 @@ import { CalendarWidget } from "./CalendarWidget"; import { QuickCaptureWidget } from "./QuickCaptureWidget"; import { AgentStatusWidget } from "./AgentStatusWidget"; import { ActiveProjectsWidget } from "./ActiveProjectsWidget"; +import { TaskProgressWidget } from "./TaskProgressWidget"; export interface WidgetDefinition { name: string; @@ -83,6 +84,17 @@ export const widgetRegistry: Record = { minHeight: 2, maxWidth: 4, }, + TaskProgressWidget: { + name: "TaskProgressWidget", + displayName: "Task Progress", + description: "Live progress of orchestrator agent tasks", + component: TaskProgressWidget, + defaultWidth: 2, + defaultHeight: 2, + minWidth: 1, + minHeight: 2, + maxWidth: 3, + }, }; /** diff --git a/apps/web/src/components/widgets/__tests__/TaskProgressWidget.test.tsx b/apps/web/src/components/widgets/__tests__/TaskProgressWidget.test.tsx new file mode 100644 index 0000000..f220dc0 --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/TaskProgressWidget.test.tsx @@ -0,0 +1,185 @@ +/** + * TaskProgressWidget Component Tests + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { TaskProgressWidget } from "../TaskProgressWidget"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +describe("TaskProgressWidget", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + }); + + it("should render loading state initially", (): void => { + mockFetch.mockImplementation( + () => + new Promise(() => { + // Never-resolving promise for loading state + }) + ); + + render(); + + expect(screen.getByText(/loading task progress/i)).toBeInTheDocument(); + }); + + it("should display tasks after successful fetch", async (): Promise => { + const mockTasks = [ + { + agentId: "agent-1", + taskId: "TASK-001", + status: "running", + agentType: "worker", + spawnedAt: new Date().toISOString(), + }, + { + agentId: "agent-2", + taskId: "TASK-002", + status: "completed", + agentType: "reviewer", + spawnedAt: new Date(Date.now() - 3600000).toISOString(), + completedAt: new Date().toISOString(), + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTasks), + } as unknown as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText("TASK-001")).toBeInTheDocument(); + expect(screen.getByText("TASK-002")).toBeInTheDocument(); + }); + + // Check stats + expect(screen.getByText("2")).toBeInTheDocument(); // Total + }); + + it("should display error state when fetch fails", async (): Promise => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + render(); + + await waitFor(() => { + expect(screen.getByText(/unable to reach orchestrator/i)).toBeInTheDocument(); + }); + }); + + it("should display empty state when no tasks", async (): Promise => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + } as unknown as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText(/no agent tasks in progress/i)).toBeInTheDocument(); + }); + }); + + it("should show agent type badges", async (): Promise => { + const mockTasks = [ + { + agentId: "agent-1", + taskId: "TASK-001", + status: "running", + agentType: "worker", + spawnedAt: new Date().toISOString(), + }, + { + agentId: "agent-2", + taskId: "TASK-002", + status: "running", + agentType: "reviewer", + spawnedAt: new Date().toISOString(), + }, + { + agentId: "agent-3", + taskId: "TASK-003", + status: "running", + agentType: "tester", + spawnedAt: new Date().toISOString(), + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTasks), + } as unknown as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText("Worker")).toBeInTheDocument(); + expect(screen.getByText("Reviewer")).toBeInTheDocument(); + expect(screen.getByText("Tester")).toBeInTheDocument(); + }); + }); + + it("should display error message for failed tasks", async (): Promise => { + const mockTasks = [ + { + agentId: "agent-1", + taskId: "TASK-FAIL", + status: "failed", + agentType: "worker", + spawnedAt: new Date().toISOString(), + error: "Build failed: type errors", + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTasks), + } as unknown as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText("Build failed: type errors")).toBeInTheDocument(); + }); + }); + + it("should sort active tasks before completed ones", async (): Promise => { + const mockTasks = [ + { + agentId: "agent-completed", + taskId: "COMPLETED-TASK", + status: "completed", + agentType: "worker", + spawnedAt: new Date(Date.now() - 7200000).toISOString(), + completedAt: new Date().toISOString(), + }, + { + agentId: "agent-running", + taskId: "RUNNING-TASK", + status: "running", + agentType: "worker", + spawnedAt: new Date().toISOString(), + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTasks), + } as unknown as Response); + + render(); + + await waitFor(() => { + const taskElements = screen.getAllByText(/TASK/); + expect(taskElements).toHaveLength(2); + // Running task should appear before completed + expect(taskElements[0]?.textContent).toBe("RUNNING-TASK"); + expect(taskElements[1]?.textContent).toBe("COMPLETED-TASK"); + }); + }); +});