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

@ -1,9 +1,9 @@
/**
* 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 { useProjectWorkState } from "./useProjectWorkState";
@ -15,48 +15,112 @@ function shortTicket(ticket: string): string {
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 }) {
const live = agent.live !== undefined;
const busy = agent.busy.state === "busy";
const tickets = [...agent.tickets].sort((a, b) => a.position - b.position);
return (
<li className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0">
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium text-content">
{agent.name}
<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">
{agent.name}
</span>
<code className="truncate text-xs text-muted">{agent.profileId}</code>
</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>
{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"
<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",
)}
>
{shortTicket(agent.busy.ticket)}
</code>
)}
</span>
{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)}
</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>
);
}