Normalise l'état Work pour empêcher l'affichage d'un onglet Work vide. QA VERT : tsc --noEmit exit 0 ; vitest run 42 fichiers / 407 tests passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
509 lines
15 KiB
TypeScript
509 lines
15 KiB
TypeScript
/**
|
|
* Read-only project work-state panel: one row per agent, showing live/offline
|
|
* and idle/busy state from the backend read-model, plus the current input queue.
|
|
*/
|
|
|
|
import { Component, type ReactNode, useState } from "react";
|
|
|
|
import type {
|
|
AgentTicketState,
|
|
AgentWorkState,
|
|
ConversationPreviewStatus,
|
|
ConversationWorkSummary,
|
|
LeafCell,
|
|
} from "@/domain";
|
|
import { useGateways } from "@/app/di";
|
|
import { leaves } from "@/features/layout/layout";
|
|
import { useLayout } from "@/features/layout/useLayout";
|
|
import type { LayoutViewModel } from "@/features/layout/useLayout";
|
|
import { Button, Panel, Spinner, cn } from "@/shared";
|
|
import { useProjectWorkState } from "./useProjectWorkState";
|
|
|
|
export interface ProjectWorkStatePanelProps {
|
|
projectId: string;
|
|
}
|
|
|
|
interface WorkStatePanelErrorBoundaryProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
interface WorkStatePanelErrorBoundaryState {
|
|
error: Error | null;
|
|
retryKey: number;
|
|
}
|
|
|
|
export class WorkStatePanelErrorBoundary extends Component<
|
|
WorkStatePanelErrorBoundaryProps,
|
|
WorkStatePanelErrorBoundaryState
|
|
> {
|
|
state: WorkStatePanelErrorBoundaryState = { error: null, retryKey: 0 };
|
|
|
|
static getDerivedStateFromError(error: Error): Partial<WorkStatePanelErrorBoundaryState> {
|
|
return { error };
|
|
}
|
|
|
|
private retry = () => {
|
|
this.setState(({ retryKey }) => ({ error: null, retryKey: retryKey + 1 }));
|
|
};
|
|
|
|
render() {
|
|
if (this.state.error) {
|
|
return (
|
|
<Panel title="Work">
|
|
<div
|
|
role="alert"
|
|
className="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
|
>
|
|
<p className="font-medium">Work panel failed to render.</p>
|
|
<p className="mt-1 text-xs text-danger/80">
|
|
{this.state.error.message}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="mt-2"
|
|
onClick={this.retry}
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
return <div key={this.state.retryKey}>{this.props.children}</div>;
|
|
}
|
|
}
|
|
|
|
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 summaryLabel(status: ConversationPreviewStatus): string {
|
|
switch (status) {
|
|
case "ready":
|
|
return "Summary";
|
|
case "missing":
|
|
return "No summary";
|
|
case "partial":
|
|
return "Partial";
|
|
case "unavailable":
|
|
return "Unavailable";
|
|
}
|
|
}
|
|
|
|
function summaryText(summary: ConversationWorkSummary): string | null {
|
|
if (summary.objectivePreview) return `Goal: ${summary.objectivePreview}`;
|
|
if (summary.summaryPreview) return summary.summaryPreview;
|
|
const lastTurn = (summary.recentTurns ?? []).at(-1);
|
|
return lastTurn ? `Last: ${lastTurn.textPreview}` : null;
|
|
}
|
|
|
|
function goToCell(nodeId: string): void {
|
|
if (typeof document === "undefined") return;
|
|
const el = document.querySelector<HTMLElement>(`[data-node-id="${nodeId}"]`);
|
|
if (!el) return;
|
|
el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
|
const previous = el.style.outline;
|
|
el.style.outline = "2px solid var(--color-primary, #5b9bd5)";
|
|
window.setTimeout(() => {
|
|
el.style.outline = previous;
|
|
}, 1200);
|
|
}
|
|
|
|
async function copyToClipboard(text: string): Promise<void> {
|
|
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
throw new Error("Clipboard unavailable");
|
|
}
|
|
|
|
function ConversationSummaryLine({
|
|
summary,
|
|
}: {
|
|
summary: ConversationWorkSummary;
|
|
}) {
|
|
const text = summaryText(summary);
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
if (!text) return null;
|
|
return (
|
|
<div className="mt-1 min-w-0 pl-1 text-xs text-muted">
|
|
<div className="flex min-w-0 items-start gap-2">
|
|
<span
|
|
className={cn(
|
|
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
|
|
summary.status === "ready"
|
|
? "bg-success/10 text-success"
|
|
: "bg-raised text-muted",
|
|
)}
|
|
>
|
|
{summaryLabel(summary.status)}
|
|
</span>
|
|
<span className="min-w-0 flex-1 break-words">{text}</span>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded px-1 py-0.5 text-[11px] text-muted hover:bg-raised hover:text-content"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
>
|
|
{expanded ? "Hide" : "View"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded px-1 py-0.5 text-[11px] text-muted hover:bg-raised hover:text-content disabled:opacity-50"
|
|
disabled={!text}
|
|
onClick={() => {
|
|
void copyToClipboard(text).then(() => {
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1000);
|
|
});
|
|
}}
|
|
>
|
|
{copied ? "Copied" : "Copy"}
|
|
</button>
|
|
</div>
|
|
{expanded && (
|
|
<div
|
|
aria-label={`conversation ${summary.conversationId} details`}
|
|
className="mt-1 space-y-1 border-l border-border pl-2"
|
|
>
|
|
{summary.objectivePreview && (
|
|
<p>
|
|
<span className="font-medium text-content">Goal:</span>{" "}
|
|
{summary.objectivePreview}
|
|
</p>
|
|
)}
|
|
{summary.summaryPreview && (
|
|
<p>
|
|
<span className="font-medium text-content">Summary:</span>{" "}
|
|
{summary.summaryPreview}
|
|
</p>
|
|
)}
|
|
{(summary.recentTurns ?? []).length > 0 && (
|
|
<ul className="space-y-0.5">
|
|
{(summary.recentTurns ?? []).map((turn) => (
|
|
<li key={`${turn.atMs}-${turn.role}-${turn.textPreview}`}>
|
|
<span className="font-medium text-content">{turn.role}:</span>{" "}
|
|
{turn.textPreview}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TicketRow({
|
|
ticket,
|
|
summary,
|
|
}: {
|
|
ticket: AgentTicketState;
|
|
summary?: ConversationWorkSummary;
|
|
}) {
|
|
const truncated = ticket.taskLen > ticket.taskPreview.length;
|
|
return (
|
|
<li className="min-w-0 text-xs text-muted">
|
|
<div className="flex min-w-0 items-start gap-2">
|
|
<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>
|
|
</div>
|
|
{summary && <ConversationSummaryLine summary={summary} />}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function AgentRow({
|
|
agent,
|
|
conversations,
|
|
visibleLeaves,
|
|
layout,
|
|
projectId,
|
|
onRefresh,
|
|
}: {
|
|
agent: AgentWorkState;
|
|
conversations: Map<string, ConversationWorkSummary>;
|
|
visibleLeaves: LeafCell[];
|
|
layout: LayoutViewModel;
|
|
projectId: string;
|
|
onRefresh: () => Promise<void>;
|
|
}) {
|
|
const { agent: agentGateway } = useGateways();
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
const [actionBusy, setActionBusy] = useState(false);
|
|
const live = agent.live !== undefined;
|
|
const busyState = agent.busy ?? { state: "idle" };
|
|
const busy = busyState.state === "busy";
|
|
const tickets = [...(agent.tickets ?? [])].sort((a, b) => a.position - b.position);
|
|
const visibleNodeIds = new Set(visibleLeaves.map((leaf) => leaf.id));
|
|
const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId);
|
|
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
|
|
const currentTurn =
|
|
busy || tickets.some((ticket) => ticket.status === "inProgress");
|
|
|
|
async function refreshAfterAction(): Promise<void> {
|
|
await onRefresh();
|
|
}
|
|
|
|
async function attach(): Promise<void> {
|
|
if (!agent.live || !attachTarget || !agentGateway?.attachLiveAgent) return;
|
|
setActionBusy(true);
|
|
setMessage(null);
|
|
try {
|
|
const attached = await agentGateway.attachLiveAgent(
|
|
projectId,
|
|
agent.agentId,
|
|
attachTarget.id,
|
|
);
|
|
await layout.attachLiveAgentToCell(
|
|
attachTarget.id,
|
|
agent.agentId,
|
|
attached.sessionId,
|
|
);
|
|
await refreshAfterAction();
|
|
goToCell(attachTarget.id);
|
|
} catch (e) {
|
|
setMessage(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setActionBusy(false);
|
|
}
|
|
}
|
|
|
|
async function stop(): Promise<void> {
|
|
if (!agent.live || !agentGateway?.stopLiveAgent) return;
|
|
const warning = currentTurn
|
|
? " The current turn will be interrupted."
|
|
: "";
|
|
if (!window.confirm(`Stop ${agent.name}?${warning}`)) return;
|
|
setActionBusy(true);
|
|
setMessage(null);
|
|
try {
|
|
const stopped = await agentGateway.stopLiveAgent(projectId, agent.agentId);
|
|
if (visibleNodeIds.has(agent.live.nodeId)) {
|
|
const leaf = visibleLeaves.find((candidate) => candidate.id === agent.live?.nodeId);
|
|
if (!leaf?.session || leaf.session === stopped.sessionId) {
|
|
await layout.setSession(agent.live.nodeId, null);
|
|
}
|
|
}
|
|
await refreshAfterAction();
|
|
} catch (e) {
|
|
setMessage(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setActionBusy(false);
|
|
}
|
|
}
|
|
|
|
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">
|
|
{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>
|
|
{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"
|
|
>
|
|
{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>
|
|
)}
|
|
</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"
|
|
>
|
|
{tickets.map((ticket) => (
|
|
<TicketRow
|
|
key={ticket.ticketId}
|
|
ticket={ticket}
|
|
summary={conversations.get(ticket.conversationId)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function ProjectWorkStatePanelContent({ projectId }: ProjectWorkStatePanelProps) {
|
|
const vm = useProjectWorkState(projectId);
|
|
const layoutVm = useLayout(projectId);
|
|
const agents = vm.state?.agents ?? [];
|
|
const visibleLeaves = layoutVm.layout ? leaves(layoutVm.layout) : [];
|
|
const conversations = new Map(
|
|
(vm.state?.conversations ?? []).map((summary) => [
|
|
summary.conversationId,
|
|
summary,
|
|
]),
|
|
);
|
|
|
|
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}
|
|
conversations={conversations}
|
|
visibleLeaves={visibleLeaves}
|
|
layout={layoutVm}
|
|
projectId={projectId}
|
|
onRefresh={vm.refresh}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
export function ProjectWorkStatePanel(props: ProjectWorkStatePanelProps) {
|
|
return (
|
|
<WorkStatePanelErrorBoundary>
|
|
<ProjectWorkStatePanelContent {...props} />
|
|
</WorkStatePanelErrorBoundary>
|
|
);
|
|
}
|