feat(#101): Add Task Progress widget for orchestrator monitoring #335

Merged
jason.woltje merged 3 commits from feature/101-task-progress-ui into develop 2026-02-05 19:33:42 +00:00
4 changed files with 489 additions and 0 deletions

View File

@@ -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 <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
case "spawning":
return <Loader2 className="w-4 h-4 text-yellow-500 animate-spin" />;
case "completed":
return <CheckCircle className="w-4 h-4 text-green-500" />;
case "failed":
case "killed":
return <XCircle className="w-4 h-4 text-amber-500" />;
default:
return <Clock className="w-4 h-4 text-gray-400" />;
}
}
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-amber-50 border-amber-200 dark:bg-amber-950 dark:border-amber-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<AgentTask[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<AgentTask[]>;
})
.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 (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-5 h-5 text-gray-400 animate-spin" />
<span className="ml-2 text-gray-500 text-sm">Loading task progress...</span>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full text-center">
<XCircle className="w-6 h-6 text-gray-400 mb-2" />
<span className="text-gray-500 text-sm">{error}</span>
<span className="text-gray-400 text-xs mt-1">Retrying automatically</span>
</div>
);
}
return (
<div className="flex flex-col h-full space-y-3">
{/* Summary stats */}
<div className="grid grid-cols-4 gap-1 text-center text-xs">
<div className="bg-gray-50 dark:bg-gray-800 rounded p-2">
<div className="text-lg font-bold text-gray-900 dark:text-gray-100">{stats.total}</div>
<div className="text-gray-500">Total</div>
</div>
<div className="bg-blue-50 dark:bg-blue-950 rounded p-2">
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{stats.active}</div>
<div className="text-blue-500">Active</div>
</div>
<div className="bg-green-50 dark:bg-green-950 rounded p-2">
<div className="text-lg font-bold text-green-600 dark:text-green-400">
{stats.completed}
</div>
<div className="text-green-500">Done</div>
</div>
<div className="bg-amber-50 dark:bg-amber-950 rounded p-2">
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{stats.failed}</div>
<div className="text-amber-500">Stopped</div>
</div>
</div>
{/* Task list */}
<div className="flex-1 overflow-auto space-y-2">
{tasks.length === 0 ? (
<div className="text-center text-gray-500 text-sm py-4">No agent tasks in progress</div>
) : (
[...tasks]
.sort((a, b) => {
// Active tasks first, then by spawn time
const statusOrder: Record<string, number> = {
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) => (
<div
key={task.agentId}
className={`p-2 rounded-lg border ${getStatusColor(task.status)}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
{getStatusIcon(task.status)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{task.taskId}
</span>
</div>
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{getAgentTypeLabel(task.agentType)}
</span>
</div>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{getStatusLabel(task.status)}</span>
<span>{getElapsedTime(task.spawnedAt, task.completedAt)}</span>
</div>
{task.error && (
<div className="text-xs text-amber-600 dark:text-amber-400 mt-1 truncate">
{task.error}
</div>
)}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -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<string, WidgetDefinition> = {
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,
},
};
/**

View File

@@ -0,0 +1,245 @@
/**
* TaskProgressWidget Component Tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } 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();
});
afterEach((): void => {
cleanup();
});
it("should render loading state initially", (): void => {
mockFetch.mockImplementation(
() =>
new Promise(() => {
// Never-resolving promise for loading state
})
);
render(<TaskProgressWidget id="task-progress-1" />);
expect(screen.getByText(/loading task progress/i)).toBeInTheDocument();
});
it("should display tasks after successful fetch", async (): Promise<void> => {
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(<TaskProgressWidget id="task-progress-1" />);
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<void> => {
mockFetch.mockRejectedValueOnce(new Error("Network error"));
render(<TaskProgressWidget id="task-progress-1" />);
await waitFor(() => {
expect(screen.getByText(/unable to reach orchestrator/i)).toBeInTheDocument();
});
});
it("should display empty state when no tasks", async (): Promise<void> => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as unknown as Response);
render(<TaskProgressWidget id="task-progress-1" />);
await waitFor(() => {
expect(screen.getByText(/no agent tasks in progress/i)).toBeInTheDocument();
});
});
it("should show agent type badges", async (): Promise<void> => {
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(<TaskProgressWidget id="task-progress-1" />);
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<void> => {
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(<TaskProgressWidget id="task-progress-1" />);
await waitFor(() => {
expect(screen.getByText("Build failed: type errors")).toBeInTheDocument();
});
});
it("should display error for non-ok HTTP responses", async (): Promise<void> => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
} as unknown as Response);
render(<TaskProgressWidget id="task-progress-1" />);
await waitFor(() => {
expect(screen.getByText(/unable to reach orchestrator/i)).toBeInTheDocument();
});
});
it("should clear interval on unmount", async (): Promise<void> => {
const clearIntervalSpy = vi.spyOn(global, "clearInterval");
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as unknown as Response);
const { unmount } = render(<TaskProgressWidget id="task-progress-1" />);
await waitFor(() => {
expect(screen.getByText(/no agent tasks in progress/i)).toBeInTheDocument();
});
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
clearIntervalSpy.mockRestore();
});
it("should limit displayed tasks to 10", async (): Promise<void> => {
const mockTasks = Array.from({ length: 15 }, (_, i) => ({
agentId: `agent-${String(i)}`,
taskId: `SLICE-${String(i).padStart(3, "0")}`,
status: "running",
agentType: "worker",
spawnedAt: new Date().toISOString(),
}));
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTasks),
} as unknown as Response);
render(<TaskProgressWidget id="task-progress-1" />);
await waitFor(() => {
// Only 10 task cards should be rendered despite 15 tasks
const workerBadges = screen.getAllByText("Worker");
expect(workerBadges).toHaveLength(10);
});
});
it("should sort active tasks before completed ones", async (): Promise<void> => {
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(<TaskProgressWidget id="task-progress-1" />);
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");
});
});
});

View File

@@ -69,6 +69,8 @@ export type WidgetComponentType =
| "CalendarWidget"
| "QuickCaptureWidget"
| "AgentStatusWidget"
| "ActiveProjectsWidget"
| "TaskProgressWidget"
| "StatCardWidget"
| "ChartWidget"
| "ListWidget"