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:
@ -317,6 +317,16 @@ function LeafView({
|
||||
? workState?.agents.find((row) => row.agentId === agentId)
|
||||
: undefined;
|
||||
const activeTicket = agentWork?.tickets.find((ticket) => ticket.status === "inProgress");
|
||||
const queuedTickets = agentWork?.tickets.filter((ticket) => ticket.status === "queued") ?? [];
|
||||
const inboxDepth = Math.max(
|
||||
agentWork?.inboxDepth ?? 0,
|
||||
agentWork?.inbox?.length ?? 0,
|
||||
queuedTickets.length,
|
||||
);
|
||||
const completedBackgroundTasks =
|
||||
agentWork?.backgroundTasks?.filter((task) =>
|
||||
task.status === "completed" || task.status === "failed" || task.status === "cancelled",
|
||||
) ?? [];
|
||||
const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket);
|
||||
const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
|
||||
const historyConversationId =
|
||||
@ -661,7 +671,7 @@ function LeafView({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{agentId && (busyByWorkState || historyConversationId) && (
|
||||
{agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0 || historyConversationId) && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@ -699,6 +709,60 @@ function LeafView({
|
||||
: "Busy"}
|
||||
</span>
|
||||
)}
|
||||
{!busyByWorkState && inboxDepth > 0 && (
|
||||
<span
|
||||
title={`${inboxDepth} pending message${inboxDepth === 1 ? "" : "s"}`}
|
||||
style={{
|
||||
maxWidth: 180,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
border: "1px solid var(--color-primary, #5b9bd5)",
|
||||
borderRadius: 3,
|
||||
background: "rgba(91, 155, 213, 0.14)",
|
||||
color: "var(--color-primary, #5b9bd5)",
|
||||
padding: "1px 6px",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Queued · {inboxDepth}
|
||||
</span>
|
||||
)}
|
||||
{busyByWorkState && inboxDepth > 0 && (
|
||||
<span
|
||||
title={`${inboxDepth} queued message${inboxDepth === 1 ? "" : "s"}`}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
border: "1px solid var(--color-primary, #5b9bd5)",
|
||||
borderRadius: 3,
|
||||
background: "rgba(91, 155, 213, 0.14)",
|
||||
color: "var(--color-primary, #5b9bd5)",
|
||||
padding: "1px 6px",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Queued {inboxDepth}
|
||||
</span>
|
||||
)}
|
||||
{completedBackgroundTasks.length > 0 && (
|
||||
<span
|
||||
title={`${completedBackgroundTasks.length} background task result${completedBackgroundTasks.length === 1 ? "" : "s"}`}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
border: "1px solid var(--color-success, #57b36a)",
|
||||
borderRadius: 3,
|
||||
background: "rgba(87, 179, 106, 0.14)",
|
||||
color: "var(--color-success, #57b36a)",
|
||||
padding: "1px 6px",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Task done {completedBackgroundTasks.length}
|
||||
</span>
|
||||
)}
|
||||
{busyByWorkState && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { LayoutInfo } from "@/domain";
|
||||
import type { DomainEvent, LayoutInfo } from "@/domain";
|
||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||
import { AgentsPanel } from "@/features/agents";
|
||||
import { TemplatesPanel } from "@/features/templates";
|
||||
@ -69,6 +69,26 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||
{ id: "git", label: "Git" },
|
||||
];
|
||||
|
||||
interface BackgroundTaskToast {
|
||||
id: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
taskId: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
function isTerminalBackgroundTaskEvent(
|
||||
event: DomainEvent,
|
||||
): event is Extract<DomainEvent, { type: "backgroundTaskChanged" }> {
|
||||
return (
|
||||
event.type === "backgroundTaskChanged" &&
|
||||
(event.state === "completed" ||
|
||||
event.state === "failed" ||
|
||||
event.state === "cancelled" ||
|
||||
event.state === "delivered")
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsView() {
|
||||
const vm = useProjects();
|
||||
const { system } = useGateways();
|
||||
@ -86,6 +106,7 @@ export function ProjectsView() {
|
||||
const [viewerConversationId, setViewerConversationId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
||||
|
||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||
|
||||
@ -99,6 +120,32 @@ export function ProjectsView() {
|
||||
setViewerConversationId(null);
|
||||
}, [active?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system.onDomainEvent((event) => {
|
||||
if (!isTerminalBackgroundTaskEvent(event)) return;
|
||||
const toast: BackgroundTaskToast = {
|
||||
id: `${event.taskId}-${event.state}-${Date.now()}`,
|
||||
projectId: event.projectId,
|
||||
agentId: event.agentId,
|
||||
taskId: event.taskId,
|
||||
state: event.state,
|
||||
};
|
||||
setTaskToasts((prev) => [...prev.slice(-2), toast]);
|
||||
window.setTimeout(() => {
|
||||
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
|
||||
}, 7000);
|
||||
}).then((u) => {
|
||||
if (cancelled) u();
|
||||
else unsubscribe = u;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [system]);
|
||||
|
||||
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
||||
|
||||
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
|
||||
@ -394,6 +441,32 @@ export function ProjectsView() {
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
{taskToasts.length > 0 && (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
|
||||
{taskToasts.map((toast) => (
|
||||
<button
|
||||
key={toast.id}
|
||||
type="button"
|
||||
className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong"
|
||||
onClick={() => {
|
||||
const projectOpen = vm.openTabs.some((tab) => tab.id === toast.projectId);
|
||||
if (projectOpen) vm.activateTab(toast.projectId);
|
||||
setSidebarCollapsed(false);
|
||||
setSidebarTab("work");
|
||||
setViewerConversationId(null);
|
||||
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
|
||||
}}
|
||||
>
|
||||
<span className="block text-sm font-medium text-content">
|
||||
Background task {toast.state}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-muted">
|
||||
{toast.agentId.slice(0, 8)} · {toast.taskId.slice(0, 8)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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