/** * 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 { 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 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(`[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, 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 (
{summaryLabel(summary.status)} {onOpenConversation ? ( ) : ( {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, onOpenConversation, }: { ticket: AgentTicketState; summary?: ConversationWorkSummary; onOpenConversation?: (conversationId: string) => void; }) { 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 InboxRow({ item }: { item: InboxItem }) { return (
  • {item.kind} {item.body} {shortTicket(item.id)}
  • ); } function BackgroundTaskRow({ task, onRefresh, }: { task: BackgroundCompletion; onRefresh: () => Promise; }) { 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(null); const attachmentRef = useRef(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, ): Promise { 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 { 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 (
  • {formatTaskStatus(task.status)} {task.kind} {task.exitCode !== null && ( · exit {task.exitCode} )} {shortTicket(task.taskId)}
    {task.summary && isTerminalTaskStatus(task.status) && (

    {task.summary}

    )} {message && (

    {message}

    )} {open && hasOutput && (
              {task.stdoutTail ? `stdout\n${task.stdoutTail}` : ""}
              {task.stdoutTail && task.stderrTail ? "\n\n" : ""}
              {task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
            
    )} {liveOpen && (
              {liveOutput || "No live output yet."}
            
    )}
  • ); } function AgentRow({ agent, conversations, visibleLeaves, layout, projectId, onRefresh, onOpenConversation, }: { agent: AgentWorkState; conversations: Map; visibleLeaves: LeafCell[]; layout: LayoutViewModel; projectId: string; onRefresh: () => Promise; onOpenConversation?: (conversationId: string) => void; }) { const { agent: agentGateway } = useGateways(); const [message, setMessage] = useState(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 { 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} {activityLine && ( {activityLine} )} {indicator && ( {indicator} )} {statusLabel} {hasDetails && ( )}
    {detailsOpen && (
    {agent.live && (
    {agent.live && liveVisible && ( )} {agent.live && !liveVisible && ( )} {agent.live && ( )}
    )} {agent.live && !liveVisible && !attachTarget && (

    No empty visible cell.

    )} {message && (

    {message}

    )} {tickets.length > 0 && (
      {tickets.map((ticket) => ( ))}
    )} {inbox.length > 0 && (

    Inbox

      {inbox.map((item) => ( ))}
    )} {backgroundTasks.length > 0 && (
    {backgroundOpen && (
      {backgroundTasks.map((task) => ( ))}
    )}
    )}
    )}
  • ); } 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 ( 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 ( ); }