feat(frontend): surface UI des tâches de fond (F1-F4)

Expose les tâches de fond côté front : modèle domaine et
normalisation du work state, panneau ProjectWorkStatePanel,
intégration dans LayoutGrid et ProjectsView, et abonnement aux
événements backgroundTaskChanged / agentInboxChanged /
agentWakeChanged pour rafraîchir l'état. npm run build + npm test
(449) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:33:51 +02:00
parent e05edc6863
commit 5d88c952ec
6 changed files with 450 additions and 9 deletions

View File

@ -8,8 +8,10 @@ import { Component, type ReactNode, useState } from "react";
import type {
AgentTicketState,
AgentWorkState,
BackgroundCompletion,
ConversationPreviewStatus,
ConversationWorkSummary,
InboxItem,
LeafCell,
} from "@/domain";
import { useGateways } from "@/app/di";
@ -85,6 +87,30 @@ function formatStatus(status: AgentTicketState["status"]): string {
return status === "inProgress" ? "In progress" : "Queued";
}
function formatTaskStatus(status: BackgroundCompletion["status"]): string {
switch (status) {
case "running":
return "Running";
case "completed":
return "Completed";
case "failed":
return "Failed";
case "cancelled":
return "Cancelled";
case "delivered":
return "Delivered";
case "pending":
return "Pending";
}
}
function taskStatusClass(status: BackgroundCompletion["status"]): string {
if (status === "running" || status === "pending") return "bg-warning/15 text-warning";
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
if (status === "failed" || status === "cancelled") return "bg-danger/10 text-danger";
return "bg-raised text-muted";
}
function requesterLabel(ticket: AgentTicketState): string {
if (ticket.source.kind === "human") return "Human";
const id = shortTicket(ticket.source.agentId);
@ -278,6 +304,92 @@ function TicketRow({
);
}
function InboxRow({ item }: { item: InboxItem }) {
return (
<li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 shrink-0 rounded-full bg-raised px-1.5 py-0.5 font-medium text-muted">
{item.kind}
</span>
<span className="min-w-0 flex-1 break-words">{item.body}</span>
<code
title={item.id}
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
>
{shortTicket(item.id)}
</code>
</div>
</li>
);
}
function BackgroundTaskRow({ task }: { task: BackgroundCompletion }) {
const [open, setOpen] = useState(false);
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
const canCancel = task.status === "running" || task.status === "pending";
const canRetry = task.status === "failed" || task.status === "cancelled";
return (
<li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-start gap-2">
<span
className={cn(
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
taskStatusClass(task.status),
)}
>
{formatTaskStatus(task.status)}
</span>
<span className="min-w-0 flex-1">
<span className="font-medium text-content">{task.kind}</span>
{task.exitCode !== null && (
<span className="text-muted"> · exit {task.exitCode}</span>
)}
</span>
<Button
size="sm"
variant="ghost"
disabled={!canCancel}
title="Backend command not exposed yet"
>
Cancel
</Button>
<Button
size="sm"
variant="ghost"
disabled={!hasOutput}
onClick={() => setOpen((v) => !v)}
>
Output
</Button>
<Button
size="sm"
variant="ghost"
disabled={!canRetry}
title="Backend command not exposed yet"
>
Retry
</Button>
<code
title={task.taskId}
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
>
{shortTicket(task.taskId)}
</code>
</div>
{open && hasOutput && (
<pre
aria-label={`task ${shortTicket(task.taskId)} output`}
className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-border bg-canvas p-2 text-[11px] text-muted"
>
{task.stdoutTail ? `stdout\n${task.stdoutTail}` : ""}
{task.stdoutTail && task.stderrTail ? "\n\n" : ""}
{task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
</pre>
)}
</li>
);
}
function AgentRow({
agent,
conversations,
@ -302,6 +414,13 @@ function AgentRow({
const busyState = agent.busy ?? { state: "idle" };
const busy = busyState.state === "busy";
const tickets = [...(agent.tickets ?? [])].sort((a, b) => a.position - b.position);
const inbox = [...(agent.inbox ?? [])].sort((a, b) => a.createdAtMs - b.createdAtMs);
const backgroundTasks = [...(agent.backgroundTasks ?? [])].sort((a, b) => {
const aDone = a.finishedAtMs ?? 0;
const bDone = b.finishedAtMs ?? 0;
return bDone - aDone || a.taskId.localeCompare(b.taskId);
});
const inboxDepth = Math.max(agent.inboxDepth ?? 0, inbox.length, tickets.length);
const visibleNodeIds = new Set(visibleLeaves.map((leaf) => leaf.id));
const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId);
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
@ -390,6 +509,14 @@ function AgentRow({
>
{busy ? "Busy" : "Idle"}
</span>
{inboxDepth > 0 && (
<span
aria-label={`${agent.name} queue depth ${inboxDepth}`}
className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
Queue {inboxDepth}
</span>
)}
{busyState.state === "busy" && (
<code
aria-label={`busy ticket ${shortTicket(busyState.ticket)}`}
@ -460,6 +587,33 @@ function AgentRow({
))}
</ul>
)}
{inbox.length > 0 && (
<div className="mt-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">
Inbox
</p>
<ul aria-label={`${agent.name} inbox`} className="mt-1 flex flex-col gap-1">
{inbox.map((item) => (
<InboxRow key={item.id} item={item} />
))}
</ul>
</div>
)}
{backgroundTasks.length > 0 && (
<div className="mt-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">
Background tasks
</p>
<ul
aria-label={`${agent.name} background tasks`}
className="mt-1 flex flex-col gap-1"
>
{backgroundTasks.map((task) => (
<BackgroundTaskRow key={task.taskId} task={task} />
))}
</ul>
</div>
)}
</li>
);
}

View File

@ -56,7 +56,10 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode
event.type === "agentExited" ||
event.type === "agentBusyChanged" ||
event.type === "delegationReady" ||
event.type === "orchestratorRequestProcessed"
event.type === "orchestratorRequestProcessed" ||
event.type === "backgroundTaskChanged" ||
event.type === "agentInboxChanged" ||
event.type === "agentWakeChanged"
) {
void refresh();
}