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>
);
}