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

@ -3,7 +3,7 @@
* and idle/busy state from the backend read-model, plus the current input queue.
*/
import { Component, type ReactNode, useState } from "react";
import { Component, type ReactNode, useEffect, useRef, useState } from "react";
import type {
AgentTicketState,
@ -14,6 +14,7 @@ import type {
InboxItem,
LeafCell,
} from "@/domain";
import type { BackgroundTaskAttachment } from "@/ports";
import { useGateways } from "@/app/di";
import { leaves } from "@/features/layout/layout";
import { useLayout } from "@/features/layout/useLayout";
@ -337,11 +338,36 @@ function BackgroundTaskRow({
}) {
const { workState } = useGateways();
const [open, setOpen] = useState(false);
const [liveOpen, setLiveOpen] = useState(false);
const [liveOutput, setLiveOutput] = useState("");
const [actionBusy, setActionBusy] = useState(false);
const [liveBusy, setLiveBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const attachmentRef = useRef<BackgroundTaskAttachment | null>(null);
const attachSeqRef = useRef(0);
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
const canCancel = task.status === "running" || task.status === "pending";
const canRetry = task.status === "failed" || task.status === "cancelled";
const canAttachLive = canCancel;
function detachLive(): void {
attachSeqRef.current += 1;
attachmentRef.current?.detach();
attachmentRef.current = null;
setLiveOpen(false);
}
useEffect(() => {
return () => {
attachSeqRef.current += 1;
attachmentRef.current?.detach();
attachmentRef.current = null;
};
}, [task.taskId]);
useEffect(() => {
if (!canAttachLive && attachmentRef.current) detachLive();
}, [canAttachLive]);
async function runAction(
action: (taskId: string) => Promise<void>,
@ -358,6 +384,36 @@ function BackgroundTaskRow({
}
}
async function toggleLive(): Promise<void> {
if (liveOpen || attachmentRef.current) {
detachLive();
return;
}
const seq = attachSeqRef.current + 1;
attachSeqRef.current = seq;
setLiveBusy(true);
setMessage(null);
setLiveOutput("");
try {
const decoder = new TextDecoder();
const attachment = await workState.attachBackgroundTask(task.taskId, (bytes) => {
setLiveOutput((prev) => prev + decoder.decode(bytes));
});
if (seq !== attachSeqRef.current) {
attachment.detach();
return;
}
attachmentRef.current = attachment;
setLiveOutput(decoder.decode(attachment.scrollback));
setLiveOpen(true);
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e));
} finally {
if (seq === attachSeqRef.current) setLiveBusy(false);
}
}
return (
<li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-start gap-2">
@ -386,6 +442,15 @@ function BackgroundTaskRow({
>
Cancel
</Button>
<Button
size="sm"
variant="ghost"
disabled={!canAttachLive || liveBusy}
loading={liveBusy}
onClick={() => void toggleLive()}
>
{liveOpen ? "Detach" : "Live"}
</Button>
<Button
size="sm"
variant="ghost"
@ -436,6 +501,14 @@ function BackgroundTaskRow({
{task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
</pre>
)}
{liveOpen && (
<pre
aria-label={`task ${shortTicket(task.taskId)} live output`}
className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-border bg-canvas p-2 text-[11px] text-muted"
>
{liveOutput || "No live output yet."}
</pre>
)}
</li>
);
}

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, {