import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ProgressEntry, RoleAggregatedStatus, RoleWorkItemActivitySection, RoleWorkItemRow, RoleWorkItemSummary, } from '../types/kanban' import type { AgentInfo } from '../types/visual' import { AgentProgressBlock } from '../chat/AgentProgressBlock' import { IconClose, IconTimeline, IconWorkItem } from '../chat/SvgIcons' interface ExecutionPanelProps { role: RoleWorkItemSummary focusedWorkItemId?: string focusedExecutionTurnId?: string agents: AgentInfo[] onClose: () => void } const ROLE_STATUS_BADGE: Record = { active: { label: 'Working', cls: 'exec-badge-running' }, waiting: { label: 'Waiting', cls: 'exec-badge-idle' }, pending: { label: 'Pending', cls: 'exec-badge-pending' }, done: { label: 'Done', cls: 'exec-badge-done' }, failed: { label: 'Failed', cls: 'exec-badge-failed' }, } const ROW_STATUS_BADGE: Record = { todo: { label: 'To do', cls: 'exec-badge-pending' }, 'in-progress': { label: 'In progress', cls: 'exec-badge-running' }, 'in-review': { label: 'In review', cls: 'exec-badge-idle' }, done: { label: 'Done', cls: 'exec-badge-done' }, failed: { label: 'Failed', cls: 'exec-badge-failed' }, cancelled: { label: 'Cancelled', cls: 'exec-badge-cancelled' }, } function formatRelativeTime(ts: number): string { const sec = Math.floor((Date.now() - ts) / 1000) if (sec < 5) return 'now' if (sec < 60) return `${sec}s ago` const min = Math.floor(sec / 60) if (min < 60) return `${min}m ago` const hr = Math.floor(min / 60) if (hr < 24) return `${hr}h ago` return `${Math.floor(hr / 24)}d ago` } function humanize(value?: string): string { const text = String(value ?? '').trim() if (!text) return '' return text.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } function rowBadge(row: RoleWorkItemRow): { label: string; cls: string } { if (row.phase === 'failed') return ROW_STATUS_BADGE.failed if (row.phase === 'cancelled') return ROW_STATUS_BADGE.cancelled return ROW_STATUS_BADGE[row.kanbanColumn] ?? ROW_STATUS_BADGE.todo } function rowSessionStatus(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 countActivityEntries(row: RoleWorkItemRow): number { const sections = row.activitySections ?? [] if (sections.length > 0) { return sections.reduce((count, section) => count + (section.entries?.length ?? 0), 0) } return row.progressLog.length } function ActivitySections({ 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 activity recorded yet
} return ( ) } export function ExecutionPanel({ role, focusedWorkItemId, focusedExecutionTurnId, agents, onClose, }: ExecutionPanelProps) { const rows = useMemo(() => ( role.workItems .slice() .sort((a, b) => a.createdAt - b.createdAt) ), [role.workItems]) const focused = useMemo(() => ( rows.find(row => ( (!!focusedWorkItemId && row.workItemId === focusedWorkItemId) || (!!focusedExecutionTurnId && row.executionTurnId === focusedExecutionTurnId) )) ?? rows[rows.length - 1] ?? null ), [focusedExecutionTurnId, focusedWorkItemId, rows]) const focusedRowKey = focused?.workItemId const [expandedIds, setExpandedIds] = useState>(() => { const init = new Set() if (focusedRowKey) init.add(focusedRowKey) return init }) const autoExpandedRef = useRef>(new Set()) useEffect(() => { if (!focusedRowKey || autoExpandedRef.current.has(focusedRowKey)) return autoExpandedRef.current.add(focusedRowKey) setExpandedIds(prev => { if (prev.has(focusedRowKey)) return prev const next = new Set(prev) next.add(focusedRowKey) return next }) }, [focusedRowKey]) const toggleExpanded = useCallback((workItemId: string) => { setExpandedIds(prev => { const next = new Set(prev) if (next.has(workItemId)) next.delete(workItemId) else next.add(workItemId) return next }) }, []) const focusedCardRef = useRef(null) const lastScrolledIdRef = useRef(null) useEffect(() => { if (!focusedRowKey || lastScrolledIdRef.current === focusedRowKey) return lastScrolledIdRef.current = focusedRowKey focusedCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }, [focusedRowKey]) const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose]) useEffect(() => { document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [handleKeyDown]) const roleAgent = agents.find(agent => agent.agent_id === role.roleId) const roleName = role.roleName || roleAgent?.name || humanize(role.roleId) || 'Role' const headerBadge = ROLE_STATUS_BADGE[role.aggregatedStatus] ?? ROLE_STATUS_BADGE.pending return ( <>

{roleName}

{headerBadge.label} {rows.length} Work Item{rows.length === 1 ? '' : 's'}
{roleName.charAt(0).toUpperCase()}
{roleName} {humanize(role.roleId)} {role.roleSessionId && ( {role.roleSessionId} )}
{rows.length === 0 && (
No work items yet
)} {rows.map((row, index) => { const isFocused = row.workItemId === focusedRowKey const isExpanded = expandedIds.has(row.workItemId) const badge = rowBadge(row) const activityCount = countActivityEntries(row) return (
{isExpanded && (
{humanize(row.kind) || 'Work item'} {row.workItemProjectionId && {row.workItemProjectionId}} {row.executionTurnId && Execution Turn} {row.isReviewTarget && Review target} {row.executorRoleName && {row.executorRoleName}}
Activity {activityCount}
)}
) })}
) }