/** * 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 { return { error }; } private retry = () => { this.setState(({ retryKey }) => ({ error: null, retryKey: retryKey + 1 })); }; render() { if (this.state.error) { return (

Work panel failed to render.

{this.state.error.message}

); } return
{this.props.children}
; } } 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(`[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 { 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 (
{summaryLabel(summary.status)} {text}
{expanded && (
{summary.objectivePreview && (

Goal:{" "} {summary.objectivePreview}

)} {summary.summaryPreview && (

Summary:{" "} {summary.summaryPreview}

)} {(summary.recentTurns ?? []).length > 0 && (
    {(summary.recentTurns ?? []).map((turn) => (
  • {turn.role}:{" "} {turn.textPreview}
  • ))}
)}
)}
); } function TicketRow({ ticket, summary, }: { ticket: AgentTicketState; summary?: ConversationWorkSummary; }) { const truncated = ticket.taskLen > ticket.taskPreview.length; return (
  • #{ticket.position + 1} {formatStatus(ticket.status)} {requesterLabel(ticket)} · {ticket.taskPreview} {truncated && ( {" "} +{ticket.taskLen - ticket.taskPreview.length} )} {shortTicket(ticket.ticketId)}
    {summary && }
  • ); } function AgentRow({ agent, conversations, visibleLeaves, layout, projectId, onRefresh, }: { agent: AgentWorkState; conversations: Map; visibleLeaves: LeafCell[]; layout: LayoutViewModel; projectId: string; onRefresh: () => Promise; }) { const { agent: agentGateway } = useGateways(); const [message, setMessage] = useState(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 { await onRefresh(); } async function attach(): Promise { 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 { 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 (
  • {agent.name} {agent.profileId} {live ? "Live" : "Offline"} {busy ? "Busy" : "Idle"} {busyState.state === "busy" && ( {shortTicket(busyState.ticket)} )} {agent.live && liveVisible && ( )} {agent.live && !liveVisible && ( )} {agent.live && ( )}
    {agent.live && !liveVisible && !attachTarget && (

    No empty visible cell.

    )} {message && (

    {message}

    )} {tickets.length > 0 && (
      {tickets.map((ticket) => ( ))}
    )}
  • ); } 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 ( void vm.refresh()} loading={vm.busy} > Refresh } > {vm.error && (

    {vm.error}

    )} {vm.busy && vm.state === null ? (
    Loading work state…
    ) : agents.length === 0 ? (

    No agent work state.

    ) : (
      {agents.map((agent) => ( ))}
    )}
    ); } export function ProjectWorkStatePanel(props: ProjectWorkStatePanelProps) { return ( ); }