Files
IdeA/frontend/src/features/workstate/ProjectWorkStatePanel.tsx

914 lines
29 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, useEffect, useRef, useState } from "react";
import type {
AgentTicketState,
AgentWorkState,
BackgroundCompletion,
ConversationPreviewStatus,
ConversationWorkSummary,
InboxItem,
LeafCell,
} from "@/domain";
import type { BackgroundTaskAttachment } from "@/ports";
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;
/** Opens the read-only transcript viewer for a conversation pair (LS7). */
onOpenConversation?: (conversationId: string) => void;
}
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 formatTaskStatus(status: BackgroundCompletion["status"]): string {
switch (status) {
case "running":
return "Running";
case "cancelling":
return "Cancelling";
case "completed":
return "Completed";
case "failed":
return "Failed";
case "cancelFailed":
return "Cancel failed";
case "cancelled":
return "Cancelled";
case "delivered":
return "Delivered";
case "pending":
return "Pending";
}
}
/** Terminal (finished) task states that carry a result summary (ticket #5). */
function isTerminalTaskStatus(status: BackgroundCompletion["status"]): boolean {
return (
status === "completed" ||
status === "failed" ||
status === "cancelFailed" ||
status === "cancelled"
);
}
function taskStatusClass(status: BackgroundCompletion["status"]): string {
if (status === "running" || status === "pending" || status === "cancelling") {
return "bg-warning/15 text-warning";
}
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
if (status === "failed" || status === "cancelFailed" || status === "cancelled") {
return "bg-danger/10 text-danger";
}
return "bg-raised text-muted";
}
function canCancelTask(status: BackgroundCompletion["status"]): boolean {
return status === "running" || status === "pending" || status === "cancelling";
}
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 compactIntent(text: string | null): string | null {
const normalized = text?.replace(/\s+/g, " ").trim();
if (!normalized) return null;
return normalized.length <= 44 ? normalized : `${normalized.slice(0, 41)}...`;
}
function compactIndicator({
tickets,
inboxDepth,
backgroundTasks,
}: {
tickets: AgentTicketState[];
inboxDepth: number;
backgroundTasks: BackgroundCompletion[];
}): string | null {
const queuedTickets = tickets.filter((ticket) => ticket.status === "queued").length;
if (queuedTickets > 0) return `+${queuedTickets}`;
if (inboxDepth > 0) return `${inboxDepth} inbox`;
if (backgroundTasks.length > 0) return `${backgroundTasks.length} bg`;
return null;
}
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,
onOpenConversation,
}: {
summary: ConversationWorkSummary;
onOpenConversation?: (conversationId: string) => void;
}) {
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>
{onOpenConversation ? (
<button
type="button"
aria-label={`open conversation ${summary.conversationId}`}
className="min-w-0 flex-1 break-words text-left hover:text-content hover:underline"
onClick={() => onOpenConversation(summary.conversationId)}
>
{text}
</button>
) : (
<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,
onOpenConversation,
}: {
ticket: AgentTicketState;
summary?: ConversationWorkSummary;
onOpenConversation?: (conversationId: string) => void;
}) {
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}
onOpenConversation={onOpenConversation}
/>
)}
</li>
);
}
function InboxRow({ item }: { item: InboxItem }) {
return (
<li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 shrink-0 rounded-full bg-raised px-1.5 py-0.5 font-medium text-muted">
{item.kind}
</span>
<span className="min-w-0 flex-1 break-words">{item.body}</span>
<code
title={item.id}
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
>
{shortTicket(item.id)}
</code>
</div>
</li>
);
}
function BackgroundTaskRow({
task,
onRefresh,
}: {
task: BackgroundCompletion;
onRefresh: () => Promise<void>;
}) {
const { workState } = useGateways();
const [open, setOpen] = useState(false);
const [liveOpen, setLiveOpen] = useState(false);
const [liveOutput, setLiveOutput] = useState("");
const [actionBusy, setActionBusy] = useState(false);
const [liveBusy, setLiveBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const attachmentRef = useRef<BackgroundTaskAttachment | null>(null);
const attachSeqRef = useRef(0);
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
const canCancel = canCancelTask(task.status);
const canRetry = canRetryTask(task.status);
const canAttachLive = canCancel;
function detachLive(): void {
attachSeqRef.current += 1;
attachmentRef.current?.detach();
attachmentRef.current = null;
setLiveOpen(false);
}
useEffect(() => {
return () => {
attachSeqRef.current += 1;
attachmentRef.current?.detach();
attachmentRef.current = null;
};
}, [task.taskId]);
useEffect(() => {
if (!canAttachLive && attachmentRef.current) detachLive();
}, [canAttachLive]);
async function runAction(
action: (taskId: string) => Promise<void>,
): Promise<void> {
setActionBusy(true);
setMessage(null);
try {
await action(task.taskId);
await onRefresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e));
} finally {
setActionBusy(false);
}
}
async function toggleLive(): Promise<void> {
if (liveOpen || attachmentRef.current) {
detachLive();
return;
}
const seq = attachSeqRef.current + 1;
attachSeqRef.current = seq;
setLiveBusy(true);
setMessage(null);
setLiveOutput("");
try {
const decoder = new TextDecoder();
const attachment = await workState.attachBackgroundTask(task.taskId, (bytes) => {
setLiveOutput((prev) => prev + decoder.decode(bytes));
});
if (seq !== attachSeqRef.current) {
attachment.detach();
return;
}
attachmentRef.current = attachment;
setLiveOutput(decoder.decode(attachment.scrollback));
setLiveOpen(true);
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e));
} finally {
if (seq === attachSeqRef.current) setLiveBusy(false);
}
}
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",
taskStatusClass(task.status),
)}
>
{formatTaskStatus(task.status)}
</span>
<span className="min-w-0 flex-1">
<span className="font-medium text-content">{task.kind}</span>
{task.exitCode !== null && (
<span className="text-muted"> · exit {task.exitCode}</span>
)}
</span>
<Button
size="sm"
variant="ghost"
disabled={!canCancel || actionBusy}
loading={actionBusy}
onClick={() =>
void runAction((id) => workState.cancelBackgroundTask(id))
}
>
Cancel
</Button>
<Button
size="sm"
variant="ghost"
disabled={!canAttachLive || liveBusy}
loading={liveBusy}
onClick={() => void toggleLive()}
>
{liveOpen ? "Detach" : "Live"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={!hasOutput}
onClick={() => setOpen((v) => !v)}
>
Output
</Button>
<Button
size="sm"
variant="ghost"
disabled={!canRetry || actionBusy}
loading={actionBusy}
onClick={() =>
void runAction((id) => workState.retryBackgroundTask(id))
}
>
Retry
</Button>
<code
title={task.taskId}
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
>
{shortTicket(task.taskId)}
</code>
</div>
{task.summary && isTerminalTaskStatus(task.status) && (
<p
aria-label={`task ${shortTicket(task.taskId)} summary`}
title={task.summary}
className="mt-1 truncate pl-1 text-[11px] text-muted"
>
{task.summary}
</p>
)}
{message && (
<p role="alert" className="mt-1 text-xs text-danger">
{message}
</p>
)}
{open && hasOutput && (
<pre
aria-label={`task ${shortTicket(task.taskId)} output`}
className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-border bg-canvas p-2 text-[11px] text-muted"
>
{task.stdoutTail ? `stdout\n${task.stdoutTail}` : ""}
{task.stdoutTail && task.stderrTail ? "\n\n" : ""}
{task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
</pre>
)}
{liveOpen && (
<pre
aria-label={`task ${shortTicket(task.taskId)} live output`}
className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-border bg-canvas p-2 text-[11px] text-muted"
>
{liveOutput || "No live output yet."}
</pre>
)}
</li>
);
}
function AgentRow({
agent,
conversations,
visibleLeaves,
layout,
projectId,
onRefresh,
onOpenConversation,
}: {
agent: AgentWorkState;
conversations: Map<string, ConversationWorkSummary>;
visibleLeaves: LeafCell[];
layout: LayoutViewModel;
projectId: string;
onRefresh: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
}) {
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";
const tickets = [...(agent.tickets ?? [])].sort((a, b) => a.position - b.position);
const inbox = [...(agent.inbox ?? [])].sort((a, b) => a.createdAtMs - b.createdAtMs);
const backgroundTasks = [...(agent.backgroundTasks ?? [])].sort(
(a, b) => b.updatedAtMs - a.updatedAtMs || a.taskId.localeCompare(b.taskId),
);
const inboxDepth = Math.max(agent.inboxDepth ?? 0, inbox.length, tickets.length);
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");
const activeTicket = tickets.find((ticket) => ticket.status === "inProgress");
const statusLabel = agentStatusLabel(live, busy);
const taskSummary = backgroundTaskSummary(backgroundTasks);
const activityLine = compactIntent(
activeTicket?.taskPreview ??
(busyState.state === "busy" ? `Ticket ${shortTicket(busyState.ticket)}` : null),
);
const indicator = compactIndicator({ tickets, inboxDepth, backgroundTasks });
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();
}
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 min-w-0 items-center justify-between gap-2">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate text-sm font-semibold text-content">
{agent.name}
</span>
{activityLine && (
<span
className="min-w-0 truncate text-xs text-muted"
title={activeTicket?.taskPreview ?? activityLine}
>
{activityLine}
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{indicator && (
<span className="rounded-full bg-raised px-1.5 py-0.5 text-[11px] font-medium text-muted">
{indicator}
</span>
)}
<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)}
>
{detailsOpen ? "Hide" : "Details"}
</button>
)}
</span>
</div>
{detailsOpen && (
<div
id={`work-agent-details-${agent.agentId}`}
className="mt-2 space-y-2 border-l border-border pl-3"
>
{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>
);
}
function ProjectWorkStatePanelContent({
projectId,
onOpenConversation,
}: 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}
onOpenConversation={onOpenConversation}
/>
))}
</ul>
)}
</Panel>
);
}
export function ProjectWorkStatePanel(props: ProjectWorkStatePanelProps) {
return (
<WorkStatePanelErrorBoundary>
<ProjectWorkStatePanelContent {...props} />
</WorkStatePanelErrorBoundary>
);
}