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:
@ -66,6 +66,12 @@ function fixedWorkState(): WorkStateGateway {
|
||||
};
|
||||
return {
|
||||
getProjectWorkState: async () => structuredClone(state),
|
||||
attachBackgroundTask: async (taskId) => ({
|
||||
taskId,
|
||||
scrollback: new Uint8Array(),
|
||||
live: false,
|
||||
detach: () => {},
|
||||
}),
|
||||
cancelBackgroundTask: async () => {},
|
||||
retryBackgroundTask: async () => {},
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user