merge(workstate): panneau Work state simplifié — #235 (QA verte)
This commit is contained in:
@ -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,51 +662,47 @@ 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>
|
||||
{activityLine && (
|
||||
<span className="mt-0.5 block truncate text-xs text-muted">
|
||||
{activityLine}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
live
|
||||
? "bg-success/15 text-success"
|
||||
: "bg-raised text-muted",
|
||||
agentStatusClass(live, busy),
|
||||
)}
|
||||
>
|
||||
{live ? "Live" : "Offline"}
|
||||
{statusLabel}
|
||||
</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",
|
||||
)}
|
||||
{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)}
|
||||
>
|
||||
{busy ? "Busy" : "Idle"}
|
||||
{detailsOpen ? "Hide" : "Details"}
|
||||
</button>
|
||||
)}
|
||||
</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"
|
||||
</div>
|
||||
|
||||
{detailsOpen && (
|
||||
<div
|
||||
id={`work-agent-details-${agent.agentId}`}
|
||||
className="mt-2 space-y-2 border-l border-border pl-3"
|
||||
>
|
||||
Queue {inboxDepth}
|
||||
</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"
|
||||
>
|
||||
{shortTicket(busyState.ticket)}
|
||||
</code>
|
||||
)}
|
||||
{agent.live && (
|
||||
<div className="flex min-w-0 flex-wrap gap-1.5">
|
||||
{agent.live && liveVisible && (
|
||||
<Button
|
||||
size="sm"
|
||||
@ -704,20 +739,20 @@ function AgentRow({
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{agent.live && !liveVisible && !attachTarget && (
|
||||
<p className="mt-1 text-xs text-muted">No empty visible cell.</p>
|
||||
<p className="text-xs text-muted">No empty visible cell.</p>
|
||||
)}
|
||||
{message && (
|
||||
<p role="alert" className="mt-1 text-xs text-danger">
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{tickets.length > 0 && (
|
||||
<ul
|
||||
aria-label={`${agent.name} tickets`}
|
||||
className="mt-2 flex flex-col gap-1"
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{tickets.map((ticket) => (
|
||||
<TicketRow
|
||||
@ -730,7 +765,7 @@ function AgentRow({
|
||||
</ul>
|
||||
)}
|
||||
{inbox.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||
Inbox
|
||||
</p>
|
||||
@ -742,11 +777,19 @@ function AgentRow({
|
||||
</div>
|
||||
)}
|
||||
{backgroundTasks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||
Background tasks
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
@ -758,6 +801,9 @@ function AgentRow({
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@ -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" }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user