feat(background-task): rendu live des tâches de fond — subscriber UI + canal IPC (#58)

Câble un canal attachable au flux de sortie d'une tâche de fond (runner
infrastructure + commande app-tauri + port/adaptateurs frontend) et le
panneau ProjectWorkStatePanel s'y abonne pour un rendu live au lieu d'un
état figé au dernier snapshot.

Validations obtenues avant commit :
- cargo test -p infrastructure --test background_task_runner : vert
- cargo check -p backend -p app-tauri : vert
- npx vitest run src/features/workstate/workstate.test.tsx : vert
- npm run typecheck : vert
- verdict QA #58 : vert

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 16:48:29 +02:00
parent 6f532d7434
commit b734c237d3
17 changed files with 528 additions and 23 deletions

View File

@ -336,6 +336,64 @@ describe("ProjectWorkStatePanel", () => {
).toBeNull();
});
it("attaches, repaints, streams, and detaches live background task output", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-live-task",
name: "Runner",
profileId: "codex",
busy: { state: "idle" },
backgroundTasks: [
{
taskId: "task-live-output",
kind: "command",
state: "running",
createdAtMs: 10,
updatedAtMs: 10,
},
],
},
],
});
workState._setBackgroundTaskAttachment("task-live-output", {
scrollback: "already printed\n",
live: true,
});
const attachSpy = vi.spyOn(workState, "attachBackgroundTask");
const view = renderPanel(workState);
const list = await screen.findByLabelText("Runner background tasks");
fireEvent.click(within(list).getByRole("button", { name: "Live" }));
expect(attachSpy).toHaveBeenCalledWith("task-live-output", expect.any(Function));
expect(
(await within(list).findByLabelText("task task-liv live output")).textContent,
).toContain("already printed");
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(1);
workState._emitBackgroundTaskOutput("task-live-output", "new line\n");
await waitFor(() =>
expect(
within(list).getByLabelText("task task-liv live output").textContent,
).toContain("new line"),
);
fireEvent.click(within(list).getByRole("button", { name: "Detach" }));
await waitFor(() =>
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(0),
);
expect(within(list).queryByLabelText("task task-liv live output")).toBeNull();
fireEvent.click(within(list).getByRole("button", { name: "Live" }));
await within(list).findByLabelText("task task-liv live output");
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(1);
view.unmount();
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(0);
});
it("renders a legacy agent without tickets", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {