Files
OpenOPC/opc/plugins/office_ui/frontend_src/chat/WorkItemProgressCard.tsx
T
2026-07-13 23:02:39 +08:00

583 lines
22 KiB
TypeScript

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<string, RoleWorkItemSummary>
/** Display-only executor-role rollup. Company/org Execution Progress
* prefers this so worker chips remain visible while awaiting review. */
executorRoleWorkItems?: Record<string, RoleWorkItemSummary>
/** 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<string, string> = {
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<WorkItemStatus, number> = {
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<string, string> = {
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 <span className="wi-projection-icon">&#10003;</span>
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 <span className="wi-projection-icon wi-projection-pulse">&#9679;</span>
}
if (status === 'failed') return <span className="wi-projection-icon">&#10007;</span>
if (status === 'waiting') return <span className="wi-projection-icon">&#9679;</span>
return null
}
const AGGREGATED_TO_WORK_ITEM_STATUS: Record<RoleAggregatedStatus, WorkItemStatus> = {
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<string, Session>()
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<string, WorkItemInfo>()
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<string, number>()
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<RoleSummaryInfo[]>(() => {
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<RoleSummaryInfo[]>(() => {
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<string, Session[]>()
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<RoleSummaryInfo[]>(() => (
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<WorkItemInfo[]>(() => {
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])
const isPreparingCompanyRuntime = isCompanyRuntime && roleSummaries.length === 0
if (!isCompanyRuntime && workItemLog.length === 0 && workItems.length === 0 && roleSummaries.length === 0) return null
return (
<div className="wi-progress-card">
<div className="wi-progress-header">
<IconWorkItem />
<span>Execution Progress</span>
</div>
{workItems.length > 0 && (
<div className="wi-progress-pipeline">
{workItems.map((workItem, i) => (
<div key={workItem.projectionId} className="wi-projection-group">
<button
type="button"
className={`wi-projection-chip wi-projection-${workItem.status}`}
onClick={() => onWorkItemClick?.(workItem.executionTurnId || '')}
title={`Open Runtime Session${workItem.roleName ? `: ${workItem.title} (${workItem.roleName})` : `: ${workItem.title}`}`}
>
<span className="wi-projection-label">{workItem.title}</span>
{renderProjectionIcon(workItem.status)}
</button>
{i < workItems.length - 1 && <span className="wi-projection-connector">&rarr;</span>}
</div>
))}
</div>
)}
{isPreparingCompanyRuntime && (
<div className="wi-progress-pipeline wi-progress-pipeline-empty" role="status">
Preparing company roles
</div>
)}
</div>
)
}