feat(workstate): UI live-state des conversations/délégations (Lot A frontend)

Ajoute le port et l'adaptateur workState (+ mock) côté frontend, le type domaine
associé, et la feature `workstate` (panneau ProjectWorkStatePanel + hook
useProjectWorkState) consommant le read-model live exposé par le backend.
Intègre le panneau dans ProjectsView. Tests verts (workstate + projects).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:06:28 +02:00
parent aae18499a9
commit 17685a08e1
11 changed files with 397 additions and 1 deletions

View File

@ -0,0 +1,106 @@
/**
* Read-only project work-state panel: one row per agent, showing live/offline
* and idle/busy state from the backend read-model.
*/
import type { AgentWorkState } from "@/domain";
import { Button, Panel, Spinner, cn } from "@/shared";
import { useProjectWorkState } from "./useProjectWorkState";
export interface ProjectWorkStatePanelProps {
projectId: string;
}
function shortTicket(ticket: string): string {
return ticket.length <= 8 ? ticket : ticket.slice(0, 8);
}
function AgentRow({ agent }: { agent: AgentWorkState }) {
const live = agent.live !== undefined;
const busy = agent.busy.state === "busy";
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}
</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"
>
{shortTicket(agent.busy.ticket)}
</code>
)}
</span>
</li>
);
}
export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) {
const vm = useProjectWorkState(projectId);
const agents = vm.state?.agents ?? [];
return (
<Panel
title="Work"
actions={
<Button
size="sm"
variant="ghost"
onClick={() => void vm.refresh()}
loading={vm.busy}
>
Refresh
</Button>
}
>
{vm.error && (
<p
role="alert"
className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{vm.busy && vm.state === null ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading work state</span>
</div>
) : agents.length === 0 ? (
<p className="text-sm text-muted">No agent work state.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{agents.map((agent) => (
<AgentRow key={agent.agentId} agent={agent} />
))}
</ul>
)}
</Panel>
);
}