feat(workstate): UI des délégations en file par agent (Lot B frontend)

Affiche les tickets en attente dans `ProjectWorkStatePanel` à partir du
snapshot de file exposé par le read-model : type de domaine et adaptateur
mock alignés sur le DTO `camelCase`, panneau enrichi (requester, aperçu de
tâche, position FIFO), hook de lecture mis à jour.

Tests verts : workstate.test.tsx + projects.test.tsx (2 fichiers / 17),
`tsc --noEmit` OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:52:23 +02:00
parent cc7d99a63e
commit c60060494d
5 changed files with 300 additions and 41 deletions

View File

@ -1654,8 +1654,11 @@ export class MockWorkStateGateway implements WorkStateGateway {
private states = new Map<string, ProjectWorkState>(); private states = new Map<string, ProjectWorkState>();
/** Seeds the read-model returned for a project (deterministic tests/dev). */ /** Seeds the read-model returned for a project (deterministic tests/dev). */
_setProjectWorkState(projectId: string, state: ProjectWorkState): void { _setProjectWorkState(
this.states.set(projectId, structuredClone(state)); projectId: string,
state: ProjectWorkState | LegacyProjectWorkState,
): void {
this.states.set(projectId, normalizeProjectWorkState(state));
} }
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> { async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
@ -1663,6 +1666,23 @@ export class MockWorkStateGateway implements WorkStateGateway {
} }
} }
type LegacyProjectWorkState = {
agents: Array<Omit<ProjectWorkState["agents"][number], "tickets"> & {
tickets?: ProjectWorkState["agents"][number]["tickets"];
}>;
};
function normalizeProjectWorkState(
state: ProjectWorkState | LegacyProjectWorkState,
): ProjectWorkState {
return {
agents: state.agents.map((agent) => ({
...structuredClone(agent),
tickets: structuredClone(agent.tickets ?? []),
})),
};
}
function mostRestrictive( function mostRestrictive(
project?: PermissionSet["fallback"], project?: PermissionSet["fallback"],
agent?: PermissionSet["fallback"], agent?: PermissionSet["fallback"],

View File

@ -149,6 +149,26 @@ export type WorkBusyState =
| { state: "idle" } | { state: "idle" }
| { state: "busy"; ticket: string; sinceMs: number }; | { state: "busy"; ticket: string; sinceMs: number };
/** FIFO status for an input/ticket currently tracked by the backend queue. */
export type TicketWorkStatus = "inProgress" | "queued";
/** Who produced an input/ticket in the work-state read-model. */
export type TicketWorkSource =
| { kind: "human" }
| { kind: "agent"; agentId: string };
/** One queued or in-progress input/ticket for an agent. */
export interface AgentTicketState {
ticketId: string;
conversationId: string;
position: number;
status: TicketWorkStatus;
source: TicketWorkSource;
requesterLabel: string;
taskPreview: string;
taskLen: number;
}
/** One agent row in the project work-state read-model. */ /** One agent row in the project work-state read-model. */
export interface AgentWorkState { export interface AgentWorkState {
agentId: string; agentId: string;
@ -156,6 +176,7 @@ export interface AgentWorkState {
profileId: string; profileId: string;
live?: LiveWorkSession; live?: LiveWorkSession;
busy: WorkBusyState; busy: WorkBusyState;
tickets: AgentTicketState[];
} }
/** Minimal read-only live-state surface for a project. */ /** Minimal read-only live-state surface for a project. */

View File

@ -1,9 +1,9 @@
/** /**
* Read-only project work-state panel: one row per agent, showing live/offline * Read-only project work-state panel: one row per agent, showing live/offline
* and idle/busy state from the backend read-model. * and idle/busy state from the backend read-model, plus the current input queue.
*/ */
import type { AgentWorkState } from "@/domain"; import type { AgentTicketState, AgentWorkState } from "@/domain";
import { Button, Panel, Spinner, cn } from "@/shared"; import { Button, Panel, Spinner, cn } from "@/shared";
import { useProjectWorkState } from "./useProjectWorkState"; import { useProjectWorkState } from "./useProjectWorkState";
@ -15,48 +15,112 @@ function shortTicket(ticket: string): string {
return ticket.length <= 8 ? ticket : ticket.slice(0, 8); return ticket.length <= 8 ? ticket : ticket.slice(0, 8);
} }
function formatStatus(status: AgentTicketState["status"]): string {
return status === "inProgress" ? "In progress" : "Queued";
}
function requesterLabel(ticket: AgentTicketState): string {
if (ticket.source.kind === "human") return "Human";
const id = shortTicket(ticket.source.agentId);
return ticket.requesterLabel.trim()
? `${ticket.requesterLabel} (${id})`
: `Agent ${id}`;
}
function TicketRow({ ticket }: { ticket: AgentTicketState }) {
const truncated = ticket.taskLen > ticket.taskPreview.length;
return (
<li className="flex min-w-0 items-start gap-2 text-xs text-muted">
<span
className={cn(
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
ticket.status === "inProgress"
? "bg-warning/15 text-warning"
: "bg-raised text-muted",
)}
>
#{ticket.position + 1} {formatStatus(ticket.status)}
</span>
<span className="min-w-0 flex-1">
<span className="font-medium text-content">{requesterLabel(ticket)}</span>
<span className="text-muted"> · </span>
<span className="break-words">{ticket.taskPreview}</span>
{truncated && (
<span
aria-label={`${ticket.taskLen - ticket.taskPreview.length} more characters`}
className="text-muted"
>
{" "}
+{ticket.taskLen - ticket.taskPreview.length}
</span>
)}
</span>
<code
aria-label={`ticket ${shortTicket(ticket.ticketId)}`}
title={ticket.ticketId}
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
>
{shortTicket(ticket.ticketId)}
</code>
</li>
);
}
function AgentRow({ agent }: { agent: AgentWorkState }) { function AgentRow({ agent }: { agent: AgentWorkState }) {
const live = agent.live !== undefined; const live = agent.live !== undefined;
const busy = agent.busy.state === "busy"; const busy = agent.busy.state === "busy";
const tickets = [...agent.tickets].sort((a, b) => a.position - b.position);
return ( return (
<li className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0"> <li className="py-2 first:pt-0 last:pb-0">
<span className="flex min-w-0 flex-col gap-0.5"> <div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-content"> <span className="flex min-w-0 flex-col gap-0.5">
{agent.name} <span className="truncate text-sm font-medium text-content">
{agent.name}
</span>
<code className="truncate text-xs text-muted">{agent.profileId}</code>
</span> </span>
<code className="truncate text-xs text-muted">{agent.profileId}</code> <span className="flex shrink-0 items-center gap-1.5">
</span> <span
<span className="flex shrink-0 items-center gap-1.5"> className={cn(
<span "rounded-full px-2 py-0.5 text-xs font-medium",
className={cn( live
"rounded-full px-2 py-0.5 text-xs font-medium", ? "bg-success/15 text-success"
live : "bg-raised text-muted",
? "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>
{agent.busy.state === "busy" && (
<code
aria-label={`busy ticket ${shortTicket(agent.busy.ticket)}`}
title={agent.busy.ticket}
className="rounded bg-raised px-1.5 py-0.5 text-xs text-muted"
> >
{shortTicket(agent.busy.ticket)} {live ? "Live" : "Offline"}
</code> </span>
)} <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>
{agent.busy.state === "busy" && (
<code
aria-label={`busy ticket ${shortTicket(agent.busy.ticket)}`}
title={agent.busy.ticket}
className="rounded bg-raised px-1.5 py-0.5 text-xs text-muted"
>
{shortTicket(agent.busy.ticket)}
</code>
)}
</span>
</div>
{tickets.length > 0 && (
<ul
aria-label={`${agent.name} tickets`}
className="mt-2 flex flex-col gap-1"
>
{tickets.map((ticket) => (
<TicketRow key={ticket.ticketId} ticket={ticket} />
))}
</ul>
)}
</li> </li>
); );
} }

View File

@ -55,6 +55,7 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode
event.type === "agentLaunched" || event.type === "agentLaunched" ||
event.type === "agentExited" || event.type === "agentExited" ||
event.type === "agentBusyChanged" || event.type === "agentBusyChanged" ||
event.type === "delegationReady" ||
event.type === "orchestratorRequestProcessed" event.type === "orchestratorRequestProcessed"
) { ) {
void refresh(); void refresh();

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock"; import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
@ -49,6 +49,7 @@ describe("ProjectWorkStatePanel", () => {
expect(await screen.findByText("Planner")).toBeTruthy(); expect(await screen.findByText("Planner")).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy(); expect(screen.getByText("Offline")).toBeTruthy();
expect(screen.getByText("Idle")).toBeTruthy(); expect(screen.getByText("Idle")).toBeTruthy();
expect(screen.queryByLabelText("Planner tickets")).toBeNull();
}); });
it("renders a live busy agent with a short ticket", async () => { it("renders a live busy agent with a short ticket", async () => {
@ -81,6 +82,116 @@ describe("ProjectWorkStatePanel", () => {
expect(screen.getByLabelText("busy ticket 4c65d981")).toBeTruthy(); expect(screen.getByLabelText("busy ticket 4c65d981")).toBeTruthy();
}); });
it("renders an in-progress human ticket", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-4",
name: "Implementer",
profileId: "codex",
busy: {
state: "busy",
ticket: "ticket-in-progress-abcdef",
sinceMs: 789,
},
tickets: [
{
ticketId: "ticket-in-progress-abcdef",
conversationId: "conversation-a",
position: 0,
status: "inProgress",
source: { kind: "human" },
requesterLabel: "Anthony",
taskPreview: "Wire the workstate queue UI",
taskLen: 36,
},
],
},
],
});
renderPanel(workState);
const list = await screen.findByLabelText("Implementer tickets");
expect(within(list).getByText("#1 In progress")).toBeTruthy();
expect(within(list).getByText("Human")).toBeTruthy();
expect(within(list).getByText("Wire the workstate queue UI")).toBeTruthy();
expect(within(list).getByLabelText("ticket ticket-i")).toBeTruthy();
expect(within(list).getByLabelText("9 more characters")).toBeTruthy();
});
it("renders two tickets in FIFO order", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-5",
name: "Reviewer",
profileId: "claude",
busy: { state: "idle" },
tickets: [
{
ticketId: "ticket-second-abcdef",
conversationId: "conversation-b",
position: 1,
status: "queued",
source: { kind: "agent", agentId: "agent-main-123456" },
requesterLabel: "Main",
taskPreview: "Then review the result",
taskLen: 22,
},
{
ticketId: "ticket-first-abcdef",
conversationId: "conversation-a",
position: 0,
status: "inProgress",
source: { kind: "agent", agentId: "agent-qa-123456" },
requesterLabel: "QA",
taskPreview: "Check behavior",
taskLen: 14,
},
],
},
],
});
renderPanel(workState);
const list = await screen.findByLabelText("Reviewer tickets");
expect(within(list).getByText("QA (agent-qa)")).toBeTruthy();
expect(within(list).getByText("Main (agent-ma)")).toBeTruthy();
expect(list.textContent?.indexOf("#1 In progress")).toBeLessThan(
list.textContent?.indexOf("#2 Queued") ?? -1,
);
});
it("normalizes mock work state without tickets", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-6",
name: "Legacy",
profileId: "gemini",
busy: { state: "idle" },
},
],
});
await expect(workState.getProjectWorkState(PROJECT_ID)).resolves.toEqual({
agents: [
{
agentId: "agent-6",
name: "Legacy",
profileId: "gemini",
busy: { state: "idle" },
tickets: [],
},
],
});
});
it("refreshes when a relevant domain event fires", async () => { it("refreshes when a relevant domain event fires", async () => {
const workState = new MockWorkStateGateway(); const workState = new MockWorkStateGateway();
const system = new MockSystemGateway(); const system = new MockSystemGateway();
@ -108,4 +219,46 @@ describe("ProjectWorkStatePanel", () => {
expect(await screen.findByText("Responder")).toBeTruthy(); expect(await screen.findByText("Responder")).toBeTruthy();
await waitFor(() => expect(spy).toHaveBeenCalled()); await waitFor(() => expect(spy).toHaveBeenCalled());
}); });
it("refreshes when delegationReady fires", async () => {
const workState = new MockWorkStateGateway();
const system = new MockSystemGateway();
renderPanel(workState, system);
await screen.findByText("No agent work state.");
const spy = vi.spyOn(workState, "getProjectWorkState");
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-7",
name: "Delegatee",
profileId: "codex",
busy: { state: "busy", ticket: "ticket-ready-123456", sinceMs: 123 },
tickets: [
{
ticketId: "ticket-ready-123456",
conversationId: "conversation-ready",
position: 0,
status: "inProgress",
source: { kind: "agent", agentId: "agent-main-123456" },
requesterLabel: "Main",
taskPreview: "Handle this delegated task",
taskLen: 26,
},
],
},
],
});
system.emit({
type: "delegationReady",
agentId: "agent-7",
ticket: "ticket-ready-123456",
text: "Handle this delegated task",
});
expect(await screen.findByText("Delegatee")).toBeTruthy();
expect(screen.getByText("#1 In progress")).toBeTruthy();
await waitFor(() => expect(spy).toHaveBeenCalled());
});
}); });