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:
106
frontend/src/features/workstate/ProjectWorkStatePanel.tsx
Normal file
106
frontend/src/features/workstate/ProjectWorkStatePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user