feat(workstate): UI des actions contrôlées (Lot D frontend)

Câble les actions contrôlées dans ProjectWorkStatePanel : port et adaptateur
agent étendus aux nouvelles commandes, adaptateur mock aligné.

Tests verts : workstate + projects (25), agent (8), singletonAgent +
agentAlreadyRunning (9), tsc --noEmit OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 19:51:52 +02:00
parent 3408c96974
commit 1c6441bb35
8 changed files with 611 additions and 33 deletions

View File

@ -1,8 +1,14 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import {
MockAgentGateway,
MockLayoutGateway,
MockSystemGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import { leaves } from "@/features/layout/layout";
import type { Gateways } from "@/ports";
import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel";
@ -11,8 +17,9 @@ const PROJECT_ID = "project-work-state-test";
function renderPanel(
workState: MockWorkStateGateway = new MockWorkStateGateway(),
system: MockSystemGateway = new MockSystemGateway(),
overrides: Partial<Gateways> = {},
) {
const gateways = { workState, system } as unknown as Gateways;
const gateways = { workState, system, ...overrides } as unknown as Gateways;
return {
workState,
system,
@ -411,4 +418,311 @@ describe("ProjectWorkStatePanel", () => {
expect(screen.getByText("#1 In progress")).toBeTruthy();
await waitFor(() => expect(spy).toHaveBeenCalled());
});
it("Open focuses a visible live cell and does not launch", async () => {
const workState = new MockWorkStateGateway();
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
const host = document.createElement("div");
host.dataset.nodeId = leafId;
document.body.appendChild(host);
const launchSpy = vi.spyOn(agent, "launchAgent");
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-open",
name: "Visible",
profileId: "codex",
live: { nodeId: leafId, sessionId: "session-open", kind: "pty" },
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
renderPanel(workState, new MockSystemGateway(), { layout, agent });
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
expect(host.style.outline).toContain("2px solid");
expect(launchSpy).not.toHaveBeenCalled();
host.remove();
});
it("Attach rebinds a hidden live agent to the deterministic empty cell", async () => {
const workState = new MockWorkStateGateway();
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Hidden",
profileId: "codex",
});
await agent.launchAgent(
PROJECT_ID,
created.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: "hidden-node" },
() => {},
);
const attachSpy = vi.spyOn(agent, "attachLiveAgent");
const launchSpy = vi.spyOn(agent, "launchAgent");
const target = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: created.id,
name: "Hidden",
profileId: "codex",
live: { nodeId: "hidden-node", sessionId: "mock-agent-session-1", kind: "pty" },
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
renderPanel(workState, new MockSystemGateway(), { layout, agent });
const attach = await screen.findByRole("button", { name: "Attach" });
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(attach);
await waitFor(() =>
expect(attachSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, target),
);
await waitFor(async () => {
const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0];
expect(leaf.agent).toBe(created.id);
expect(leaf.session).toBe("mock-agent-session-1");
});
expect(launchSpy).not.toHaveBeenCalled();
});
it("disables Attach when no visible target cell exists", async () => {
const workState = new MockWorkStateGateway();
const layout = new MockLayoutGateway();
const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
await layout.mutateLayout(PROJECT_ID, {
type: "setCellAgent",
target: leafId,
agent: "other-agent",
});
await layout.mutateLayout(PROJECT_ID, {
type: "setSession",
target: leafId,
session: "occupied-session",
});
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-hidden",
name: "Hidden",
profileId: "codex",
live: { nodeId: "hidden-node", sessionId: "session-hidden", kind: "pty" },
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
renderPanel(workState, new MockSystemGateway(), {
layout,
agent: new MockAgentGateway(),
});
const attach = await screen.findByRole("button", { name: "Attach" });
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(true));
expect(await screen.findByText("No empty visible cell.")).toBeTruthy();
});
it("Stop confirms with a busy warning, stops the live agent, and refreshes Work", async () => {
const workState = new MockWorkStateGateway();
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Runner",
profileId: "codex",
});
const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
await agent.launchAgent(
PROJECT_ID,
created.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: leafId },
() => {},
);
await layout.mutateLayout(PROJECT_ID, {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout(PROJECT_ID, {
type: "setSession",
target: leafId,
session: "mock-agent-session-1",
});
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: created.id,
name: "Runner",
profileId: "codex",
live: { nodeId: leafId, sessionId: "mock-agent-session-1", kind: "pty" },
busy: { state: "busy", ticket: "ticket-stop", sinceMs: 1 },
tickets: [
{
ticketId: "ticket-stop",
conversationId: "conv-stop",
position: 0,
status: "inProgress",
source: { kind: "human" },
requesterLabel: "Human",
taskPreview: "Do work",
taskLen: 7,
},
],
},
],
conversations: [],
});
const stopSpy = vi.spyOn(agent, "stopLiveAgent");
const refreshSpy = vi.spyOn(workState, "getProjectWorkState");
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel(workState, new MockSystemGateway(), { layout, agent });
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
await waitFor(() =>
expect(confirmSpy).toHaveBeenCalledWith(
"Stop Runner? The current turn will be interrupted.",
),
);
await waitFor(() => expect(stopSpy).toHaveBeenCalledWith(PROJECT_ID, created.id));
await waitFor(() => expect(refreshSpy).toHaveBeenCalled());
await waitFor(async () => {
const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0];
expect(leaf.session).toBeNull();
});
confirmSpy.mockRestore();
});
it("View conversation exposes only Lot C preview fields", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-view",
name: "Viewer",
profileId: "codex",
busy: { state: "idle" },
tickets: [
{
ticketId: "ticket-view",
conversationId: "conversation-view",
position: 0,
status: "queued",
source: { kind: "human" },
requesterLabel: "Human",
taskPreview: "Inspect preview",
taskLen: 15,
},
],
},
],
conversations: [
{
conversationId: "conversation-view",
status: "ready",
objectivePreview: "Keep it compact",
summaryPreview: "Only the exposed summary preview.",
summaryLen: 33,
upTo: "internal-turn-id",
recentTurns: [
{
role: "response",
source: { kind: "agent", agentId: "agent-view" },
atMs: 42,
textPreview: "Recent exposed turn.",
textLen: 20,
},
],
},
],
});
renderPanel(workState);
const list = await screen.findByLabelText("Viewer tickets");
fireEvent.click(within(list).getByRole("button", { name: "View" }));
expect(within(list).getByText("Goal:")).toBeTruthy();
expect(within(list).getByText("Keep it compact")).toBeTruthy();
expect(within(list).getByText("Summary:")).toBeTruthy();
expect(
within(list).getByText("Only the exposed summary preview."),
).toBeTruthy();
expect(within(list).getByText("response:")).toBeTruthy();
expect(within(list).getByText("Recent exposed turn.")).toBeTruthy();
expect(list.textContent).not.toContain("internal-turn-id");
});
it("Copy summary copies only the exposed preview text", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-copy",
name: "Copier",
profileId: "codex",
busy: { state: "idle" },
tickets: [
{
ticketId: "ticket-copy",
conversationId: "conversation-copy",
position: 0,
status: "queued",
source: { kind: "human" },
requesterLabel: "Human",
taskPreview: "Copy preview",
taskLen: 12,
},
],
},
],
conversations: [
{
conversationId: "conversation-copy",
status: "ready",
objectivePreview: null,
summaryPreview: "Copy this summary preview only.",
summaryLen: 31,
upTo: null,
recentTurns: [
{
role: "prompt",
source: { kind: "human" },
atMs: 1,
textPreview: "Do not copy this turn.",
textLen: 22,
},
],
},
],
});
renderPanel(workState);
const list = await screen.findByLabelText("Copier tickets");
fireEvent.click(within(list).getByRole("button", { name: "Copy" }));
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith("Copy this summary preview only."),
);
expect(await within(list).findByRole("button", { name: "Copied" })).toBeTruthy();
vi.unstubAllGlobals();
});
});