feat(workstate): UI live-state des conversations/délégations (Lot A frontend)

Ajoute le port et l'adaptateur workState (+ mock) côté frontend, le type domaine
associé, et la feature `workstate` (panneau ProjectWorkStatePanel + hook
useProjectWorkState) consommant le read-model live exposé par le backend.
Intègre le panneau dans ProjectsView. Tests verts (workstate + projects).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:06:28 +02:00
parent aae18499a9
commit 17685a08e1
11 changed files with 397 additions and 1 deletions

View File

@ -0,0 +1,111 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel";
const PROJECT_ID = "project-work-state-test";
function renderPanel(
workState: MockWorkStateGateway = new MockWorkStateGateway(),
system: MockSystemGateway = new MockSystemGateway(),
) {
const gateways = { workState, system } as unknown as Gateways;
return {
workState,
system,
...render(
<DIProvider gateways={gateways}>
<ProjectWorkStatePanel projectId={PROJECT_ID} />
</DIProvider>,
),
};
}
describe("ProjectWorkStatePanel", () => {
it("renders the empty state", async () => {
renderPanel();
expect(await screen.findByText("No agent work state.")).toBeTruthy();
});
it("renders an idle offline agent", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-1",
name: "Planner",
profileId: "codex",
busy: { state: "idle" },
},
],
});
renderPanel(workState);
expect(await screen.findByText("Planner")).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
expect(screen.getByText("Idle")).toBeTruthy();
});
it("renders a live busy agent with a short ticket", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-2",
name: "Builder",
profileId: "claude",
live: {
nodeId: "node-a",
sessionId: "session-a",
kind: "pty",
},
busy: {
state: "busy",
ticket: "4c65d981-da5e-412d-85ed-3b9779b670fa",
sinceMs: 123,
},
},
],
});
renderPanel(workState);
expect(await screen.findByText("Builder")).toBeTruthy();
expect(screen.getByText("Live")).toBeTruthy();
expect(screen.getByText("Busy")).toBeTruthy();
expect(screen.getByLabelText("busy ticket 4c65d981")).toBeTruthy();
});
it("refreshes when a relevant domain event fires", async () => {
const workState = new MockWorkStateGateway();
const system = new MockSystemGateway();
renderPanel(workState, system);
await screen.findByText("No agent work state.");
const spy = vi.spyOn(workState, "getProjectWorkState");
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-3",
name: "Responder",
profileId: "gemini",
busy: { state: "busy", ticket: "ticket-123456789", sinceMs: 456 },
},
],
});
system.emit({
type: "agentBusyChanged",
agentId: "agent-3",
busy: true,
});
expect(await screen.findByText("Responder")).toBeTruthy();
await waitFor(() => expect(spy).toHaveBeenCalled());
});
});