feat(workstate): panneau Work state simplifié — état + intitulé simples, détails et background tasks repliés par défaut — #235

This commit is contained in:
2026-08-21 17:52:44 +02:00
parent 2c8c71c02f
commit 8760ade168
2 changed files with 217 additions and 133 deletions

View File

@ -138,6 +138,27 @@ function canRetryTask(status: BackgroundCompletion["status"]): boolean {
return status === "failed" || status === "cancelFailed" || status === "cancelled";
}
function agentStatusLabel(live: boolean, busy: boolean): string {
if (busy) return "Busy";
return live ? "Idle" : "Offline";
}
function agentStatusClass(live: boolean, busy: boolean): string {
if (busy) return "bg-warning/15 text-warning";
if (live) return "bg-success/10 text-success";
return "bg-raised text-muted";
}
function backgroundTaskSummary(tasks: BackgroundCompletion[]): string | null {
if (tasks.length === 0) return null;
const running = tasks.filter((task) => canCancelTask(task.status)).length;
const failed = tasks.filter((task) => canRetryTask(task.status)).length;
if (running > 0 && failed > 0) return `${tasks.length} background tasks (${running} active, ${failed} failed)`;
if (running > 0) return `${running} background task${running > 1 ? "s" : ""} active`;
if (failed > 0) return `${failed} background task${failed > 1 ? "s" : ""} need attention`;
return `${tasks.length} background task${tasks.length > 1 ? "s" : ""}`;
}
function requesterLabel(ticket: AgentTicketState): string {
if (ticket.source.kind === "human") return "Human";
const id = shortTicket(ticket.source.agentId);
@ -554,6 +575,8 @@ function AgentRow({
const { agent: agentGateway } = useGateways();
const [message, setMessage] = useState<string | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [backgroundOpen, setBackgroundOpen] = useState(false);
const live = agent.live !== undefined;
const busyState = agent.busy ?? { state: "idle" };
const busy = busyState.state === "busy";
@ -568,6 +591,22 @@ function AgentRow({
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
const currentTurn =
busy || tickets.some((ticket) => ticket.status === "inProgress");
const activeTicket = tickets.find((ticket) => ticket.status === "inProgress");
const statusLabel = agentStatusLabel(live, busy);
const taskSummary = backgroundTaskSummary(backgroundTasks);
const activityLine =
activeTicket?.taskPreview ??
(busyState.state === "busy" ? `Ticket ${shortTicket(busyState.ticket)}` : null) ??
(inboxDepth > 0 ? `${inboxDepth} queued` : null) ??
taskSummary;
const hasActions = Boolean(agent.live);
const hasDetails =
hasActions ||
tickets.length > 0 ||
inbox.length > 0 ||
backgroundTasks.length > 0 ||
message ||
(agent.live && !liveVisible && !attachTarget);
async function refreshAfterAction(): Promise<void> {
await onRefresh();
@ -623,141 +662,148 @@ function AgentRow({
return (
<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">
<div className="flex min-w-0 items-start justify-between gap-3">
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold 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>
{inboxDepth > 0 && (
<span
aria-label={`${agent.name} queue depth ${inboxDepth}`}
className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
Queue {inboxDepth}
{activityLine && (
<span className="mt-0.5 block truncate text-xs text-muted">
{activityLine}
</span>
)}
{busyState.state === "busy" && (
<code
aria-label={`busy ticket ${shortTicket(busyState.ticket)}`}
title={busyState.ticket}
className="rounded bg-raised px-1.5 py-0.5 text-xs text-muted"
</span>
<span className="flex shrink-0 items-center gap-2">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
agentStatusClass(live, busy),
)}
>
{statusLabel}
</span>
{hasDetails && (
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-muted hover:bg-raised hover:text-content"
aria-expanded={detailsOpen}
aria-controls={`work-agent-details-${agent.agentId}`}
onClick={() => setDetailsOpen((value) => !value)}
>
{shortTicket(busyState.ticket)}
</code>
)}
{agent.live && liveVisible && (
<Button
size="sm"
variant="ghost"
onClick={() => goToCell(agent.live!.nodeId)}
>
Open
</Button>
)}
{agent.live && !liveVisible && (
<Button
size="sm"
variant="ghost"
disabled={!attachTarget || !agentGateway?.attachLiveAgent}
loading={actionBusy}
title={
attachTarget
? undefined
: "No empty visible cell available"
}
onClick={() => void attach()}
>
Attach
</Button>
)}
{agent.live && (
<Button
size="sm"
variant="danger"
loading={actionBusy}
disabled={!agentGateway?.stopLiveAgent}
onClick={() => void stop()}
>
Stop
</Button>
{detailsOpen ? "Hide" : "Details"}
</button>
)}
</span>
</div>
{agent.live && !liveVisible && !attachTarget && (
<p className="mt-1 text-xs text-muted">No empty visible cell.</p>
)}
{message && (
<p role="alert" className="mt-1 text-xs text-danger">
{message}
</p>
)}
{tickets.length > 0 && (
<ul
aria-label={`${agent.name} tickets`}
className="mt-2 flex flex-col gap-1"
{detailsOpen && (
<div
id={`work-agent-details-${agent.agentId}`}
className="mt-2 space-y-2 border-l border-border pl-3"
>
{tickets.map((ticket) => (
<TicketRow
key={ticket.ticketId}
ticket={ticket}
summary={conversations.get(ticket.conversationId)}
onOpenConversation={onOpenConversation}
/>
))}
</ul>
)}
{inbox.length > 0 && (
<div className="mt-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">
Inbox
</p>
<ul aria-label={`${agent.name} inbox`} className="mt-1 flex flex-col gap-1">
{inbox.map((item) => (
<InboxRow key={item.id} item={item} />
))}
</ul>
</div>
)}
{backgroundTasks.length > 0 && (
<div className="mt-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">
Background tasks
</p>
<ul
aria-label={`${agent.name} background tasks`}
className="mt-1 flex flex-col gap-1"
>
{backgroundTasks.map((task) => (
<BackgroundTaskRow
key={task.taskId}
task={task}
onRefresh={refreshAfterAction}
/>
))}
</ul>
{agent.live && (
<div className="flex min-w-0 flex-wrap gap-1.5">
{agent.live && liveVisible && (
<Button
size="sm"
variant="ghost"
onClick={() => goToCell(agent.live!.nodeId)}
>
Open
</Button>
)}
{agent.live && !liveVisible && (
<Button
size="sm"
variant="ghost"
disabled={!attachTarget || !agentGateway?.attachLiveAgent}
loading={actionBusy}
title={
attachTarget
? undefined
: "No empty visible cell available"
}
onClick={() => void attach()}
>
Attach
</Button>
)}
{agent.live && (
<Button
size="sm"
variant="danger"
loading={actionBusy}
disabled={!agentGateway?.stopLiveAgent}
onClick={() => void stop()}
>
Stop
</Button>
)}
</div>
)}
{agent.live && !liveVisible && !attachTarget && (
<p className="text-xs text-muted">No empty visible cell.</p>
)}
{message && (
<p role="alert" className="text-xs text-danger">
{message}
</p>
)}
{tickets.length > 0 && (
<ul
aria-label={`${agent.name} tickets`}
className="flex flex-col gap-1"
>
{tickets.map((ticket) => (
<TicketRow
key={ticket.ticketId}
ticket={ticket}
summary={conversations.get(ticket.conversationId)}
onOpenConversation={onOpenConversation}
/>
))}
</ul>
)}
{inbox.length > 0 && (
<div>
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">
Inbox
</p>
<ul aria-label={`${agent.name} inbox`} className="mt-1 flex flex-col gap-1">
{inbox.map((item) => (
<InboxRow key={item.id} item={item} />
))}
</ul>
</div>
)}
{backgroundTasks.length > 0 && (
<div>
<button
type="button"
className="text-xs font-medium text-muted hover:text-content hover:underline"
aria-expanded={backgroundOpen}
aria-controls={`work-agent-background-${agent.agentId}`}
onClick={() => setBackgroundOpen((value) => !value)}
>
{backgroundOpen ? "Hide background tasks" : taskSummary}
</button>
{backgroundOpen && (
<ul
id={`work-agent-background-${agent.agentId}`}
aria-label={`${agent.name} background tasks`}
className="mt-1 flex flex-col gap-1"
>
{backgroundTasks.map((task) => (
<BackgroundTaskRow
key={task.taskId}
task={task}
onRefresh={refreshAfterAction}
/>
))}
</ul>
)}
</div>
)}
</div>
)}
</li>