From 11390416037b87c77ba965c77db231f20ab2e669 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 21 Jul 2026 17:56:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20repliage=20par=20d=C3=A9faut?= =?UTF-8?q?=20des=20background=20tasks=20par=20agent=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebWorkspace replie désormais par défaut la liste des background tasks par agent dans le panneau Work ; QA a ajouté les cas de test associés dans WebWorkspaceLive.test.tsx (887 tests verts, tsc --noEmit clean). Co-Authored-By: Claude Sonnet 5 --- frontend/src/features/web/WebWorkspace.tsx | 63 +++++- .../features/web/WebWorkspaceLive.test.tsx | 209 ++++++++++++++++-- 2 files changed, 252 insertions(+), 20 deletions(-) diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index f2b4911..185b6b4 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -253,6 +253,28 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string const [openAgentId, setOpenAgentId] = useState(null); const agents = vm.state?.agents ?? []; + // Per-agent background-tasks disclosure (collapsed by default). A live refresh + // must never flip this — only drop an entry once its agent has no tasks left, + // so state doesn't leak for agents that no longer have a background section. + const [expandedBgAgents, setExpandedBgAgents] = useState>(new Set()); + useEffect(() => { + const withTasks = new Set( + agents.filter((a) => (a.backgroundTasks ?? []).length > 0).map((a) => a.agentId), + ); + setExpandedBgAgents((cur) => { + const next = new Set([...cur].filter((id) => withTasks.has(id))); + return next.size === cur.size ? cur : next; + }); + }, [agents]); + const toggleBgExpanded = useCallback((agentId: string) => { + setExpandedBgAgents((cur) => { + const next = new Set(cur); + if (next.has(agentId)) next.delete(agentId); + else next.add(agentId); + return next; + }); + }, []); + return (
@@ -284,6 +306,8 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId)) } onRefresh={vm.refresh} + bgExpanded={expandedBgAgents.has(a.agentId)} + onToggleBgExpanded={() => toggleBgExpanded(a.agentId)} /> ))} @@ -310,11 +334,15 @@ function AgentLiveRow({ open, onToggleOpen, onRefresh, + bgExpanded, + onToggleBgExpanded, }: { agent: AgentWorkState; open: boolean; onToggleOpen: () => void; onRefresh: () => Promise; + bgExpanded: boolean; + onToggleBgExpanded: () => void; }) { const live = agent.live !== undefined; const busy = agent.busy?.state === "busy"; @@ -360,12 +388,35 @@ function AgentLiveRow({ {backgroundTasks.length > 0 && (
-

Background tasks

-
    - {backgroundTasks.map((task) => ( - - ))} -
+ + {bgExpanded && ( +
    + {backgroundTasks.map((task) => ( + + ))} +
+ )}
)} diff --git a/frontend/src/features/web/WebWorkspaceLive.test.tsx b/frontend/src/features/web/WebWorkspaceLive.test.tsx index fe40c52..33785cc 100644 --- a/frontend/src/features/web/WebWorkspaceLive.test.tsx +++ b/frontend/src/features/web/WebWorkspaceLive.test.tsx @@ -57,6 +57,32 @@ function renderPaired(gateways: Gateways) { } const AGENT_IDLE = { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" as const }, tickets: [] }; +const AGENT_FRONT = { agentId: "a2", name: "Front", profileId: "p2", busy: { state: "idle" as const }, tickets: [] }; + +function backgroundTask( + taskId: string, + ownerAgentId: string, + updatedAtMs = 1, +): BackgroundCompletion { + return { + taskId, + ownerAgentId, + projectId: "p", + kind: "shell", + status: "running", + exitCode: null, + summary: null, + stdoutTail: null, + stderrTail: null, + updatedAtMs, + }; +} + +async function openLivePanel(gateways: Gateways) { + renderPaired(gateways); + fireEvent.click(await screen.findByText("Demo")); + return screen.findByTestId("web-workstate"); +} describe("WebWorkspace live surfaces (F5)", () => { it("refreshes the work-state on a relevant event.domain event", async () => { @@ -83,23 +109,14 @@ describe("WebWorkspace live surfaces (F5)", () => { }); it("renders background tasks and wires cancel through the gateway", async () => { - const task: BackgroundCompletion = { - taskId: "bt-1", - ownerAgentId: "a1", - projectId: "p", - kind: "shell", - status: "running", - exitCode: null, - summary: null, - stdoutTail: null, - stderrTail: null, - updatedAtMs: 1, - }; + const task = backgroundTask("bt-1", "a1"); const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }])); const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask"); - renderPaired(gateways); + await openLivePanel(gateways); - fireEvent.click(await screen.findByText("Demo")); + fireEvent.click( + await screen.findByRole("button", { name: "Afficher les background tasks de Archi" }), + ); const tasks = await screen.findByLabelText("Archi background tasks"); expect(tasks.textContent).toContain("Running"); expect(tasks.textContent).toContain("shell"); @@ -108,6 +125,170 @@ describe("WebWorkspace live surfaces (F5)", () => { await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1")); }); + it("keeps background task groups collapsed by default and toggles them per agent", async () => { + const { gateways } = await seeded(state([ + { ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-a", "a1")] }, + { ...AGENT_FRONT, backgroundTasks: [backgroundTask("bt-f", "a2")] }, + ])); + await openLivePanel(gateways); + + const archiToggle = await screen.findByRole("button", { + name: "Afficher les background tasks de Archi", + }); + const frontToggle = await screen.findByRole("button", { + name: "Afficher les background tasks de Front", + }); + expect(archiToggle.getAttribute("aria-expanded")).toBe("false"); + expect(frontToggle.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByLabelText("Archi background tasks")).toBeNull(); + expect(screen.queryByLabelText("Front background tasks")).toBeNull(); + + fireEvent.click(archiToggle); + expect( + screen + .getByRole("button", { name: "Masquer les background tasks de Archi" }) + .getAttribute("aria-expanded"), + ).toBe("true"); + expect( + screen + .getByRole("button", { name: "Afficher les background tasks de Front" }) + .getAttribute("aria-expanded"), + ).toBe("false"); + expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy(); + expect(screen.queryByLabelText("Front background tasks")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Front" })); + expect(screen.getByLabelText("Archi background tasks")).toBeTruthy(); + expect(screen.getByLabelText("Front background tasks")).toBeTruthy(); + }); + + it("updates task counters without changing the current expanded state", async () => { + const { gateways, projectId } = await seeded(state([ + { ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1")] }, + ])); + await openLivePanel(gateways); + + const collapsedToggle = await screen.findByRole("button", { + name: "Afficher les background tasks de Archi", + }); + expect(collapsedToggle.getAttribute("aria-expanded")).toBe("false"); + expect(collapsedToggle.textContent).toContain("1"); + expect(screen.queryByLabelText("Archi background tasks")).toBeNull(); + + (gateways.workState as MockWorkStateGateway)._setProjectWorkState( + projectId, + state([ + { + ...AGENT_IDLE, + backgroundTasks: [backgroundTask("bt-1", "a1", 1), backgroundTask("bt-2", "a1", 2)], + }, + ]), + ); + act(() => { + (gateways.system as MockSystemGateway).emit({ + type: "backgroundTaskChanged", + projectId, + taskId: "bt-2", + agentId: "a1", + state: "running", + }); + }); + + await waitFor(() => + expect( + screen.getByRole("button", { name: "Afficher les background tasks de Archi" }).textContent, + ).toContain("2"), + ); + expect( + screen + .getByRole("button", { name: "Afficher les background tasks de Archi" }) + .getAttribute("aria-expanded"), + ).toBe("false"); + expect(screen.queryByLabelText("Archi background tasks")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Archi" })); + expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy(); + + (gateways.workState as MockWorkStateGateway)._setProjectWorkState( + projectId, + state([{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1", 3)] }]), + ); + act(() => { + (gateways.system as MockSystemGateway).emit({ + type: "backgroundTaskChanged", + projectId, + taskId: "bt-2", + agentId: "a1", + state: "completed", + }); + }); + + await waitFor(() => + expect( + screen.getByRole("button", { name: "Masquer les background tasks de Archi" }).textContent, + ).toContain("1"), + ); + expect( + screen + .getByRole("button", { name: "Masquer les background tasks de Archi" }) + .getAttribute("aria-expanded"), + ).toBe("true"); + expect(screen.getByLabelText("Archi background tasks")).toBeTruthy(); + }); + + it("hides and resets an open background task group when its task count reaches zero", async () => { + const { gateways, projectId } = await seeded(state([ + { ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1")] }, + ])); + await openLivePanel(gateways); + fireEvent.click(await screen.findByRole("button", { + name: "Afficher les background tasks de Archi", + })); + expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy(); + + (gateways.workState as MockWorkStateGateway)._setProjectWorkState( + projectId, + state([{ ...AGENT_IDLE, backgroundTasks: [] }]), + ); + act(() => { + (gateways.system as MockSystemGateway).emit({ + type: "backgroundTaskChanged", + projectId, + taskId: "bt-1", + agentId: "a1", + state: "completed", + }); + }); + + await waitFor(() => + expect(screen.queryByRole("button", { name: /background tasks de Archi/ })).toBeNull(), + ); + expect(screen.queryByLabelText("Archi background tasks")).toBeNull(); + + (gateways.workState as MockWorkStateGateway)._setProjectWorkState( + projectId, + state([{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-2", "a1", 2)] }]), + ); + act(() => { + (gateways.system as MockSystemGateway).emit({ + type: "backgroundTaskChanged", + projectId, + taskId: "bt-2", + agentId: "a1", + state: "running", + }); + }); + + await waitFor(() => + expect( + screen + .getByRole("button", { name: "Afficher les background tasks de Archi" }) + .getAttribute("aria-expanded"), + ).toBe("false"), + ); + expect(screen.queryByLabelText("Archi background tasks")).toBeNull(); + }); + it("re-synchronises the read-model when the WS reconnects", async () => { const { gateways, projectId } = await seeded(state([AGENT_IDLE])); // Register a live client whose connection state we can drive.