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:
@ -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"],
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
@ -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,11 +15,64 @@ 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">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="flex min-w-0 flex-col gap-0.5">
|
<span className="flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="truncate text-sm font-medium text-content">
|
<span className="truncate text-sm font-medium text-content">
|
||||||
{agent.name}
|
{agent.name}
|
||||||
@ -57,6 +110,17 @@ function AgentRow({ agent }: { agent: AgentWorkState }) {
|
|||||||
</code>
|
</code>
|
||||||
)}
|
)}
|
||||||
</span>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user