feat(#52): implement Active Projects & Agent Chains widget
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed

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:
2026-02-03 19:17:13 -06:00
parent fc87494137
commit 4c3604e85c
7 changed files with 783 additions and 0 deletions

View File

@@ -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
});
});
});