From 8760ade168deac958dc88ba301d66c9df983113b Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 21 Aug 2026 17:52:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(workstate):=20panneau=20Work=20state=20sim?= =?UTF-8?q?plifi=C3=A9=20=E2=80=94=20=C3=A9tat=20+=20intitul=C3=A9=20simpl?= =?UTF-8?q?es,=20d=C3=A9tails=20et=20background=20tasks=20repli=C3=A9s=20p?= =?UTF-8?q?ar=20d=C3=A9faut=20=E2=80=94=20#235?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workstate/ProjectWorkStatePanel.tsx | 298 ++++++++++-------- .../src/features/workstate/workstate.test.tsx | 52 ++- 2 files changed, 217 insertions(+), 133 deletions(-) diff --git a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx index c36e490a..52d2d4c8 100644 --- a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx +++ b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx @@ -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(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 { await onRefresh(); @@ -623,141 +662,148 @@ function AgentRow({ return (
  • -
    - - +
    + + {agent.name} - {agent.profileId} - - - - {live ? "Live" : "Offline"} - - - {busy ? "Busy" : "Idle"} - - {inboxDepth > 0 && ( - - Queue {inboxDepth} + {activityLine && ( + + {activityLine} )} - {busyState.state === "busy" && ( - + + + {statusLabel} + + {hasDetails && ( + - )} - {agent.live && !liveVisible && ( - - )} - {agent.live && ( - + {detailsOpen ? "Hide" : "Details"} + )}
    - {agent.live && !liveVisible && !attachTarget && ( -

    No empty visible cell.

    - )} - {message && ( -

    - {message} -

    - )} - {tickets.length > 0 && ( -
      - {tickets.map((ticket) => ( - - ))} -
    - )} - {inbox.length > 0 && ( -
    -

    - Inbox -

    -
      - {inbox.map((item) => ( - - ))} -
    -
    - )} - {backgroundTasks.length > 0 && ( -
    -

    - Background tasks -

    -
      - {backgroundTasks.map((task) => ( - - ))} -
    + {agent.live && ( +
    + {agent.live && liveVisible && ( + + )} + {agent.live && !liveVisible && ( + + )} + {agent.live && ( + + )} +
    + )} + {agent.live && !liveVisible && !attachTarget && ( +

    No empty visible cell.

    + )} + {message && ( +

    + {message} +

    + )} + {tickets.length > 0 && ( +
      + {tickets.map((ticket) => ( + + ))} +
    + )} + {inbox.length > 0 && ( +
    +

    + Inbox +

    +
      + {inbox.map((item) => ( + + ))} +
    +
    + )} + {backgroundTasks.length > 0 && ( +
    + + {backgroundOpen && ( +
      + {backgroundTasks.map((task) => ( + + ))} +
    + )} +
    + )}
    )}
  • diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx index 030e095a..7f006916 100644 --- a/frontend/src/features/workstate/workstate.test.tsx +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -34,6 +34,22 @@ function renderPanel( }; } +async function openAgentDetails(agentName: string): Promise { + 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 { + 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" }));