import React, { useMemo } from 'react' import type { ReactNode } from 'react' import type { AgentAnimStatus, ProgressEntry, RoleAggregatedStatus, RoleWorkItemActivitySection, RoleWorkItemRow, RoleWorkItemSummary, Session, WorkItemProgressEntry, } from '../types/kanban' import { IconWorkItem } from './SvgIcons' import { getWorkItemAssignmentLabel, humanizeWorkItemRoleId } from '../lib/workItemIdentity' import { getExecutionTurnId } from '../lib/workItemRuntimeIds' interface WorkItemProgressCardProps { workItemLog: WorkItemProgressEntry[] /** * Per-role DelegationWorkItem rollup. When present, drives the panel * (1 row = 1 work item; review work items appear under reviewer role). * Falls back to ``childSessions`` derivation only when undefined / empty * (legacy non-company-mode runs). * Source: ``snapshot_builder._build_role_work_items_for_session``. */ roleWorkItems?: Record /** Display-only executor-role rollup. Company/org Execution Progress * prefers this so worker chips remain visible while awaiting review. */ executorRoleWorkItems?: Record /** Legacy session-based source. Kept for non-company-mode and for the * case where a primary session has no ``roleWorkItems`` payload yet. */ childSessions?: Session[] /** Company/org mode uses work-item rollups as the only role source. */ isCompanyRuntime?: boolean onWorkItemClick?: (executionTurnId: string) => void } type WorkItemStatus = 'active' | 'done' | 'failed' | 'waiting' | 'pending' interface WorkItemInfo { projectionId: string title: string roleName?: string status: WorkItemStatus executionTurnId?: string } interface RoleTurnInfo { executionTurnId: string title: string status: WorkItemStatus statusLabel: string // kanban column label (To do / In progress / In review / Done) columnId: string // todo | in_progress | in_review | done updatedAt: number /** Set when the row was derived from a DelegationWorkItem rather than * a runtime Session. Used as the React key and for inline activity * expansion. */ workItemId?: string /** Per-row activity entries (already filtered by work-item projection * on the backend). Empty for session-derived rows. */ progressLog?: ProgressEntry[] activitySections?: RoleWorkItemActivitySection[] /** True when this row sits under the reviewer because the work item is * in an ``in_review`` phase. */ isReviewTarget?: boolean } interface RoleSummaryInfo { roleKey: string executionTurnId: string // default click target = latest turn title: string // role display name status: WorkItemStatus // aggregated across all turns statusLabel: string // kanban label for aggregated status executionAgent?: string roleName?: string updatedAt: number // most recent turn's updatedAt turns: RoleTurnInfo[] // chronological ASC (oldest first, newest last) /** Live tracker state (only set for work-item-driven summaries). When * ``reflecting`` / ``tool_active`` the chip shows the orange pulse; * otherwise the chip's colour is governed by ``status`` alone. */ runtimeStatus?: AgentAnimStatus } /** Kanban-column labels used both on the role's aggregate chip and on * each per-turn row. Matches the column headers shown on the kanban * board so the vocabulary stays consistent across views. */ const COLUMN_LABELS: Record = { todo: 'To do', in_progress: 'In progress', in_review: 'In review', done: 'Done', } /** Map the WorkItemStatus derived from a Session back to the kanban column * it sits in. Keeps the label logic in one place rather than diverging * between the role chip and the per-turn row. */ function workItemStatusToColumnId(status: WorkItemStatus, fallbackColumnId?: string): string { if (fallbackColumnId && fallbackColumnId in COLUMN_LABELS) return fallbackColumnId switch (status) { case 'active': return 'in_progress' case 'waiting': return 'in_review' case 'done': return 'done' case 'failed': return 'done' case 'pending': return 'todo' default: return 'todo' } } function labelForColumnId(columnId: string): string { return COLUMN_LABELS[columnId] ?? COLUMN_LABELS.todo } /** Priority used to aggregate status across a role's turns so the main * chip reflects "any turn still in flight" rather than whatever was * updated last. */ const STATUS_AGGREGATE_PRIORITY: Record = { active: 0, waiting: 1, failed: 2, done: 3, pending: 4, } function aggregateStatus(statuses: WorkItemStatus[]): WorkItemStatus { if (statuses.length === 0) return 'pending' let best: WorkItemStatus = statuses[0] let bestRank = STATUS_AGGREGATE_PRIORITY[best] ?? 99 for (const s of statuses) { const rank = STATUS_AGGREGATE_PRIORITY[s] ?? 99 if (rank < bestRank) { best = s bestRank = rank } } return best } const EXECUTION_AGENT_LABELS: Record = { native: 'Native', codex: 'Codex', claude_code: 'Claude Code', cursor: 'Cursor', opencode: 'OpenCode', } /** Check if a string looks like a UUID or long hex id */ function isUuidLike(s: string): boolean { return s.length > 12 && /^[0-9a-f-]+$/i.test(s.replace(/_/g, '')) } function trimString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } function firstNonEmpty(...values: unknown[]): string { for (const value of values) { const text = trimString(value) if (text) return text } return '' } function formatExecutionAgent(value?: string): string { const normalized = trimString(value) if (!normalized) return '' return EXECUTION_AGENT_LABELS[normalized] ?? humanizeWorkItemRoleId(normalized) } function renderProjectionIcon(status: WorkItemStatus): ReactNode { if (status === 'done') return if (status === 'active') { // Always pulse when the role is "active". Active aggregates two cases // — (a) tracker is reflecting/tool_active right now, or (b) at least // one work item is in an in-progress phase. Both read as "this role // is working" to the user, so both should breathe. The earlier // refactor that gated pulse on runtime tracker only made finished-but- // running phases look static, which the user reported as a regression. return } if (status === 'failed') return if (status === 'waiting') return return null } const AGGREGATED_TO_WORK_ITEM_STATUS: Record = { active: 'active', waiting: 'waiting', pending: 'pending', done: 'done', failed: 'failed', } /** Backend ``kanban_column`` returns hyphenated ids (``in-progress`` / * ``in-review``); this card's CSS classes use underscored forms. The * conversion lives here so the rest of the file keeps speaking one * vocabulary. */ function normalizeColumnId(columnId: string): string { if (!columnId) return 'todo' if (columnId === 'in-progress') return 'in_progress' if (columnId === 'in-review') return 'in_review' return columnId } /** Per-row status used for icon + chip colour. Mirrors the backend * phase → column mapping (``opc/layer2_organization/phase.py``) but * reduced to the 5 UI states the chip renders. */ function phaseAggregateForRow(phase: string): WorkItemStatus { switch (phase) { case 'running': case 'waiting_for_peer': case 'waiting_for_children': case 'paused': case 'needs_attention': return 'active' case 'awaiting_manager_review': case 'awaiting_human': case 'queued': case 'ready': case 'ready_for_rework': case 'waiting_dependencies': return 'waiting' case 'approved': return 'done' case 'failed': case 'cancelled': return 'failed' default: return 'pending' } } function statusFromSession(session: Session): WorkItemStatus { const status = trimString(session.status).toLowerCase() if (status === 'done' || status === 'delivered') return 'done' if (status === 'failed' || status === 'cancelled') return 'failed' if ( status === 'awaiting_peer' || status === 'awaiting_manager_review' || status === 'awaiting_human' || status === 'awaiting_review' || status === 'blocked' || status === 'paused' || status === 'awaiting_owner' ) { return 'waiting' } if (status === 'running' || status === 'deliverable' || status === 'active' || status === 'ready') return 'active' return 'pending' } export function WorkItemProgressCard({ workItemLog, roleWorkItems, executorRoleWorkItems, childSessions, isCompanyRuntime = false, onWorkItemClick, }: WorkItemProgressCardProps) { // Build child session lookup for enriching display names const sessionByTaskId = useMemo(() => { const map = new Map() for (const s of childSessions ?? []) { map.set(s.taskId, s) const executionTurnId = getExecutionTurnId(s) if (executionTurnId) map.set(executionTurnId, s) } return map }, [childSessions]) // Build ordered work-item list from log entries — used // only as a fallback when no child sessions exist yet, because the // work-item log emits one projection id per runtime turn which means a single // role can appear multiple times (execute + each review + attention). // For kanban-push runs we'd rather derive the pipeline from // roleSummaries below, which already groups by workItemRoleId. const workItemLogWorkItems = useMemo(() => { const map = new Map() const order: string[] = [] for (const entry of workItemLog) { const entryExecutionTurnId = getExecutionTurnId(entry) const projectionId = entry.workItemProjectionId ?? entryExecutionTurnId ?? '' if (!projectionId) continue if (!map.has(projectionId)) { order.push(projectionId) // Determine display title — prefer roleName, avoid UUID fallback const rawTitle = entry.workItemProjectionTitle ?? projectionId const title = entry.roleName || (!isUuidLike(rawTitle) ? rawTitle : 'Agent') map.set(projectionId, { projectionId, title, roleName: entry.roleName, status: 'pending', executionTurnId: entryExecutionTurnId, }) } const info = map.get(projectionId)! // Update with latest role/title info if (entry.roleName) { info.roleName = entry.roleName info.title = entry.roleName } else if (entry.workItemProjectionTitle && !isUuidLike(entry.workItemProjectionTitle)) { info.title = entry.workItemProjectionTitle } if (entryExecutionTurnId) info.executionTurnId = entryExecutionTurnId // Update status based on event type switch (entry.type) { case 'work_item_started': info.status = 'active'; break case 'gate_approved': info.status = 'done'; break case 'gate_rejected': info.status = 'active'; break case 'awaiting_manager_review': case 'awaiting_human': case 'awaiting_review': case 'awaiting_peer': info.status = 'waiting'; break case 'work_item_failed': case 'deadlock': info.status = 'failed'; break } } // Enrich from child sessions for (const info of map.values()) { if (!info.executionTurnId) continue const session = sessionByTaskId.get(info.executionTurnId) if (!session) continue const label = getWorkItemAssignmentLabel(session) if (label) info.title = label if (session.workItemRoleName) info.roleName = session.workItemRoleName if (session.status === 'done') info.status = 'done' else if (session.status === 'failed') info.status = 'failed' } return order.map(id => map.get(id)!) }, [workItemLog, sessionByTaskId]) const taskOrder = useMemo(() => { const map = new Map() for (const entry of workItemLog) { const taskId = getExecutionTurnId(entry) if (taskId && !map.has(taskId)) map.set(taskId, map.size) } return map }, [workItemLog]) // Primary path: drive rows directly from the per-role DelegationWorkItem // rollup the backend ships in ``session.role_work_items``. This is the // fix for "1 row should = 1 work item" — the legacy session-driven // derivation below treats every runtime Task as its own row, which // double-counts rework turns and silently drops queued / review-target // work items. See ``plan/bug-breezy-dragonfly.md`` for the full root-cause // breakdown. const displayRoleWorkItems = executorRoleWorkItems ?? roleWorkItems const roleSummariesFromWorkItems = useMemo(() => { if (!displayRoleWorkItems) return [] const summaries: RoleSummaryInfo[] = [] for (const summary of Object.values(displayRoleWorkItems)) { if (!summary || !Array.isArray(summary.workItems) || summary.workItems.length === 0) continue // Backend already sorts ASC by createdAt; defensive copy + re-sort // here means a prop-mutation upstream can't reorder the rows. const ordered = [...summary.workItems].sort((a, b) => a.createdAt - b.createdAt) const turns: RoleTurnInfo[] = ordered.map((row: RoleWorkItemRow) => { const columnId = normalizeColumnId(row.kanbanColumn) const phaseToStatus = phaseAggregateForRow(row.phase) return { executionTurnId: row.executionTurnId ?? '', title: trimString(row.title) || trimString(row.executorRoleName) || trimString(row.executorRoleId) || 'Work item', status: phaseToStatus, statusLabel: labelForColumnId(columnId), columnId, updatedAt: row.updatedAt, workItemId: row.workItemId, progressLog: row.progressLog, activitySections: row.activitySections ?? [], isReviewTarget: row.isReviewTarget, } }) const aggregatedStatus = AGGREGATED_TO_WORK_ITEM_STATUS[summary.aggregatedStatus] ?? 'pending' const aggregatedColumnId = workItemStatusToColumnId(aggregatedStatus) const latest = turns[turns.length - 1] // Chip click target: prefer the latest turn's runtime task. When the // last work item has no execution turn yet (queued / never dispatched), // walk back to find the most recent dispatched turn. const fallbackExecutionTurnId = [...turns].reverse().find(t => !!t.executionTurnId)?.executionTurnId ?? '' summaries.push({ roleKey: summary.roleKey, executionTurnId: latest.executionTurnId || fallbackExecutionTurnId, title: summary.roleName || summary.roleId, status: aggregatedStatus, statusLabel: labelForColumnId(aggregatedColumnId), roleName: summary.roleName, updatedAt: turns.reduce((max, t) => Math.max(max, t.updatedAt), 0), turns, runtimeStatus: summary.runtimeStatus, }) } // Stable order: roles first appearing in time go left. summaries.sort((a, b) => { const aFirst = a.turns[0]?.updatedAt ?? Number.POSITIVE_INFINITY const bFirst = b.turns[0]?.updatedAt ?? Number.POSITIVE_INFINITY return aFirst - bFirst }) return summaries }, [displayRoleWorkItems]) const roleSummariesFromSessions = useMemo(() => { const sessions = [...(childSessions ?? [])] if (sessions.length === 0) return [] // Group by workItemRoleId (fall back to the assignee when missing // so legacy non-company-mode sessions still get a stable key). const groups = new Map() const groupOrder: string[] = [] for (const session of sessions) { const roleKey = firstNonEmpty( session.workItemRoleId, session.assigneeIds[0], session.taskId, // last-resort singleton ) if (!groups.has(roleKey)) { groups.set(roleKey, []) groupOrder.push(roleKey) } groups.get(roleKey)!.push(session) } // Sort role groups by the earliest work-item-log/updatedAt position // of any turn in the group so the bar reads left-to-right in the // order roles first appeared. groupOrder.sort((leftKey, rightKey) => { const leftSessions = groups.get(leftKey) ?? [] const rightSessions = groups.get(rightKey) ?? [] const leftOrder = Math.min( ...leftSessions.map(s => taskOrder.get(getExecutionTurnId(s) || s.taskId) ?? Number.POSITIVE_INFINITY), ) const rightOrder = Math.min( ...rightSessions.map(s => taskOrder.get(getExecutionTurnId(s) || s.taskId) ?? Number.POSITIVE_INFINITY), ) if (leftOrder !== rightOrder) return leftOrder - rightOrder const leftUpdated = Math.min(...leftSessions.map(s => s.updatedAt)) const rightUpdated = Math.min(...rightSessions.map(s => s.updatedAt)) return leftUpdated - rightUpdated }) const summaries: RoleSummaryInfo[] = [] for (const roleKey of groupOrder) { const roleSessions = groups.get(roleKey) ?? [] if (roleSessions.length === 0) continue // Build one turn entry per session, sorted chronologically (ASC). // Per design: "a 最前面, c 最后面" — first-happened goes first. // Each turn shows only its kanban column (To do / In progress / // In review / Done). No synthetic "kind" classification. const turns: RoleTurnInfo[] = roleSessions .slice() .sort((a, b) => a.updatedAt - b.updatedAt) .map((session) => { const status = statusFromSession(session) const executionTurnId = getExecutionTurnId(session) const columnId = workItemStatusToColumnId( status, String((session as Session & { columnId?: string }).columnId ?? '').trim() || undefined, ) return { executionTurnId: executionTurnId || session.taskId, title: firstNonEmpty( getWorkItemAssignmentLabel(session), session.title, session.workItemRoleName, session.workItemRoleId, 'Turn', ), status, statusLabel: labelForColumnId(columnId), columnId, updatedAt: session.updatedAt, } }) // The newest turn is the one the user probably wants to open when // they click the main chip — "what is this role doing right now" // beats "what did this role do first". const latest = turns[turns.length - 1] const aggregatedStatus = aggregateStatus(turns.map(t => t.status)) const aggregatedColumnId = workItemStatusToColumnId(aggregatedStatus) // Role name beats task title on the main chip so the chip always // reads as "this role" regardless of which of its turns is loaded // as latest. const representative = roleSessions[0] const title = firstNonEmpty( representative.workItemRoleName, representative.workItemRoleId, representative.assigneeIds[0], getWorkItemAssignmentLabel(representative), representative.title, 'Agent', ) summaries.push({ roleKey, executionTurnId: latest.executionTurnId, title, status: aggregatedStatus, statusLabel: labelForColumnId(aggregatedColumnId), executionAgent: formatExecutionAgent( representative.selectedExecutionAgent ?? representative.preferredAgent, ), roleName: representative.workItemRoleName, updatedAt: Math.max(...roleSessions.map(s => s.updatedAt)), turns, }) } return summaries }, [childSessions, taskOrder]) // Pick the work-item-driven summaries when present. In company/org // mode, runtime sessions are only audit targets; they must not synthesize // role rows because that recreates the role/session mix-up seen in new37. const roleSummaries = useMemo(() => ( roleSummariesFromWorkItems.length > 0 ? roleSummariesFromWorkItems : (isCompanyRuntime ? [] : roleSummariesFromSessions) ), [isCompanyRuntime, roleSummariesFromWorkItems, roleSummariesFromSessions]) // Top-level pipeline: one chip per role, derived from roleSummaries // when available (kanban-push runs). Fall back to work-item-log work items // when child sessions haven't been serialized yet. const workItems = useMemo(() => { if (roleSummaries.length > 0) { return roleSummaries.map(role => ({ projectionId: role.roleKey, title: role.title, roleName: role.roleName, status: role.status, executionTurnId: role.executionTurnId, })) } return isCompanyRuntime ? [] : workItemLogWorkItems }, [isCompanyRuntime, roleSummaries, workItemLogWorkItems]) if (isCompanyRuntime && roleSummaries.length === 0) return null if (workItemLog.length === 0 && workItems.length === 0 && roleSummaries.length === 0) return null return (
Execution Progress
{workItems.length > 0 && (
{workItems.map((workItem, i) => (
{i < workItems.length - 1 && }
))}
)}
) }