merge(workstate): panneau Work state simplifié — #235 (QA verte)

This commit is contained in:
2026-08-21 17:52:44 +02:00
2 changed files with 217 additions and 133 deletions

View File

@ -138,6 +138,27 @@ function canRetryTask(status: BackgroundCompletion["status"]): boolean {
return status === "failed" || status === "cancelFailed" || status === "cancelled";
}
function agentStatusLabel(live: boolean, busy: boolean): string {
if (busy) return "Busy";
return live ? "Idle" : "Offline";
}
function agentStatusClass(live: boolean, busy: boolean): string {
if (busy) return "bg-warning/15 text-warning";
if (live) return "bg-success/10 text-success";
return "bg-raised text-muted";
}
function backgroundTaskSummary(tasks: BackgroundCompletion[]): string | null {
if (tasks.length === 0) return null;
const running = tasks.filter((task) => canCancelTask(task.status)).length;
const failed = tasks.filter((task) => canRetryTask(task.status)).length;
if (running > 0 && failed > 0) return `${tasks.length} background tasks (${running} active, ${failed} failed)`;
if (running > 0) return `${running} background task${running > 1 ? "s" : ""} active`;
if (failed > 0) return `${failed} background task${failed > 1 ? "s" : ""} need attention`;
return `${tasks.length} background task${tasks.length > 1 ? "s" : ""}`;
}
function requesterLabel(ticket: AgentTicketState): string {
if (ticket.source.kind === "human") return "Human";
const id = shortTicket(ticket.source.agentId);
@ -554,6 +575,8 @@ function AgentRow({
const { agent: agentGateway } = useGateways();
const [message, setMessage] = useState<string | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [backgroundOpen, setBackgroundOpen] = useState(false);
const live = agent.live !== undefined;
const busyState = agent.busy ?? { state: "idle" };
const busy = busyState.state === "busy";
@ -568,6 +591,22 @@ function AgentRow({
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
const currentTurn =
busy || tickets.some((ticket) => ticket.status === "inProgress");
const activeTicket = tickets.find((ticket) => ticket.status === "inProgress");
const statusLabel = agentStatusLabel(live, busy);
const taskSummary = backgroundTaskSummary(backgroundTasks);
const activityLine =
activeTicket?.taskPreview ??
(busyState.state === "busy" ? `Ticket ${shortTicket(busyState.ticket)}` : null) ??
(inboxDepth > 0 ? `${inboxDepth} queued` : null) ??
taskSummary;
const hasActions = Boolean(agent.live);
const hasDetails =
hasActions ||
tickets.length > 0 ||
inbox.length > 0 ||
backgroundTasks.length > 0 ||
message ||
(agent.live && !liveVisible && !attachTarget);
async function refreshAfterAction(): Promise<void> {
await onRefresh();
@ -623,141 +662,148 @@ function AgentRow({
return (
<li className="py-2 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium text-content">
<div className="flex min-w-0 items-start justify-between gap-3">
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-content">
{agent.name}
</span>
<code className="truncate text-xs text-muted">{agent.profileId}</code>
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
live
? "bg-success/15 text-success"
: "bg-raised text-muted",
)}
>
{live ? "Live" : "Offline"}
</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
busy
? "bg-warning/15 text-warning"
: "bg-raised text-muted",
)}
>
{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}
{activityLine && (
<span className="mt-0.5 block truncate text-xs text-muted">
{activityLine}
</span>
)}
{busyState.state === "busy" && (
<code
aria-label={`busy ticket ${shortTicket(busyState.ticket)}`}
title={busyState.ticket}
className="rounded bg-raised px-1.5 py-0.5 text-xs text-muted"
</span>
<span className="flex shrink-0 items-center gap-2">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
agentStatusClass(live, busy),
)}
>
{statusLabel}
</span>
{hasDetails && (
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-muted hover:bg-raised hover:text-content"
aria-expanded={detailsOpen}
aria-controls={`work-agent-details-${agent.agentId}`}
onClick={() => setDetailsOpen((value) => !value)}
>
{shortTicket(busyState.ticket)}
</code>
)}
{agent.live && liveVisible && (
<Button
size="sm"
variant="ghost"
onClick={() => goToCell(agent.live!.nodeId)}
>
Open
</Button>
)}
{agent.live && !liveVisible && (
<Button
size="sm"
variant="ghost"
disabled={!attachTarget || !agentGateway?.attachLiveAgent}
loading={actionBusy}
title={
attachTarget
? undefined
: "No empty visible cell available"
}
onClick={() => void attach()}
>
Attach
</Button>
)}
{agent.live && (
<Button
size="sm"
variant="danger"
loading={actionBusy}
disabled={!agentGateway?.stopLiveAgent}
onClick={() => void stop()}
>
Stop
</Button>
{detailsOpen ? "Hide" : "Details"}
</button>
)}
</span>
</div>
{agent.live && !liveVisible && !attachTarget && (
<p className="mt-1 text-xs text-muted">No empty visible cell.</p>
)}
{message && (
<p role="alert" className="mt-1 text-xs text-danger">
{message}
</p>
)}
{tickets.length > 0 && (
<ul
aria-label={`${agent.name} tickets`}
className="mt-2 flex flex-col gap-1"
{detailsOpen && (
<div
id={`work-agent-details-${agent.agentId}`}
className="mt-2 space-y-2 border-l border-border pl-3"
>
{tickets.map((ticket) => (
<TicketRow
key={ticket.ticketId}
ticket={ticket}
summary={conversations.get(ticket.conversationId)}
onOpenConversation={onOpenConversation}
/>
))}
</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}
onRefresh={refreshAfterAction}
/>
))}
</ul>
{agent.live && (
<div className="flex min-w-0 flex-wrap gap-1.5">
{agent.live && liveVisible && (
<Button
size="sm"
variant="ghost"
onClick={() => goToCell(agent.live!.nodeId)}
>
Open
</Button>
)}
{agent.live && !liveVisible && (
<Button
size="sm"
variant="ghost"
disabled={!attachTarget || !agentGateway?.attachLiveAgent}
loading={actionBusy}
title={
attachTarget
? undefined
: "No empty visible cell available"
}
onClick={() => void attach()}
>
Attach
</Button>
)}
{agent.live && (
<Button
size="sm"
variant="danger"
loading={actionBusy}
disabled={!agentGateway?.stopLiveAgent}
onClick={() => void stop()}
>
Stop
</Button>
)}
</div>
)}
{agent.live && !liveVisible && !attachTarget && (
<p className="text-xs text-muted">No empty visible cell.</p>
)}
{message && (
<p role="alert" className="text-xs text-danger">
{message}
</p>
)}
{tickets.length > 0 && (
<ul
aria-label={`${agent.name} tickets`}
className="flex flex-col gap-1"
>
{tickets.map((ticket) => (
<TicketRow
key={ticket.ticketId}
ticket={ticket}
summary={conversations.get(ticket.conversationId)}
onOpenConversation={onOpenConversation}
/>
))}
</ul>
)}
{inbox.length > 0 && (
<div>
<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>
<button
type="button"
className="text-xs font-medium text-muted hover:text-content hover:underline"
aria-expanded={backgroundOpen}
aria-controls={`work-agent-background-${agent.agentId}`}
onClick={() => setBackgroundOpen((value) => !value)}
>
{backgroundOpen ? "Hide background tasks" : taskSummary}
</button>
{backgroundOpen && (
<ul
id={`work-agent-background-${agent.agentId}`}
aria-label={`${agent.name} background tasks`}
className="mt-1 flex flex-col gap-1"
>
{backgroundTasks.map((task) => (
<BackgroundTaskRow
key={task.taskId}
task={task}
onRefresh={refreshAfterAction}
/>
))}
</ul>
)}
</div>
)}
</div>
)}
</li>

View File

@ -34,6 +34,22 @@ function renderPanel(
};
}
async function openAgentDetails(agentName: string): Promise<HTMLElement> {
const name = await screen.findByText(agentName);
const row = name.closest("li") as HTMLElement | null;
expect(row).toBeTruthy();
const button = within(row!).queryByRole("button", { name: "Details" });
if (button) fireEvent.click(button);
return row!;
}
async function openBackgroundTasks(agentName: string): Promise<HTMLElement> {
const row = await openAgentDetails(agentName);
const toggle = within(row).getByRole("button", { name: /background task/i });
fireEvent.click(toggle);
return await within(row).findByLabelText(`${agentName} background tasks`);
}
describe("ProjectWorkStatePanel", () => {
it("renders the empty state", async () => {
renderPanel();
@ -58,7 +74,7 @@ describe("ProjectWorkStatePanel", () => {
expect(await screen.findByText("Planner")).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
expect(screen.getByText("Idle")).toBeTruthy();
expect(screen.queryByText("Idle")).toBeNull();
expect(screen.queryByLabelText("Planner tickets")).toBeNull();
});
@ -87,9 +103,8 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Builder")).toBeTruthy();
expect(screen.getByText("Live")).toBeTruthy();
expect(screen.getByText("Busy")).toBeTruthy();
expect(screen.getByLabelText("busy ticket 4c65d981")).toBeTruthy();
expect(screen.getByText("Ticket 4c65d981")).toBeTruthy();
});
it("renders an in-progress human ticket", async () => {
@ -123,6 +138,9 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Wire the workstate queue UI")).toBeTruthy();
expect(screen.queryByLabelText("Implementer tickets")).toBeNull();
await openAgentDetails("Implementer");
const list = await screen.findByLabelText("Implementer tickets");
expect(within(list).getByText("#1 In progress")).toBeTruthy();
expect(within(list).getByText("Human")).toBeTruthy();
@ -168,6 +186,9 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Reviewer")).toBeTruthy();
expect(screen.queryByLabelText("Reviewer tickets")).toBeNull();
await openAgentDetails("Reviewer");
const list = await screen.findByLabelText("Reviewer tickets");
expect(within(list).getByText("QA (agent-qa)")).toBeTruthy();
expect(within(list).getByText("Main (agent-ma)")).toBeTruthy();
@ -270,7 +291,9 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
const list = await screen.findByLabelText("Runner background tasks");
expect(await screen.findByText("6 background tasks (3 active, 3 failed)")).toBeTruthy();
expect(screen.queryByLabelText("Runner background tasks")).toBeNull();
const list = await openBackgroundTasks("Runner");
const cancelButtons = within(list).getAllByRole("button", { name: "Cancel" });
const retryButtons = within(list).getAllByRole("button", { name: "Retry" });
// waiting + queued + cancelling are cancellable; failed + expired + cancelFailed are retryable.
@ -355,7 +378,9 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
const list = await screen.findByLabelText("Reporter background tasks");
expect(await screen.findByText("4 background tasks (1 active, 1 failed)")).toBeTruthy();
expect(screen.queryByLabelText("Reporter background tasks")).toBeNull();
const list = await openBackgroundTasks("Reporter");
// Terminal tasks with a summary render it discreetly.
expect(
within(list).getByLabelText("task done-wit summary").textContent,
@ -400,7 +425,7 @@ describe("ProjectWorkStatePanel", () => {
const attachSpy = vi.spyOn(workState, "attachBackgroundTask");
const view = renderPanel(workState);
const list = await screen.findByLabelText("Runner background tasks");
const list = await openBackgroundTasks("Runner");
fireEvent.click(within(list).getByRole("button", { name: "Live" }));
@ -447,7 +472,7 @@ describe("ProjectWorkStatePanel", () => {
expect(await screen.findByText("Legacy")).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
expect(screen.getByText("Idle")).toBeTruthy();
expect(screen.queryByText("Idle")).toBeNull();
expect(screen.queryByLabelText("Legacy tickets")).toBeNull();
});
@ -577,6 +602,9 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Continue implementation")).toBeTruthy();
expect(screen.queryByLabelText("Summarizer tickets")).toBeNull();
await openAgentDetails("Summarizer");
const list = await screen.findByLabelText("Summarizer tickets");
expect(within(list).getAllByText("Summary")).toHaveLength(2);
expect(
@ -654,6 +682,9 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
expect(await screen.findByText("Validate partial context")).toBeTruthy();
expect(screen.queryByLabelText("Contextualizer tickets")).toBeNull();
await openAgentDetails("Contextualizer");
const list = await screen.findByLabelText("Contextualizer tickets");
expect(within(list).getByText("#1 In progress")).toBeTruthy();
expect(within(list).getByText("Validate partial context")).toBeTruthy();
@ -735,6 +766,7 @@ describe("ProjectWorkStatePanel", () => {
});
expect(await screen.findByText("Delegatee")).toBeTruthy();
await openAgentDetails("Delegatee");
expect(screen.getByText("#1 In progress")).toBeTruthy();
await waitFor(() => expect(spy).toHaveBeenCalled());
});
@ -764,6 +796,7 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState, new MockSystemGateway(), { layout, agent });
await openAgentDetails("Visible");
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
expect(host.style.outline).toContain("2px solid");
@ -804,6 +837,7 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState, new MockSystemGateway(), { layout, agent });
await openAgentDetails("Hidden");
const attach = await screen.findByRole("button", { name: "Attach" });
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(attach);
@ -852,6 +886,7 @@ describe("ProjectWorkStatePanel", () => {
agent: new MockAgentGateway(),
});
await openAgentDetails("Hidden");
const attach = await screen.findByRole("button", { name: "Attach" });
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(true));
expect(await screen.findByText("No empty visible cell.")).toBeTruthy();
@ -912,6 +947,7 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState, new MockSystemGateway(), { layout, agent });
await openAgentDetails("Runner");
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
await waitFor(() =>
@ -974,6 +1010,7 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
await openAgentDetails("Viewer");
const list = await screen.findByLabelText("Viewer tickets");
fireEvent.click(within(list).getByRole("button", { name: "View" }));
@ -1036,6 +1073,7 @@ describe("ProjectWorkStatePanel", () => {
renderPanel(workState);
await openAgentDetails("Copier");
const list = await screen.findByLabelText("Copier tickets");
fireEvent.click(within(list).getByRole("button", { name: "Copy" }));