import { useCallback, useEffect, useMemo, useState } from 'react' import type { AgentAnimStatus, ProgressEntry, RoleAggregatedStatus, RoleWorkItemActivitySection, RoleWorkItemRow, RoleWorkItemSummary, Session, } from '../types/kanban' import type { AgentInfo } from '../types/visual' import { AgentProgressBlock } from './AgentProgressBlock' import { MarkdownBody } from './MessageList' import { IconClose, IconHandoff, IconTimeline, IconSearch, IconChevron } from './SvgIcons' import { TERMINAL_SESSION_STATUSES, getSessionRuntimeStatus, isSessionWorking } from '../lib/sessionRuntime' import { getWorkItemRoleLabel } from '../lib/workItemIdentity' interface AgentWorkPanelProps { sessions: Session[] /** * When provided (company-mode primary sessions), the panel renders one * row per role with the per-role aggregated status from the backend. * Falls back to the legacy ``sessions`` view when undefined / empty. */ roleWorkItems?: Record isCompanyRuntime?: boolean agents: AgentInfo[] onOpenChildDetail?: (taskId: string) => void onOpenExecutionPanel?: (taskId: string) => void } interface AgentWorkPanelLegacyViewProps { sessions: Session[] agents: AgentInfo[] onOpenChildDetail?: (taskId: string) => void onOpenExecutionPanel?: (taskId: string) => void } type StatusFilter = 'all' | 'active' | 'idle' | 'pending' function agentStatusOf(s: Session): AgentAnimStatus { return getSessionRuntimeStatus(s) } function statusSortKey(s: Session): number { const st = agentStatusOf(s) if (st === 'tool_active') return 0 if (st === 'reflecting') return 1 if (s.status === 'running') return 2 if (s.status === 'pending') return 3 if (s.status === 'done') return 5 if (s.status === 'failed') return 6 return 4 } function elapsed(ts: number): string { const sec = Math.floor((Date.now() - ts) / 1000) if (sec < 5) return 'now' if (sec < 60) return `${sec}s` const min = Math.floor(sec / 60) if (min < 60) return `${min}m` return `${Math.floor(min / 60)}h` } function lastActivity(entries: ProgressEntry[]): string { if (entries.length === 0) return '\u2014' return elapsed(entries[entries.length - 1].timestamp) } function activitySummary(s: Session): string { if (s.status === 'done') return 'Completed' if (s.status === 'failed') return 'Failed' if (s.status === 'cancelled') return 'Cancelled' const st = agentStatusOf(s) if (st === 'tool_active' && s.currentTool) return s.currentTool if (st === 'reflecting') return 'Thinking\u2026' if (s.status === 'pending') return 'Pending' const log = s.progressLog if (log.length > 0) { const last = log[log.length - 1] if (last.type === 'work_item_started') return `Work item: ${last.summary}` if (last.type === 'tool_call') return last.summary if (last.detail) return last.detail.replace(/\s+/g, ' ').trim() return last.summary } return 'Idle' } function terminalIcon(status: string): string | null { if (status === 'done') return '\u2713' if (status === 'failed') return '\u2717' if (status === 'cancelled') return '\u2014' return null } function terminalClass(status: string): string { if (status === 'done') return 'awp-terminal-done' if (status === 'failed') return 'awp-terminal-failed' if (status === 'cancelled') return 'awp-terminal-cancelled' return '' } const EXECUTION_AGENT_LABELS: Record = { native: 'Native', codex: 'Codex', claude_code: 'Claude Code', cursor: 'Cursor', opencode: 'OpenCode', } const RESULT_BANNER: Record = { done: { cls: 'awp-result-done', text: 'Work item completed successfully' }, failed: { cls: 'awp-result-failed', text: 'Work item failed' }, cancelled: { cls: 'awp-result-cancelled', text: 'Work item cancelled' }, } /** Role-aggregated panel: one row per role, sourced from the * DelegationWorkItem rollup. Renders when ``roleWorkItems`` is non-empty; * the legacy session-based panel below is preserved verbatim as a fallback * for non-company runs. */ const ROLE_STATUS_LABEL: Record = { active: 'Working', waiting: 'Waiting', pending: 'Pending', done: 'Completed', failed: 'Failed', } const ROLE_STATUS_SORT: Record = { active: 0, waiting: 1, pending: 2, done: 3, failed: 4, } function summarizeRoleActivity(summary: RoleWorkItemSummary): string { if (summary.runtimeStatus === 'tool_active') return 'Running tool…' if (summary.runtimeStatus === 'reflecting') return 'Thinking…' const label = ROLE_STATUS_LABEL[summary.aggregatedStatus] ?? 'Pending' if (summary.aggregatedStatus === 'done') { return `${label} · ${summary.workItems.length} work item${summary.workItems.length === 1 ? '' : 's'}` } return label } function lastRoleActivity(summary: RoleWorkItemSummary): string { if (summary.workItems.length === 0) return '—' const ts = summary.workItems.reduce((max, w) => Math.max(max, w.updatedAt), 0) if (!ts) return '—' return elapsed(ts) } function roleWorkItemSessionStatus(row: RoleWorkItemRow): string | undefined { if (row.phase === 'failed') return 'failed' if (row.phase === 'cancelled') return 'cancelled' if (row.kanbanColumn === 'done') return 'done' return undefined } function WorkItemActivitySections({ sections, fallbackEntries, sessionStatus, }: { sections?: RoleWorkItemActivitySection[] fallbackEntries?: ProgressEntry[] sessionStatus?: string }) { const visibleSections = (sections ?? []).filter(section => ( (section.entries?.length ?? 0) > 0 || !!section.runtimeTaskId )) if (visibleSections.length > 0) { return (
{visibleSections.map((section, index) => { const entries = section.entries ?? [] const key = `${section.runtimeTaskId || section.kind}:${index}` return (
{section.title} {section.roleName && ( {section.roleName} )} {entries.length > 0 && ( {entries.length} )}
{entries.length > 0 ? ( ) : (
No runtime activity yet
)}
) })}
) } if (!fallbackEntries || fallbackEntries.length === 0) { return
No runtime activity yet
} return ( ) } function AgentWorkPanelRoleView({ roleWorkItems, onOpenChildDetail, onOpenExecutionPanel, }: { roleWorkItems: Record onOpenChildDetail?: (taskId: string) => void onOpenExecutionPanel?: (taskId: string) => void }) { const [selectedRoleKey, setSelectedRoleKey] = useState(null) const [selectedWorkItemId, setSelectedWorkItemId] = useState(null) const [filter, setFilter] = useState('all') const [search, setSearch] = useState('') const [showSearch, setShowSearch] = useState(false) const summaries = useMemo(() => Object.values(roleWorkItems), [roleWorkItems]) const sorted = useMemo(() => { let list = summaries.slice() if (filter === 'active') { list = list.filter(s => s.aggregatedStatus === 'active') } else if (filter === 'idle') { list = list.filter(s => s.aggregatedStatus === 'done' || s.aggregatedStatus === 'failed') } else if (filter === 'pending') { list = list.filter(s => s.aggregatedStatus === 'pending' || s.aggregatedStatus === 'waiting') } if (search.trim()) { const q = search.toLowerCase() list = list.filter(s => s.roleName.toLowerCase().includes(q) || s.roleId.toLowerCase().includes(q) || s.workItems.some(w => w.title.toLowerCase().includes(q)), ) } list.sort((a, b) => { const aRank = ROLE_STATUS_SORT[a.aggregatedStatus] ?? 99 const bRank = ROLE_STATUS_SORT[b.aggregatedStatus] ?? 99 if (aRank !== bRank) return aRank - bRank return a.roleName.localeCompare(b.roleName) }) return list }, [summaries, filter, search]) // Reset selection if the role disappears (rare, but possible during run // teardown). Stale selection would otherwise crash the detail view. useEffect(() => { if (selectedRoleKey && !summaries.some(s => s.roleKey === selectedRoleKey)) { setSelectedRoleKey(null) setSelectedWorkItemId(null) } }, [summaries, selectedRoleKey]) const activeCount = summaries.filter(s => s.aggregatedStatus === 'active').length const selected = selectedRoleKey ? roleWorkItems[selectedRoleKey] ?? null : null const selectedWorkItem: RoleWorkItemRow | null = (selected && selectedWorkItemId) ? selected.workItems.find(w => w.workItemId === selectedWorkItemId) ?? null : null if (summaries.length === 0) return null return (
Agents {activeCount > 0 ? `${activeCount}/${summaries.length} active` : `${summaries.length}`}
{showSearch && (
setSearch(e.target.value)} autoFocus />
)}
{sorted.map((summary) => { const isSelected = selectedRoleKey === summary.roleKey const isActive = summary.aggregatedStatus === 'active' const isLive = summary.runtimeStatus === 'reflecting' || summary.runtimeStatus === 'tool_active' const isTerminal = summary.aggregatedStatus === 'done' || summary.aggregatedStatus === 'failed' return ( ) })} {sorted.length === 0 && (
{search ? 'No matching agents' : 'No agents in this filter'}
)}
{selected && (
{selected.roleName.charAt(0).toUpperCase()}
{selected.roleName} {ROLE_STATUS_LABEL[selected.aggregatedStatus]}
Work items
    {selected.workItems.map((row) => { const expanded = selectedWorkItemId === row.workItemId const columnId = (row.kanbanColumn === 'in-progress') ? 'in_progress' : (row.kanbanColumn === 'in-review' ? 'in_review' : row.kanbanColumn) return (
  • {expanded && (
    {row.executionTurnId && ( )}
    )}
  • ) })}
{selectedWorkItem === null && selected.workItems.length === 0 && (
No work items yet
)}
)}
) } function AgentWorkPanelLegacyView({ sessions, agents, onOpenChildDetail, onOpenExecutionPanel }: AgentWorkPanelLegacyViewProps) { const [selectedTaskId, setSelectedTaskId] = useState(null) const [filter, setFilter] = useState('all') const [search, setSearch] = useState('') const [showSearch, setShowSearch] = useState(false) // Reset selection when parent session changes (sessions list swaps entirely) useEffect(() => { if (selectedTaskId && !sessions.some(s => s.taskId === selectedTaskId)) { setSelectedTaskId(null) } }, [sessions, selectedTaskId]) const sorted = useMemo(() => { let list = [...sessions] if (filter === 'active') { list = list.filter(isSessionWorking) } else if (filter === 'idle') { list = list.filter(s => TERMINAL_SESSION_STATUSES.has(s.status) || (agentStatusOf(s) === 'idle' && s.status !== 'pending'), ) } else if (filter === 'pending') { list = list.filter(s => s.status === 'pending') } if (search.trim()) { const q = search.toLowerCase() list = list.filter(s => { const name = s.assigneeIds[0] ?? s.title return name.toLowerCase().includes(q) || s.title.toLowerCase().includes(q) }) } list.sort((a, b) => statusSortKey(a) - statusSortKey(b)) return list }, [sessions, filter, search]) const activeCount = sessions.filter(isSessionWorking).length const selected = useMemo(() => { if (!selectedTaskId) return null return sessions.find(s => s.taskId === selectedTaskId) ?? null }, [sessions, selectedTaskId]) const selectedAgent = useMemo(() => { if (!selected) return undefined const id = selected.assigneeIds[0] return id ? agents.find(a => a.agent_id === id) : undefined }, [selected, agents]) const handleSelect = useCallback((taskId: string) => { setSelectedTaskId(prev => { if (prev === taskId) { onOpenChildDetail?.(taskId) return prev } return taskId }) }, [onOpenChildDetail]) if (sessions.length === 0) return null return (
{/* Header */}
Agents {activeCount > 0 ? `${activeCount}/${sessions.length} active` : `${sessions.length}`}
{showSearch && (
setSearch(e.target.value)} autoFocus />
)} {/* Compact list */}
{sorted.map(s => { const isActive = isSessionWorking(s) const isTerminal = TERMINAL_SESSION_STATUSES.has(s.status) const isSelected = selectedTaskId === s.taskId const agentName = s.assigneeIds[0]?.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) ?? s.title const projectionLabel = s.workItemProjectionId?.replace(/_/g, ' ') ?? '' const tIcon = terminalIcon(s.status) const tCls = terminalClass(s.status) return ( ) })} {sorted.length === 0 && (
{search ? 'No matching agents' : 'No agents in this filter'}
)}
{/* Detail view for selected agent */} {selected && (
{(selectedAgent?.name ?? selected.assigneeIds[0] ?? '?').charAt(0).toUpperCase()}
{selectedAgent?.name ?? selected.assigneeIds[0] ?? selected.title} {getWorkItemRoleLabel(selected) && ( {getWorkItemRoleLabel(selected)} )} {selected.employeeAssignment?.name && ( {selected.employeeAssignment.name} )} {selected.selectedExecutionAgent && ( Agent: {EXECUTION_AGENT_LABELS[selected.selectedExecutionAgent] ?? selected.selectedExecutionAgent} )} {selected.workItemProjectionId && ( {selected.workItemProjectionId.replace(/_/g, ' ')} )}
{onOpenChildDetail && ( )} {onOpenExecutionPanel && ( )}
{selected.handoffContext && (
Handoff
)}
Activity
{selected.progressLog.length === 0 && !selected.agentStatus && !TERMINAL_SESSION_STATUSES.has(selected.status) && (
No activity recorded yet
)}
{TERMINAL_SESSION_STATUSES.has(selected.status) && RESULT_BANNER[selected.status] && (
{terminalIcon(selected.status)} {RESULT_BANNER[selected.status].text}
)}
)}
) } export function AgentWorkPanel({ sessions, roleWorkItems, isCompanyRuntime = false, agents, onOpenChildDetail, onOpenExecutionPanel, }: AgentWorkPanelProps) { // Prefer the work-item-driven view whenever the backend provides it: // it is the single source of truth for "1 row = 1 work item". if (roleWorkItems && Object.keys(roleWorkItems).length > 0) { return ( ) } if (isCompanyRuntime) return null return ( ) }