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:
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user