Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,313 @@
import React, { useMemo, useState } from 'react'
import type { ProgressEntry, ProgressEntryType } from '../types/kanban'
import { progressEntryKey } from '../lib/progressEntryKey'
import { IconBrain, IconTool, IconChevron, IconSparkle, IconShield, IconArrowRight, IconGate, IconZap, IconWorkItem, IconGatePass, IconGateReject, IconClock, IconHandoff } from './SvgIcons'
interface AgentProgressBlockProps {
entries: ProgressEntry[]
agentStatus?: string
currentTool?: string
toolElapsedMs?: number
lastToolSummary?: string
sessionStatus?: string
expandedByDefault?: boolean
}
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled'])
export const INLINE_PROGRESS_ENTRY_TYPES = new Set<ProgressEntryType>(['thinking', 'tool_call', 'autonomy', 'needs_input', 'verification'])
const ENTRY_CONFIG: Record<ProgressEntryType, { icon: React.ReactNode; color: string; label: string }> = {
thinking: { icon: <IconBrain />, color: 'var(--accent)', label: 'Thinking' },
tool_call: { icon: <IconTool />, color: 'var(--green)', label: 'Tool' },
autonomy: { icon: <IconShield />, color: 'var(--yellow)', label: 'Autonomy' },
handoff: { icon: <IconArrowRight />, color: 'var(--accent)', label: 'Handoff' },
gate_result: { icon: <IconGate />, color: 'var(--green)', label: 'Gate' },
status_change: { icon: <IconZap />, color: 'var(--text-secondary)', label: 'Status' },
work_item_started: { icon: <IconWorkItem />, color: 'var(--accent)', label: 'Work item' },
gate_approved: { icon: <IconGatePass />, color: 'var(--green)', label: 'Gate Passed' },
gate_rejected: { icon: <IconGateReject />, color: 'var(--red)', label: 'Rejected' },
awaiting_manager_review: { icon: <IconClock />, color: 'var(--yellow)', label: 'Awaiting Manager Review' },
awaiting_human: { icon: <IconClock />, color: 'var(--yellow)', label: 'Awaiting Human Review' },
awaiting_review: { icon: <IconClock />, color: 'var(--yellow)', label: 'Awaiting Review' },
awaiting_peer: { icon: <IconClock />, color: 'var(--yellow)', label: 'Awaiting Peer' },
work_item_failed: { icon: <IconZap />, color: 'var(--red)', label: 'Failed' },
deadlock: { icon: <IconHandoff />, color: 'var(--red)', label: 'Deadlock' },
needs_input: { icon: <IconClock />, color: 'var(--yellow)', label: 'Needs Input' },
verification: { icon: <IconShield />, color: 'var(--accent)', label: 'Verification' },
}
const COLLAPSED_COUNT = 5
function elapsed(ts: number): string {
const sec = Math.floor((Date.now() - ts) / 1000)
if (sec < 5) return 'just now'
if (sec < 60) return `${sec}s ago`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
return `${Math.floor(min / 60)}h ago`
}
function normalizeNestedJson(value: unknown): unknown {
if (typeof value === 'string') {
const trimmed = value.trim()
if (
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|| (trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
try {
return normalizeNestedJson(JSON.parse(trimmed))
} catch {
return value
}
}
return value
}
if (Array.isArray(value)) {
return value.map(normalizeNestedJson)
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, normalizeNestedJson(nestedValue)]),
)
}
return value
}
function formatToolDetail(detail: string): string {
const trimmed = detail.trim()
if (!trimmed) return detail
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return detail
try {
return JSON.stringify(normalizeNestedJson(JSON.parse(trimmed)), null, 2)
} catch {
return detail
}
}
export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElapsedMs, lastToolSummary, sessionStatus, expandedByDefault }: AgentProgressBlockProps) {
const [expanded, setExpanded] = useState(!!expandedByDefault)
const isTerminal = !!sessionStatus && TERMINAL_STATUSES.has(sessionStatus)
const isThinking = !isTerminal && agentStatus === 'reflecting'
const isToolActive = !isTerminal && agentStatus === 'tool_active'
const isWorking = isThinking || isToolActive
const filteredEntries = useMemo(() => {
return entries
}, [entries])
const visibleEntries = useMemo(() => {
if (expanded || filteredEntries.length <= COLLAPSED_COUNT) return filteredEntries
return filteredEntries.slice(-COLLAPSED_COUNT)
}, [filteredEntries, expanded])
const hiddenCount = filteredEntries.length - visibleEntries.length
if (filteredEntries.length === 0 && !isWorking && !isTerminal) return null
return (
<div className="ptl-block">
{/* ── Live status indicator ──────────────────────── */}
{isWorking && (
<div className={`ptl-live ${isToolActive ? 'ptl-live-tool' : 'ptl-live-think'}`}>
<span className="ptl-live-icon">
{isToolActive ? <IconTool /> : <IconSparkle />}
</span>
<span className="ptl-live-text">
{isToolActive ? 'Running' : 'Thinking'}
</span>
{isToolActive && currentTool && (
<code className="ptl-live-tool-name">{currentTool}</code>
)}
{isToolActive && typeof toolElapsedMs === 'number' && toolElapsedMs > 0 && (
<span className="ptl-live-elapsed">
{toolElapsedMs < 1000 ? `${toolElapsedMs}ms` : `${(toolElapsedMs / 1000).toFixed(1)}s`}
</span>
)}
<span className="ptl-live-shimmer" />
</div>
)}
{/* ── Last tool result summary ──────────────────── */}
{lastToolSummary && !isToolActive && (
<div className="ptl-last-tool-summary">
<span className="ptl-last-tool-label">Last tool result:</span>
<span className="ptl-last-tool-text">{lastToolSummary}</span>
</div>
)}
{/* ── Collapse toggle (above timeline) ──────────── */}
{hiddenCount > 0 && (
<button className="ptl-expand" onClick={() => setExpanded(true)}>
<IconChevron />
<span>{hiddenCount} earlier step{hiddenCount > 1 ? 's' : ''}</span>
</button>
)}
{/* ── Timeline entries ───────────────────────────── */}
{visibleEntries.length > 0 && (
<div className="ptl-timeline">
{visibleEntries.map((entry, i) => {
const isLast = i === visibleEntries.length - 1
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
return (
<div key={progressEntryKey(entry, i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
<div className="ptl-connector">
<div className="ptl-dot" style={{ color: cfg.color }}>
{cfg.icon}
</div>
{!isLast && <div className="ptl-line" />}
</div>
<div className="ptl-content">
<AgentProgressEntryCard entry={entry} />
</div>
</div>
)
})}
</div>
)}
{/* ── Terminal state completion indicator ──────────── */}
{isTerminal && !isWorking && (
<div className={`ptl-completion ptl-completion-${sessionStatus}`}>
<span className="ptl-completion-icon">
{sessionStatus === 'done' ? '\u2713' : sessionStatus === 'failed' ? '\u2717' : '\u2014'}
</span>
<span className="ptl-completion-text">
{sessionStatus === 'done' ? 'Completed' : sessionStatus === 'failed' ? 'Failed' : 'Cancelled'}
</span>
</div>
)}
{/* ── Collapse button (when expanded) ────────────── */}
{expanded && filteredEntries.length > COLLAPSED_COUNT && (
<button className="ptl-expand" onClick={() => setExpanded(false)}>
<IconChevron down />
<span>Show less</span>
</button>
)}
</div>
)
}
export const AgentProgressEntryCard = React.memo(function AgentProgressEntryCard({ entry }: { entry: ProgressEntry }) {
const [expanded, setExpanded] = useState(false)
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
const hasToolDetail = entry.type === 'tool_call' && !!entry.detail
if (entry.type === 'tool_call') {
return (
<div className={`ptl-tool-card${expanded ? ' expanded' : ''}`}>
<button
className={`ptl-row ptl-tool-toggle${hasToolDetail ? ' clickable' : ''}`}
onClick={() => {
if (!hasToolDetail) return
setExpanded(prev => !prev)
}}
type="button"
>
<span className="ptl-label" style={{ color: cfg.color }}>{cfg.label}</span>
<code className="ptl-tool-badge">{entry.summary}</code>
<span className="ptl-time">{elapsed(entry.timestamp)}</span>
{hasToolDetail && (
<span className="ptl-tool-chevron">
<IconChevron down={expanded} />
</span>
)}
</button>
{expanded && entry.detail && (
<pre className="ptl-tool-card-detail">{formatToolDetail(entry.detail)}</pre>
)}
</div>
)
}
if (entry.type === 'autonomy') {
const hasDetail = !!entry.detail
return (
<div className={`ptl-tool-card ptl-autonomy-card${expanded ? ' expanded' : ''}`}>
<button
className={`ptl-row ptl-tool-toggle${hasDetail ? ' clickable' : ''}`}
onClick={() => { if (hasDetail) setExpanded(prev => !prev) }}
type="button"
>
<span className="ptl-label" style={{ color: cfg.color }}>{cfg.label}</span>
<code className="ptl-tool-badge">{entry.summary}</code>
<span className="ptl-time">{elapsed(entry.timestamp)}</span>
{hasDetail && (
<span className="ptl-tool-chevron">
<IconChevron down={expanded} />
</span>
)}
</button>
{expanded && entry.detail && (
<div className="ptl-tool-card-detail ptl-autonomy-detail">{entry.detail}</div>
)}
</div>
)
}
if (entry.type === 'thinking') {
return (
<div className="ptl-tool-card">
<button
className="ptl-row ptl-tool-toggle clickable"
onClick={() => setExpanded(prev => !prev)}
type="button"
>
<span className="ptl-label" style={{ color: cfg.color }}>{cfg.label}</span>
<span className="ptl-summary">{entry.summary}</span>
<span className="ptl-time">{elapsed(entry.timestamp)}</span>
<span className="ptl-tool-chevron">
<IconChevron down={expanded} />
</span>
</button>
{expanded && (
<div className="ptl-tool-card-detail ptl-thinking-detail">{entry.detail || entry.summary}</div>
)}
</div>
)
}
if (entry.type === 'verification') {
const hasDetail = !!entry.detail
return (
<div className={`ptl-tool-card ptl-verification-card${expanded ? ' expanded' : ''}`}>
<button
className={`ptl-row ptl-tool-toggle${hasDetail ? ' clickable' : ''}`}
onClick={() => { if (hasDetail) setExpanded(prev => !prev) }}
type="button"
>
<span className="ptl-label" style={{ color: cfg.color }}>{cfg.label}</span>
<span className="ptl-summary">{entry.summary || 'Verification'}</span>
<span className="ptl-time">{elapsed(entry.timestamp)}</span>
{hasDetail && (
<span className="ptl-tool-chevron">
<IconChevron down={expanded} />
</span>
)}
</button>
{expanded && entry.detail && (
<div className="ptl-tool-card-detail ptl-verification-detail">{entry.detail}</div>
)}
</div>
)
}
return (
<>
<div className="ptl-row">
<span className="ptl-label" style={{ color: cfg.color }}>{cfg.label}</span>
<span className="ptl-summary">{entry.summary}</span>
<span className="ptl-time">{elapsed(entry.timestamp)}</span>
</div>
{entry.detail && (
<div className="ptl-detail">{entry.detail}</div>
)}
</>
)
})
@@ -0,0 +1,737 @@
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<string, RoleWorkItemSummary>
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<string, string> = {
native: 'Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
const RESULT_BANNER: Record<string, { cls: string; text: string }> = {
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<RoleAggregatedStatus, string> = {
active: 'Working',
waiting: 'Waiting',
pending: 'Pending',
done: 'Completed',
failed: 'Failed',
}
const ROLE_STATUS_SORT: Record<RoleAggregatedStatus, number> = {
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 (
<div className="wf-role-turn-activity-sections">
{visibleSections.map((section, index) => {
const entries = section.entries ?? []
const key = `${section.runtimeTaskId || section.kind}:${index}`
return (
<section key={key} className="wf-role-turn-activity-section">
<div className="wf-role-turn-activity-section-head">
<span className="wf-role-turn-activity-section-title">{section.title}</span>
{section.roleName && (
<span className="wf-role-turn-activity-section-role">{section.roleName}</span>
)}
{entries.length > 0 && (
<span className="wf-role-turn-activity-section-count">{entries.length}</span>
)}
</div>
{entries.length > 0 ? (
<AgentProgressBlock
entries={entries}
sessionStatus={sessionStatus}
expandedByDefault
/>
) : (
<div className="wf-role-turn-empty-activity">No runtime activity yet</div>
)}
</section>
)
})}
</div>
)
}
if (!fallbackEntries || fallbackEntries.length === 0) {
return <div className="wf-role-turn-empty-activity">No runtime activity yet</div>
}
return (
<AgentProgressBlock
entries={fallbackEntries}
sessionStatus={sessionStatus}
expandedByDefault
/>
)
}
function AgentWorkPanelRoleView({
roleWorkItems,
onOpenChildDetail,
onOpenExecutionPanel,
}: {
roleWorkItems: Record<string, RoleWorkItemSummary>
onOpenChildDetail?: (taskId: string) => void
onOpenExecutionPanel?: (taskId: string) => void
}) {
const [selectedRoleKey, setSelectedRoleKey] = useState<string | null>(null)
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null)
const [filter, setFilter] = useState<StatusFilter>('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 (
<div className="awp">
<div className="awp-header">
<span className="awp-title">
Agents
<span className="awp-count">
{activeCount > 0
? `${activeCount}/${summaries.length} active`
: `${summaries.length}`}
</span>
</span>
<div className="awp-controls">
<select
className="awp-filter"
value={filter}
onChange={e => setFilter(e.target.value as StatusFilter)}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="idle">Idle / Done</option>
<option value="pending">Pending</option>
</select>
<button
className={`awp-search-toggle${showSearch ? ' active' : ''}`}
onClick={() => setShowSearch(v => !v)}
title="Search agents"
>
<IconSearch />
</button>
</div>
</div>
{showSearch && (
<div className="awp-search-bar">
<input
className="awp-search-input"
type="text"
placeholder="Search agents or work items..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
)}
<div className="awp-list">
{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 (
<button
key={summary.roleKey}
className={`awp-row${isSelected ? ' awp-row-selected' : ''}${isActive ? ' awp-row-active' : ''}${isTerminal ? ` awp-row-terminal awp-terminal-${summary.aggregatedStatus}` : ''}`}
onClick={() => {
setSelectedRoleKey(prev => (prev === summary.roleKey ? null : summary.roleKey))
setSelectedWorkItemId(null)
}}
title={`${summary.roleName}${ROLE_STATUS_LABEL[summary.aggregatedStatus]}`}
>
<span className={`awp-dot${isLive ? ' awp-dot-active' : ''} awp-dot-${summary.aggregatedStatus}`} />
<div className="awp-row-info">
<span className="awp-row-name">{summary.roleName}</span>
<span className="awp-row-projection">
{summary.workItems.length} work item{summary.workItems.length === 1 ? '' : 's'}
</span>
</div>
<span className="awp-row-activity">{summarizeRoleActivity(summary)}</span>
<span className="awp-row-time">{lastRoleActivity(summary)}</span>
</button>
)
})}
{sorted.length === 0 && (
<div className="awp-empty">
{search ? 'No matching agents' : 'No agents in this filter'}
</div>
)}
</div>
{selected && (
<div className="awp-detail">
<div className="awp-detail-header">
<div className="awp-detail-identity">
<div className="awp-detail-avatar">
{selected.roleName.charAt(0).toUpperCase()}
</div>
<div className="awp-detail-meta">
<span className="awp-detail-name">{selected.roleName}</span>
<span className="awp-detail-role">{ROLE_STATUS_LABEL[selected.aggregatedStatus]}</span>
</div>
</div>
<div className="awp-detail-actions">
<button
className="awp-detail-close"
onClick={() => { setSelectedRoleKey(null); setSelectedWorkItemId(null) }}
title="Close detail"
>
<IconClose />
</button>
</div>
</div>
<div className="awp-detail-body">
<div className="awp-detail-section">
<div className="awp-detail-section-label">
<IconTimeline />
<span>Work items</span>
</div>
<ul className="wf-role-turns">
{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 (
<li key={row.workItemId} className={`wf-role-turn${expanded ? ' wf-role-turn-expanded' : ''}`}>
<button
type="button"
className="wf-role-turn-button"
onClick={() => setSelectedWorkItemId(prev => (prev === row.workItemId ? null : row.workItemId))}
>
<span className={`wf-role-turn-column wf-role-turn-column-${columnId}`}>
{row.kanbanColumn.replace('-', ' ').replace(/^./, c => c.toUpperCase())}
</span>
<span className="wf-role-turn-title">
{row.isReviewTarget && <span className="wf-role-turn-tag">Review</span>}
{row.title}
</span>
<span className="wf-role-turn-time">{elapsed(row.updatedAt)}</span>
<span className="wf-role-turn-chevron">
<IconChevron down={expanded} />
</span>
</button>
{expanded && (
<div className="wf-role-turn-activity">
<WorkItemActivitySections
sections={row.activitySections}
fallbackEntries={row.progressLog}
sessionStatus={roleWorkItemSessionStatus(row)}
/>
{row.executionTurnId && (
<button
type="button"
className="wf-role-turn-open-session"
onClick={(event) => {
event.stopPropagation()
if (row.executionTurnId) {
(onOpenExecutionPanel ?? onOpenChildDetail)?.(row.executionTurnId)
}
}}
>
Open runtime session
</button>
)}
</div>
)}
</li>
)
})}
</ul>
</div>
{selectedWorkItem === null && selected.workItems.length === 0 && (
<div className="awp-detail-empty">No work items yet</div>
)}
</div>
</div>
)}
</div>
)
}
function AgentWorkPanelLegacyView({ sessions, agents, onOpenChildDetail, onOpenExecutionPanel }: AgentWorkPanelLegacyViewProps) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
const [filter, setFilter] = useState<StatusFilter>('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 (
<div className="awp">
{/* Header */}
<div className="awp-header">
<span className="awp-title">
Agents
<span className="awp-count">
{activeCount > 0
? `${activeCount}/${sessions.length} active`
: `${sessions.length}`}
</span>
</span>
<div className="awp-controls">
<select
className="awp-filter"
value={filter}
onChange={e => setFilter(e.target.value as StatusFilter)}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="idle">Idle / Done</option>
<option value="pending">Pending</option>
</select>
<button
className={`awp-search-toggle${showSearch ? ' active' : ''}`}
onClick={() => setShowSearch(v => !v)}
title="Search agents"
>
<IconSearch />
</button>
</div>
</div>
{showSearch && (
<div className="awp-search-bar">
<input
className="awp-search-input"
type="text"
placeholder="Search agents..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
)}
{/* Compact list */}
<div className="awp-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 (
<button
key={s.taskId}
className={`awp-row${isSelected ? ' awp-row-selected' : ''}${isActive ? ' awp-row-active' : ''}${isTerminal ? ` awp-row-terminal ${tCls}` : ''}`}
onClick={() => handleSelect(s.taskId)}
onDoubleClick={() => onOpenChildDetail?.(s.taskId)}
title={onOpenChildDetail ? 'Click to inspect, click again or double-click to open full context' : undefined}
>
{tIcon ? (
<span className={`awp-terminal-icon ${tCls}`}>{tIcon}</span>
) : (
<span className={`awp-dot${isActive ? ' awp-dot-active' : ''}`} />
)}
<div className="awp-row-info">
<span className="awp-row-name">{agentName}</span>
{projectionLabel && <span className="awp-row-projection">{projectionLabel}</span>}
</div>
<span className="awp-row-activity">{activitySummary(s)}</span>
<span className="awp-row-time">{lastActivity(s.progressLog)}</span>
</button>
)
})}
{sorted.length === 0 && (
<div className="awp-empty">
{search ? 'No matching agents' : 'No agents in this filter'}
</div>
)}
</div>
{/* Detail view for selected agent */}
{selected && (
<div className="awp-detail">
<div className="awp-detail-header">
<div className="awp-detail-identity">
<div className={`awp-detail-avatar${TERMINAL_SESSION_STATUSES.has(selected.status) ? ` ${terminalClass(selected.status)}` : ''}`}>
{(selectedAgent?.name ?? selected.assigneeIds[0] ?? '?').charAt(0).toUpperCase()}
</div>
<div className="awp-detail-meta">
<span className="awp-detail-name">
{selectedAgent?.name ?? selected.assigneeIds[0] ?? selected.title}
</span>
{getWorkItemRoleLabel(selected) && (
<span className="awp-detail-role">
{getWorkItemRoleLabel(selected)}
</span>
)}
{selected.employeeAssignment?.name && (
<span className="awp-detail-employee">{selected.employeeAssignment.name}</span>
)}
{selected.selectedExecutionAgent && (
<span className="awp-detail-employee">
Agent: {EXECUTION_AGENT_LABELS[selected.selectedExecutionAgent] ?? selected.selectedExecutionAgent}
</span>
)}
{selected.workItemProjectionId && (
<span className="awp-detail-projection">
{selected.workItemProjectionId.replace(/_/g, ' ')}
</span>
)}
</div>
</div>
<div className="awp-detail-actions">
{onOpenChildDetail && (
<button
className="awp-detail-expand"
onClick={() => onOpenChildDetail(selected.taskId)}
title="Open full context"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M2.5 3.25h9a1 1 0 011 1v5.5a1 1 0 01-1 1h-4l-2.75 2v-2h-2.25a1 1 0 01-1-1v-5.5a1 1 0 011-1Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
<path d="M4.5 5.5h5M4.5 7.5h3.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
</button>
)}
{onOpenExecutionPanel && (
<button
className="awp-detail-expand"
onClick={() => onOpenExecutionPanel(selected.taskId)}
title="Open execution panel"
>
<IconTimeline />
</button>
)}
<button
className="awp-detail-close"
onClick={() => setSelectedTaskId(null)}
title="Close detail"
>
<IconClose />
</button>
</div>
</div>
<div className="awp-detail-body">
{selected.handoffContext && (
<div className="awp-detail-section">
<div className="awp-detail-section-label">
<IconHandoff />
<span>Handoff</span>
</div>
<div className="msg-content-agent-card">
<MarkdownBody content={selected.handoffContext} className="awp-detail-handoff awp-detail-handoff-markdown" />
</div>
</div>
)}
<div className="awp-detail-section">
<div className="awp-detail-section-label">
<IconTimeline />
<span>Activity</span>
</div>
<AgentProgressBlock
entries={selected.progressLog}
agentStatus={selected.agentStatus}
currentTool={selected.currentTool}
toolElapsedMs={selected.toolElapsedMs}
lastToolSummary={selected.lastToolSummary}
sessionStatus={selected.status}
expandedByDefault
/>
{selected.progressLog.length === 0 && !selected.agentStatus && !TERMINAL_SESSION_STATUSES.has(selected.status) && (
<div className="awp-detail-empty">No activity recorded yet</div>
)}
</div>
{TERMINAL_SESSION_STATUSES.has(selected.status) && RESULT_BANNER[selected.status] && (
<div className={`awp-result-banner ${RESULT_BANNER[selected.status].cls}`}>
<span className="awp-result-icon">{terminalIcon(selected.status)}</span>
<span>{RESULT_BANNER[selected.status].text}</span>
</div>
)}
</div>
</div>
)}
</div>
)
}
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 (
<AgentWorkPanelRoleView
roleWorkItems={roleWorkItems}
onOpenChildDetail={onOpenChildDetail}
onOpenExecutionPanel={onOpenExecutionPanel}
/>
)
}
if (isCompanyRuntime) return null
return (
<AgentWorkPanelLegacyView
sessions={sessions}
agents={agents}
onOpenChildDetail={onOpenChildDetail}
onOpenExecutionPanel={onOpenExecutionPanel}
/>
)
}
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict'
import type { ChatMessage } from '../types/chat'
import { mapBackendMessage } from '../lib/collabSync'
import { analyzeCheckpointMessages } from './checkpointUtils'
import { __chatStoreTestUtils } from './ChatStore'
const syntheticCheckpoint: ChatMessage = {
id: 'checkpoint::cp-delivery',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'Company Member',
content: 'Human review requested.',
timestamp: 1,
mentions: [],
metadata: {
checkpoint_type: 'company_delivery_feedback',
checkpoint_id: 'cp-delivery',
summary: 'Pending review',
},
}
const backendCheckpointUpdate: ChatMessage = {
id: 'db-message-1',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'Company Member',
content: 'Human review requested.',
timestamp: 2,
mentions: [],
metadata: {
checkpoint_type: 'company_delivery_feedback',
checkpoint_id: 'cp-delivery',
checkpoint_status: 'ignored',
checkpoint_reply_kind: 'ignore',
},
}
const mergedCheckpoint = __chatStoreTestUtils.dedupeMessages([
syntheticCheckpoint,
backendCheckpointUpdate,
])
assert.equal(mergedCheckpoint.length, 1)
assert.equal(mergedCheckpoint[0].id, 'db-message-1')
assert.equal(mergedCheckpoint[0].metadata?.checkpoint_status, 'ignored')
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).pendingMessageIds], [])
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).respondedMessageIds], ['db-message-1'])
const terminalSyntheticCheckpoint: ChatMessage = {
...syntheticCheckpoint,
timestamp: 2,
metadata: {
...syntheticCheckpoint.metadata,
checkpoint_status: 'ignored',
checkpoint_reply_kind: 'ignore',
},
}
const mergedSameIdCheckpoint = __chatStoreTestUtils.dedupeMessages([
syntheticCheckpoint,
terminalSyntheticCheckpoint,
])
assert.equal(mergedSameIdCheckpoint.length, 1)
assert.equal(mergedSameIdCheckpoint[0].id, 'checkpoint::cp-delivery')
assert.equal(mergedSameIdCheckpoint[0].metadata?.checkpoint_status, 'ignored')
assert.deepEqual([...analyzeCheckpointMessages(mergedSameIdCheckpoint).pendingMessageIds], [])
const optimisticUserMessage: ChatMessage = {
id: 'msg-local',
channelId: 'session:task-1',
sender: 'user',
senderName: 'You',
content: 'New requirement',
timestamp: 3,
mentions: [],
metadata: {
ui_message_id: 'ui-1',
},
}
const backendUserMessage: ChatMessage = {
id: 'db-user-1',
channelId: 'session:task-1',
sender: 'user',
senderName: 'You',
content: 'New requirement',
timestamp: 4,
mentions: [],
metadata: {
ui_message_id: 'ui-1',
},
}
const mergedUserMessage = __chatStoreTestUtils.dedupeMessages([
optimisticUserMessage,
backendUserMessage,
])
assert.equal(mergedUserMessage.length, 1)
assert.equal(mergedUserMessage[0].metadata?.ui_message_id, 'ui-1')
const nativeCompanyRawTurn: ChatMessage = {
id: 'native-raw-1',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'Task Generalist',
content: '最终分析已经完成,结论如下。',
timestamp: 5,
mentions: [],
metadata: {
source: 'engine',
transcript_kind: 'runtime_v2_assistant',
},
}
const companyRoleResult: ChatMessage = {
id: 'role-result-1',
channelId: 'session:task-1',
sender: 'chao',
senderName: 'Chao',
content: '最终分析已经完成,结论如下。',
timestamp: 6,
mentions: [],
metadata: {
source: 'engine',
transcript_kind: 'company_role_result',
},
}
const mergedNativeCompanyDuplicate = __chatStoreTestUtils.dedupeMessages([
nativeCompanyRawTurn,
companyRoleResult,
])
assert.equal(mergedNativeCompanyDuplicate.length, 1)
assert.equal(mergedNativeCompanyDuplicate[0].id, 'role-result-1')
assert.equal(mergedNativeCompanyDuplicate[0].senderName, 'Chao')
const mappedTaskGeneralistMessage = mapBackendMessage({
message_id: 'legacy-task-generalist',
channel_id: 'session:task-1',
sender: 'task_generalist',
sender_name: 'Task Generalist',
content: 'Legacy native task result.',
created_at: 10,
metadata: {
transcript_kind: 'runtime_v2_company_assistant',
},
})
assert.equal(mappedTaskGeneralistMessage.senderName, 'OPC')
console.log('ChatStore.test.ts: OK (optimistic, checkpoint, and company result identity merging)')
@@ -0,0 +1,495 @@
import { useCallback, useMemo, useReducer, useState } from 'react'
import type { ChatChannel, ChatMessage } from '../types/chat'
function uid(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
}
const DUPLICATE_WINDOW_MS = 2000
const RESULT_SURFACE_PRIORITY: Record<string, number> = {
child_task_result: 80,
child_task_result_retry: 79,
company_role_result: 75,
company_role_result_retry: 74,
child_result: 70,
runtime_v2_assistant: 60,
runtime_v2_company_assistant: 20,
top_level_reply: 40,
worker_notification: 10,
}
function messageMetadata(message: ChatMessage): Record<string, unknown> {
return (message.metadata ?? {}) as Record<string, unknown>
}
function normalizeMessageContent(content: string): string {
const normalized = String(content ?? '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.map(line => line.trimEnd())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
const titleStripped = stripNarrativeTitlePrefix(normalized)
const paragraphs = titleStripped.split(/\n{2,}/).map(part => part.trim()).filter(Boolean)
if (paragraphs.length > 1 && /^Verification:\s/i.test(paragraphs[paragraphs.length - 1])) {
return paragraphs.slice(0, -1).join('\n\n').trim()
}
return titleStripped
}
function stripNarrativeTitlePrefix(content: string): string {
const trimmed = String(content || '').trim()
const markdownTitle = trimmed.match(/^\*\*(.{8,160}?)\*\*:\s+([\s\S]+)$/)
if (markdownTitle) {
const body = markdownTitle[2].trim()
if (body.length >= 80) return body
}
const colonIndex = trimmed.indexOf(': ')
if (colonIndex < 8 || colonIndex > 160) return trimmed
const prefix = trimmed.slice(0, colonIndex).replace(/\*/g, '').trim()
const body = trimmed.slice(colonIndex + 2).trim()
if (body.length < 80) return trimmed
if (!/[A-Za-z\u4e00-\u9fff]/.test(prefix)) return trimmed
if (/^(https?|file)$/i.test(prefix)) return trimmed
return body
}
function messageIdentityKeys(message: ChatMessage): Set<string> {
const metadata = messageMetadata(message)
const keys = new Set<string>()
const checkpointType = typeof metadata.checkpoint_type === 'string' ? metadata.checkpoint_type.trim() : ''
const checkpointId = typeof metadata.checkpoint_id === 'string' ? metadata.checkpoint_id.trim() : ''
for (const value of [
message.id,
typeof metadata.ui_message_id === 'string' ? metadata.ui_message_id : '',
checkpointType && checkpointId ? `checkpoint:${checkpointType}:${checkpointId}` : '',
]) {
const normalized = String(value ?? '').trim()
if (normalized) keys.add(normalized)
}
return keys
}
function isDerivedIdentityKey(value: string): boolean {
return value.startsWith('checkpoint:')
}
function messageTimestamp(message: ChatMessage): number {
return typeof message.timestamp === 'number' ? message.timestamp : 0
}
function messageRoleBucket(message: ChatMessage): 'user' | 'assistant' {
const sender = String(message.sender ?? '').trim().toLowerCase()
const metadata = messageMetadata(message)
const role = typeof metadata.role === 'string' ? metadata.role.trim().toLowerCase() : ''
if (sender === 'user' || role === 'user') return 'user'
return 'assistant'
}
function messagePreferenceScore(message: ChatMessage): number {
const metadata = messageMetadata(message)
const sender = String(message.sender ?? '').trim().toLowerCase()
let score = 0
const resultPriority = resultSurfacePriority(message)
if (resultPriority) score += 1000 + resultPriority
if (metadata.source === 'engine') score += 100
if (sender && sender !== 'system') score += 20
if (sender && !['assistant', 'system', 'user'].includes(sender)) score += 5
if (message.replyToId) score += 2
score += Math.min(Object.keys(metadata).length, 10)
return score
}
function messageHasEngineSource(message: ChatMessage): boolean {
return String(messageMetadata(message).source ?? '').trim().toLowerCase() === 'engine'
}
function resultSurfacePriority(message: ChatMessage): number {
const metadata = messageMetadata(message)
const transcriptKind = String(metadata.transcript_kind ?? '').trim()
if (transcriptKind) return RESULT_SURFACE_PRIORITY[transcriptKind] ?? 0
const kind = String(metadata.kind ?? '').trim()
return RESULT_SURFACE_PRIORITY[kind] ?? 0
}
function isResultSurface(message: ChatMessage): boolean {
return resultSurfacePriority(message) > 0
}
function messagesShareIdentity(existing: ChatMessage, candidate: ChatMessage): boolean {
if (existing.channelId !== candidate.channelId) return false
const existingIds = messageIdentityKeys(existing)
const candidateIds = messageIdentityKeys(candidate)
for (const id of existingIds) {
if (candidateIds.has(id)) return true
}
return false
}
function messagesSemanticallyMatch(existing: ChatMessage, candidate: ChatMessage): boolean {
if (messagesShareIdentity(existing, candidate)) return true
if (existing.channelId !== candidate.channelId) return false
if (messageRoleBucket(existing) !== messageRoleBucket(candidate)) return false
if (normalizeMessageContent(existing.content) !== normalizeMessageContent(candidate.content)) return false
const bothResultSurfaces = isResultSurface(existing) && isResultSurface(candidate)
if (!bothResultSurfaces && String(existing.replyToId ?? '') !== String(candidate.replyToId ?? '')) return false
if (!(messageHasEngineSource(existing) || messageHasEngineSource(candidate))) return false
const existingTs = messageTimestamp(existing)
const candidateTs = messageTimestamp(candidate)
if (!bothResultSurfaces && existingTs && candidateTs && Math.abs(existingTs - candidateTs) > DUPLICATE_WINDOW_MS) {
return false
}
return true
}
function mergeDuplicateMessages(
existing: ChatMessage,
candidate: ChatMessage,
preferCandidate = false,
): ChatMessage {
let preferred = existing
let secondary = candidate
if (preferCandidate) {
preferred = candidate
secondary = existing
} else if (messagePreferenceScore(candidate) > messagePreferenceScore(existing)) {
preferred = candidate
secondary = existing
}
const mentions: string[] = []
for (const values of [secondary.mentions, preferred.mentions]) {
for (const value of values ?? []) {
if (!mentions.includes(value)) mentions.push(value)
}
}
const existingIds = messageIdentityKeys(existing)
const candidateIds = messageIdentityKeys(candidate)
let canonicalId = ''
for (const id of existingIds) {
if (candidateIds.has(id) && !isDerivedIdentityKey(id)) {
canonicalId = id
break
}
}
const normalizedContent = normalizeMessageContent(preferred.content)
const content = normalizedContent && normalizedContent === normalizeMessageContent(secondary.content)
? normalizedContent
: preferred.content
return {
...secondary,
...preferred,
...(canonicalId ? { id: canonicalId } : {}),
content,
metadata: { ...messageMetadata(secondary), ...messageMetadata(preferred) },
mentions,
timestamp: messageTimestamp(preferred) || messageTimestamp(secondary),
}
}
function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
const deduped: ChatMessage[] = []
// Map from identity key → index in deduped for O(1) identity lookups
const identityKeyToIdx = new Map<string, number>()
for (const message of [...messages].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
const candidateIds = messageIdentityKeys(message)
let matchIndex = -1
let preferCandidate = false
// O(1) identity lookup via Map instead of O(n) backward scan
for (const id of candidateIds) {
const idx = identityKeyToIdx.get(id)
if (idx !== undefined) {
matchIndex = idx
preferCandidate = deduped[idx].id === message.id
break
}
}
// Semantic match: scan backwards with early-exit when only short-window matches remain.
if (matchIndex === -1) {
const candidateTs = messageTimestamp(message)
const candidateIsResultSurface = isResultSurface(message)
for (let i = deduped.length - 1; i >= 0; i--) {
const existingTs = messageTimestamp(deduped[i])
if (
!candidateIsResultSurface
&& candidateTs > 0
&& existingTs > 0
&& candidateTs - existingTs > DUPLICATE_WINDOW_MS
) break
if (messagesSemanticallyMatch(deduped[i], message)) {
matchIndex = i
break
}
}
}
const insertIdx = matchIndex === -1 ? deduped.length : matchIndex
if (matchIndex === -1) {
deduped.push(message)
} else {
deduped[matchIndex] = mergeDuplicateMessages(deduped[matchIndex], message, preferCandidate)
}
// Register all identity keys for the merged/inserted message for fast future lookups
for (const id of messageIdentityKeys(deduped[insertIdx])) {
if (!identityKeyToIdx.has(id)) identityKeyToIdx.set(id, insertIdx)
}
}
return deduped
}
export const __chatStoreTestUtils = {
dedupeMessages,
}
type ChannelAction =
| { type: 'SET'; channels: ChatChannel[] }
| { type: 'ADD'; channel: ChatChannel }
| { type: 'REMOVE'; channelId: string }
| { type: 'REMOVE_PARTICIPANT'; agentId: string }
| { type: 'CLEAR' }
function channelReducer(state: ChatChannel[], action: ChannelAction): ChatChannel[] {
switch (action.type) {
case 'SET': return action.channels
case 'CLEAR': return []
case 'ADD': return state.some(ch => ch.id === action.channel.id) ? state : [...state, action.channel]
case 'REMOVE': return state.filter(ch => ch.id !== action.channelId)
case 'REMOVE_PARTICIPANT': return state.map(ch => ({
...ch,
participants: ch.participants.filter(p => p !== action.agentId),
}))
default: return state
}
}
type MessageAction =
| { type: 'SET'; messages: ChatMessage[] }
| { type: 'ADD'; message: ChatMessage }
| { type: 'MERGE'; messages: ChatMessage[] }
| { type: 'MARK_SENDER_DELETED'; senderId: string }
| { type: 'REMOVE_BY_CHANNEL'; channelId: string }
| { type: 'REMOVE_BY_TASK_ID'; taskId: string }
| { type: 'CLEAR' }
function messageReducer(state: ChatMessage[], action: MessageAction): ChatMessage[] {
switch (action.type) {
case 'SET': {
// Backend snapshots (collab_sync / collab_sync_push) arrive frequently
// while agents are running. A naive replace drops any client-side
// optimistic message (id prefixed `msg-` from sendMessage) that the
// backend has not round-tripped yet, which causes the composer's sent
// text to flicker in and out and user input lines to disappear for
// a beat. Preserve those local-only messages here until the backend
// snapshot catches up.
const incoming = dedupeMessages(action.messages)
if (state.length === 0) return incoming
const localOnly = state.filter(existing =>
typeof existing.id === 'string' &&
existing.id.startsWith('msg-') &&
!incoming.some(inc => messagesSemanticallyMatch(existing, inc))
)
if (localOnly.length === 0) return incoming
return dedupeMessages([...incoming, ...localOnly])
}
case 'CLEAR':
return []
case 'ADD': {
// Fast path: only scan recent messages within the dedup window — avoids O(n²) full dedup
// for the common case of a single new message arriving from the WebSocket.
// Identity matches (same message_id) are checked across the full list so that
// metadata-only updates (e.g. checkpoint_status changes) always merge in-place
// regardless of how old the original message is.
const candidateTs = messageTimestamp(action.message)
const candidateIds = messageIdentityKeys(action.message)
let pastWindow = false
for (let i = state.length - 1; i >= 0; i--) {
const existingTs = messageTimestamp(state[i])
if (!pastWindow && candidateTs > 0 && existingTs > 0 && candidateTs - existingTs > DUPLICATE_WINDOW_MS) {
pastWindow = true
}
if (messagesShareIdentity(state[i], action.message)) {
const updated = [...state]
updated[i] = mergeDuplicateMessages(state[i], action.message, state[i].id === action.message.id)
return updated
}
if ((isResultSurface(action.message) || !pastWindow) && messagesSemanticallyMatch(state[i], action.message)) {
const updated = [...state]
updated[i] = mergeDuplicateMessages(state[i], action.message)
return updated
}
}
return [...state, action.message]
}
case 'MERGE': {
if (action.messages.length === 0) return state
return dedupeMessages([...state, ...action.messages])
}
case 'MARK_SENDER_DELETED': return state.map(m =>
m.sender === action.senderId ? { ...m, senderDeleted: true, senderName: '[已删除的 Agent]' } : m
)
case 'REMOVE_BY_CHANNEL': return state.filter(m => m.channelId !== action.channelId)
case 'REMOVE_BY_TASK_ID': return state.filter(m =>
m.channelId !== `session:${action.taskId}` &&
!((m.metadata as Record<string, unknown>)?.task_id === action.taskId)
)
default: return state
}
}
export interface ChatStoreState {
scopeProjectId: string
channels: ChatChannel[]
messages: ChatMessage[]
sendMessage: (opts: { channelId: string; sender: string; senderName: string; content: string; replyToId?: string; metadata?: ChatMessage['metadata'] }) => ChatMessage
getChannelMessages: (channelId: string) => ChatMessage[]
getUnreadCount: (channelId: string) => number
markRead: (channelId: string) => void
markSenderDeleted: (agentId: string) => void
removeParticipant: (agentId: string) => void
removeSessionData: (taskId: string) => void
clear: () => void
initFromBackend: (projectId: string, channels: ChatChannel[], messages: ChatMessage[]) => void
addMessageFromBackend: (msg: ChatMessage) => void
mergeMessagesFromBackend: (messages: ChatMessage[]) => void
addChannelFromBackend: (ch: ChatChannel) => void
}
export function useChatStore(): ChatStoreState {
const [channels, dispatchCh] = useReducer(channelReducer, [])
const [messages, dispatchMsg] = useReducer(messageReducer, [])
const [readTimestamps, setReadTimestamps] = useState<Record<string, number>>({})
const [scopeProjectId, setScopeProjectId] = useState<string>('default')
const messagesByChannel = useMemo<Record<string, ChatMessage[]>>(() => {
const buckets: Record<string, ChatMessage[]> = {}
for (const message of messages) {
if (!buckets[message.channelId]) buckets[message.channelId] = []
buckets[message.channelId].push(message)
}
return buckets
}, [messages])
const unreadCounts = useMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {}
for (const message of messages) {
if (message.sender === 'user') continue
const lastRead = readTimestamps[message.channelId] ?? 0
if (message.timestamp <= lastRead) continue
counts[message.channelId] = (counts[message.channelId] ?? 0) + 1
}
return counts
}, [messages, readTimestamps])
const sendMessage = useCallback((opts: {
channelId: string; sender: string; senderName: string; content: string;
replyToId?: string; metadata?: ChatMessage['metadata']
}) => {
const msg: ChatMessage = {
id: `msg-${uid()}`,
channelId: opts.channelId,
sender: opts.sender,
senderName: opts.senderName,
content: opts.content,
timestamp: Date.now(),
replyToId: opts.replyToId,
mentions: [],
metadata: opts.metadata,
}
dispatchMsg({ type: 'ADD', message: msg })
return msg
}, [])
const getChannelMessages = useCallback((channelId: string) => {
return messagesByChannel[channelId] ?? []
}, [messagesByChannel])
const getUnreadCount = useCallback((channelId: string) => {
return unreadCounts[channelId] ?? 0
}, [unreadCounts])
const markRead = useCallback((channelId: string) => {
setReadTimestamps(prev => ({ ...prev, [channelId]: Date.now() }))
}, [])
const markSenderDeleted = useCallback((agentId: string) => {
dispatchMsg({ type: 'MARK_SENDER_DELETED', senderId: agentId })
}, [])
const removeParticipant = useCallback((agentId: string) => {
dispatchCh({ type: 'REMOVE_PARTICIPANT', agentId })
}, [])
const removeSessionData = useCallback((taskId: string) => {
dispatchCh({ type: 'REMOVE', channelId: `session:${taskId}` })
dispatchMsg({ type: 'REMOVE_BY_TASK_ID', taskId })
}, [])
const clear = useCallback(() => {
dispatchCh({ type: 'CLEAR' })
dispatchMsg({ type: 'CLEAR' })
setReadTimestamps({})
}, [])
const initFromBackend = useCallback((projectId: string, chs: ChatChannel[], msgs: ChatMessage[]) => {
const nextProjectId = projectId || 'default'
const projectChanged = nextProjectId !== scopeProjectId
setScopeProjectId(nextProjectId)
dispatchCh({ type: 'SET', channels: chs })
// Backend `collab_sync` / `collab_sync_push` payloads carry the
// "current window" of messages, not the full history. Dispatching
// SET here would drop any older messages that were loaded earlier
// via `session_detail` (limit: 200) — the very first user-typed
// project-goal message sits at the top of the channel and is the
// first to fall out of this window. Every subsequent push would
// then wipe it, and the next `session_detail` refresh would merge
// it back, producing a ~1s flicker cycle on the top of the list.
// MERGE instead so the snapshot is additive, not destructive.
if (projectChanged) {
dispatchMsg({ type: 'SET', messages: msgs })
} else {
dispatchMsg({ type: 'MERGE', messages: msgs })
}
// Mark all loaded messages as read so they don't show as unread (#17)
const latest: Record<string, number> = {}
for (const m of msgs) {
if (!latest[m.channelId] || m.timestamp > latest[m.channelId]) {
latest[m.channelId] = m.timestamp
}
}
setReadTimestamps(prev => projectChanged ? latest : ({ ...prev, ...latest }))
}, [scopeProjectId])
const addMessageFromBackend = useCallback((msg: ChatMessage) => {
dispatchMsg({ type: 'ADD', message: msg })
}, [])
const mergeMessagesFromBackend = useCallback((msgs: ChatMessage[]) => {
dispatchMsg({ type: 'MERGE', messages: msgs })
}, [])
const addChannelFromBackend = useCallback((ch: ChatChannel) => {
dispatchCh({ type: 'ADD', channel: ch })
}, [])
return useMemo(() => ({
scopeProjectId, channels, messages,
sendMessage, getChannelMessages, getUnreadCount, markRead,
markSenderDeleted, removeParticipant, removeSessionData, clear, initFromBackend,
addMessageFromBackend, mergeMessagesFromBackend, addChannelFromBackend,
}), [scopeProjectId, channels, messages, sendMessage, getChannelMessages, getUnreadCount, markRead,
markSenderDeleted, removeParticipant, removeSessionData, clear, initFromBackend,
addMessageFromBackend, mergeMessagesFromBackend, addChannelFromBackend])
}
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { DeliveryFeedbackPanel } from './DeliveryFeedbackPanel'
const markup = renderToStaticMarkup(
React.createElement(DeliveryFeedbackPanel, {
meta: {
checkpoint_type: 'company_delivery_feedback',
checkpoint_id: 'cp-delivery',
work_item_projection_title: 'CEO Delivery',
feedback_scope: 'final',
prompt: 'This final delivery is ready for review.\n\n- Inspect the build\n- Confirm acceptance\n\n```txt\nready\n```',
options: [
{ id: 'approve', label: 'Fully Agree / 完全同意' },
{ id: 'ignore', label: 'Ignore / 忽略' },
{ id: 'feedback', label: 'Feedback / 反馈' },
],
permission_requests: [{ id: 'perm-1' }],
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(markup, /CEO Delivery \(for self-evolution\)/)
assert.match(markup, /Fully Agree/)
assert.match(markup, /Ignore/)
assert.match(markup, /Feedback for self-evolution/)
assert.match(markup, /<li>Inspect the build<\/li>/)
assert.match(markup, /<code class="language-txt">/)
assert.match(markup, /<summary>Runtime State<\/summary>/)
assert.equal((markup.match(/class="ckpt-btn /g) ?? []).length, 3)
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'DeliveryFeedbackPanel.tsx'), 'utf8')
assert.match(src, /metadata\.self_evolution_trigger = true/, 'delivery card replies must explicitly trigger self-evolution')
assert.match(src, /kind === 'approve' \|\| kind === 'feedback'/, 'ignore must not trigger self-evolution metadata')
assert.match(src, /buildReplyMetadata\('ignore'\)/, 'delivery card must send an explicit ignore checkpoint reply')
assert.match(src, /submittingAction/, 'delivery card actions must be locally locked while awaiting server metadata')
assert.match(src, /disabled=\{actionsDisabled\}/, 'delivery card must disable controls immediately after a card action')
assert.doesNotMatch(src, /ckpt-btn-deny/, 'delivery self-evolution card must not render a deny action')
assert.doesNotMatch(src, /localResponded|setLocalResponded/, 'panel must wait for server checkpoint metadata before showing responded state')
console.log('DeliveryFeedbackPanel.test.tsx: OK (markdown delivery review panel)')
@@ -0,0 +1,165 @@
import React, { useCallback, useMemo, useState } from 'react'
import type { ChatMessageMeta, CheckpointReplyMetadata } from '../types/chat'
import { MarkdownBody } from './MarkdownBody'
interface DeliveryFeedbackPanelProps {
meta: ChatMessageMeta
onReply: (text: string, metadata?: CheckpointReplyMetadata) => void
responded: boolean
}
function firstLine(text: string): string {
return text.split('\n').map((line) => line.trim()).find(Boolean) ?? text
}
function checkpointStatusLabel(status: string): string {
switch (status) {
case 'ignored':
return 'Ignored'
case 'timeout':
case 'timed_out':
case 'expired':
return 'Expired'
case 'stale':
case 'invalid':
return 'Inactive'
case 'superseded':
return 'Superseded'
case 'cancelled':
case 'canceled':
return 'Cancelled'
case 'resolved':
return 'Resolved'
default:
return 'Responded'
}
}
export const DeliveryFeedbackPanel = React.memo(function DeliveryFeedbackPanel({
meta, onReply, responded,
}: DeliveryFeedbackPanelProps) {
const isResponded = responded
const [feedback, setFeedback] = useState('')
const [submittingAction, setSubmittingAction] = useState<CheckpointReplyMetadata['checkpoint_reply_kind'] | null>(null)
const checkpointStatus = String(meta.checkpoint_status ?? '').trim().toLowerCase()
const resolvedLabel = checkpointStatusLabel(checkpointStatus)
const prompt = String(meta.prompt ?? meta.summary ?? '').trim()
const baseTitle = String(meta.work_item_projection_title ?? firstLine(prompt) ?? 'Human Review').trim() || 'Human Review'
const title = `${baseTitle} (for self-evolution)`
const summary = String(meta.summary ?? '').trim()
const activeSubagents = useMemo(
() => (meta.active_subagents ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.active_subagents],
)
const permissionRequests = useMemo(
() => (meta.permission_requests ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.permission_requests],
)
const worktreePath = String(meta.worktree_path ?? '').trim()
const hasRuntimeState = activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath
const actionsDisabled = isResponded || submittingAction !== null
const buildReplyMetadata = useCallback((kind: NonNullable<CheckpointReplyMetadata['checkpoint_reply_kind']>, text = ''): CheckpointReplyMetadata => {
const checkpointId = String(meta.checkpoint_id ?? '').trim()
if (!checkpointId) {
throw new Error('Delivery self-evolution reply requires checkpoint_id metadata.')
}
const metadata: CheckpointReplyMetadata = {
response_to_checkpoint_id: checkpointId,
response_to_checkpoint_type: 'company_delivery_feedback',
checkpoint_reply_kind: kind,
}
if (kind === 'approve' || kind === 'feedback') {
metadata.self_evolution_trigger = true
metadata.human_feedback_text = text
}
return metadata
}, [meta.checkpoint_id])
const handleApprove = useCallback(() => {
if (actionsDisabled) return
const metadata = buildReplyMetadata('approve')
setSubmittingAction('approve')
onReply('I fully agree with this delivery.', metadata)
}, [actionsDisabled, buildReplyMetadata, onReply])
const handleFeedback = useCallback(() => {
const text = feedback.trim()
if (actionsDisabled || !text) return
const metadata = buildReplyMetadata('feedback', text)
setSubmittingAction('feedback')
onReply(text, metadata)
setFeedback('')
}, [actionsDisabled, buildReplyMetadata, feedback, onReply])
const handleIgnore = useCallback(() => {
if (actionsDisabled) return
const metadata = buildReplyMetadata('ignore')
setSubmittingAction('ignore')
onReply('Ignore this self-evolution review.', metadata)
}, [actionsDisabled, buildReplyMetadata, onReply])
return (
<div className="ckpt-panel ckpt-delivery-feedback">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-user-input">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 1.5L14 5v4.5c0 2.25-1.8 4.25-6 5-4.2-.75-6-2.75-6-5V5L8 1.5Z" />
<path d="M5.5 8.25L7.25 10l3.25-4" />
</svg>
</div>
<div className="ckpt-title">{title}</div>
<span className="ckpt-badge ckpt-badge-scope">
{String(meta.feedback_scope ?? 'final').replace(/_/g, ' ')}
</span>
{isResponded && <span className="ckpt-badge ckpt-badge-responded">{resolvedLabel}</span>}
</div>
{summary && summary !== title && (
<div className="ckpt-section">
<div className="ckpt-section-title">Summary</div>
<MarkdownBody content={summary} className="ckpt-markdown" />
</div>
)}
{prompt && (
<div className="ckpt-section">
<div className="ckpt-section-title">Review Request</div>
<MarkdownBody content={prompt} className="ckpt-markdown" />
</div>
)}
{hasRuntimeState && (
<details className="ckpt-runtime-details">
<summary>Runtime State</summary>
<div className="ckpt-runtime-body">
{worktreePath && <div>Worktree: <code>{worktreePath}</code></div>}
{activeSubagents.length > 0 && <div>Active subagents: {activeSubagents.length}</div>}
{permissionRequests.length > 0 && <div>Pending permission records: {permissionRequests.length}</div>}
</div>
</details>
)}
{!isResponded && (
<div className="ckpt-actions ckpt-actions-inline-feedback">
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove} disabled={actionsDisabled}>
Fully Agree
</button>
<button className="ckpt-btn ckpt-btn-cancel" onClick={handleIgnore} disabled={actionsDisabled}>
Ignore
</button>
<textarea
className="ckpt-feedback-input ckpt-feedback-inline-input"
placeholder="Feedback for self-evolution..."
value={feedback}
onChange={event => setFeedback(event.target.value)}
disabled={actionsDisabled}
rows={2}
/>
<button className="ckpt-btn ckpt-btn-feedback" onClick={handleFeedback} disabled={actionsDisabled || !feedback.trim()}>
Send Feedback
</button>
</div>
)}
</div>
)
})
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { EscalationPanel } from './EscalationPanel'
const markup = renderToStaticMarkup(
React.createElement(EscalationPanel, {
meta: {
checkpoint_type: 'company_work_item_gate',
checkpoint_id: 'cp-gate',
prompt: 'Gate Review\n\n- Confirm the artifact exists\n- Confirm tests passed\n\n```json\n{"ok": true}\n```',
summary: 'Review the gate evidence.',
options: [
{ id: 'approve', label: 'Approve' },
{ id: 'deny', label: 'Deny' },
],
active_subagents: [{ id: 'sub-1' }],
worktree_path: '/tmp/work',
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(markup, /Gate Review/)
assert.match(markup, /<li>Confirm the artifact exists<\/li>/)
assert.match(markup, /<code class="language-json">/)
assert.match(markup, /<summary>Runtime State<\/summary>/)
assert.equal((markup.match(/<button/g) ?? []).length, 3)
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'EscalationPanel.tsx'), 'utf8')
assert.doesNotMatch(src, /localResponded|setLocalResponded/, 'panel must wait for server checkpoint metadata before showing responded state')
console.log('EscalationPanel.test.tsx: OK (markdown gate panel)')
@@ -0,0 +1,128 @@
import React, { useCallback, useMemo } from 'react'
import type { ChatMessageMeta, HumanEscalationOption } from '../types/chat'
import { MarkdownBody } from './MarkdownBody'
interface EscalationPanelProps {
meta: ChatMessageMeta
onReply: (text: string) => void
responded: boolean
}
function firstLine(text: string): string {
return text.split('\n').map((line) => line.trim()).find(Boolean) ?? text
}
function checkpointStatusLabel(status: string): string {
switch (status) {
case 'timeout':
case 'timed_out':
case 'expired':
return 'Expired'
case 'stale':
case 'invalid':
return 'Inactive'
case 'cancelled':
case 'canceled':
return 'Cancelled'
case 'resolved':
return 'Resolved'
default:
return 'Responded'
}
}
export const EscalationPanel = React.memo(function EscalationPanel({
meta, onReply, responded,
}: EscalationPanelProps) {
const isResponded = responded
const checkpointStatus = String(meta.checkpoint_status ?? '').trim().toLowerCase()
const resolvedLabel = checkpointStatusLabel(checkpointStatus)
const prompt = String(meta.prompt ?? meta.summary ?? '')
const lines = useMemo(
() => prompt.split('\n').map((line) => line.trim()).filter(Boolean),
[prompt],
)
const title = firstLine(prompt).replace(/^\[[^\]]+\]\s*/, '') || 'Action Required'
const details = lines.slice(1).join('\n').trim()
const summary = String(meta.summary ?? '').trim()
const options = (meta.options ?? []).filter((opt): opt is HumanEscalationOption => !!opt?.id)
const activeSubagents = useMemo(
() => (meta.active_subagents ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.active_subagents],
)
const permissionRequests = useMemo(
() => (meta.permission_requests ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.permission_requests],
)
const worktreePath = String(meta.worktree_path ?? '').trim()
const hasRuntimeState = activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath
const handleReply = useCallback((option: HumanEscalationOption) => {
if (isResponded) return
onReply(option.label || option.id)
}, [isResponded, onReply])
return (
<div className="ckpt-panel ckpt-escalation">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-escalation">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 1.5L14.5 13H1.5L8 1.5Z" />
<path d="M8 5.5V9" />
<circle cx="8" cy="11.5" r="0.75" fill="currentColor" stroke="none" />
</svg>
</div>
<div className="ckpt-title">{title}</div>
<span className="ckpt-badge ckpt-badge-scope">
{String(meta.escalation_type ?? 'decision_needed').replace(/_/g, ' ')}
</span>
{isResponded && <span className="ckpt-badge ckpt-badge-responded">{resolvedLabel}</span>}
</div>
{summary && summary !== title && (
<div className="ckpt-section">
<div className="ckpt-section-title">Summary</div>
<MarkdownBody content={summary} className="ckpt-markdown" />
</div>
)}
{details && (
<div className="ckpt-section">
<div className="ckpt-section-title">Request</div>
<MarkdownBody content={details} className="ckpt-markdown" />
</div>
)}
{hasRuntimeState && (
<details className="ckpt-runtime-details">
<summary>Runtime State</summary>
<div className="ckpt-runtime-body">
{worktreePath && <div>Worktree: <code>{worktreePath}</code></div>}
{activeSubagents.length > 0 && <div>Active subagents: {activeSubagents.length}</div>}
{permissionRequests.length > 0 && <div>Pending permission records: {permissionRequests.length}</div>}
</div>
</details>
)}
{!isResponded && options.length > 0 && (
<div className="ckpt-actions ckpt-escalation-actions">
{options.map((option) => (
<button
key={option.id}
className={`ckpt-btn ${option.id.includes('deny') ? 'ckpt-btn-deny' : 'ckpt-btn-approve'}`}
onClick={() => handleReply(option)}
>
{option.label || option.id}
</button>
))}
</div>
)}
{!isResponded && meta.default_action && (
<div className="ckpt-escalation-hint">
Default on timeout: <code>{meta.default_action}</code>
</div>
)}
</div>
)
})
@@ -0,0 +1,122 @@
import React, { useCallback, useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { IconCheck, IconCopy } from './SvgIcons'
const CODE_BLOCK_MAX_LINES = 30
const CODE_BLOCK_PEEK_LINES = 10
function CodeBlock({ className, children }: { className?: string; children?: React.ReactNode }) {
const [copied, setCopied] = useState(false)
const [expanded, setExpanded] = useState(false)
const text = String(children).replace(/\n$/, '')
const lang = className?.replace('language-', '') || ''
const lines = text.split('\n')
const needsTruncation = lines.length > CODE_BLOCK_MAX_LINES
const omittedCount = needsTruncation ? lines.length - CODE_BLOCK_PEEK_LINES * 2 : 0
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}, [text])
return (
<div className="code-block-wrap">
<div className="code-block-header">
<span className="code-block-lang">{lang || 'code'}{needsTruncation ? ` (${lines.length} lines)` : ''}</span>
<button className="code-block-copy" onClick={handleCopy}>
{copied ? <><IconCheck /> <span>Copied</span></> : <><IconCopy /> <span>Copy</span></>}
</button>
</div>
<pre><code className={className}>
{needsTruncation && !expanded ? (
<>
{lines.slice(0, CODE_BLOCK_PEEK_LINES).join('\n') + '\n'}
<span className="code-block-omitted" onClick={() => setExpanded(true)}>
{'... +'}{omittedCount}{' lines (click to expand)'}
</span>
{'\n' + lines.slice(-CODE_BLOCK_PEEK_LINES).join('\n')}
</>
) : (
text
)}
</code></pre>
{needsTruncation && expanded && (
<button className="code-block-collapse-btn" onClick={() => setExpanded(false)}>
Collapse ({lines.length} lines)
</button>
)}
</div>
)
}
const mdComponents = {
code({ className, children, ...props }: any) {
const isBlock = className?.startsWith('language-')
if (isBlock) {
return <CodeBlock className={className}>{children}</CodeBlock>
}
return <code className={className} {...props}>{children}</code>
},
}
const MSG_COLLAPSE_CHAR_THRESHOLD = 3000
const MSG_COLLAPSE_LINE_THRESHOLD = 60
const MSG_PREVIEW_CHARS = 800
function shouldCollapseContent(content: string): boolean {
if (content.length > MSG_COLLAPSE_CHAR_THRESHOLD) return true
let newlines = 0
for (let i = 0; i < content.length; i++) {
if (content[i] === '\n' && ++newlines >= MSG_COLLAPSE_LINE_THRESHOLD) return true
}
return false
}
function truncatePreview(content: string): string {
const cut = content.lastIndexOf('\n', MSG_PREVIEW_CHARS)
return content.slice(0, cut > MSG_PREVIEW_CHARS / 2 ? cut : MSG_PREVIEW_CHARS)
}
type MarkdownCollapseMode = 'auto' | 'never'
export const MarkdownBody = React.memo(function MarkdownBody({
content,
className = 'msg-content-agent',
collapseMode = 'auto',
}: {
content: string
className?: string
collapseMode?: MarkdownCollapseMode
}) {
const collapsible = collapseMode !== 'never' && shouldCollapseContent(content)
const [collapsed, setCollapsed] = useState(collapsible)
useEffect(() => {
setCollapsed(collapseMode !== 'never' && shouldCollapseContent(content))
}, [collapseMode, content])
const displayContent = collapsed ? truncatePreview(content) : content
const lineCount = content.split('\n').length
const charCount = content.length
return (
<div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{displayContent}
</ReactMarkdown>
{collapsible && collapsed && (
<button className="msg-collapse-toggle" onClick={() => setCollapsed(false)}>
Show more ({lineCount} lines, {(charCount / 1000).toFixed(1)}k chars)
</button>
)}
{collapsible && !collapsed && (
<button className="msg-collapse-toggle" onClick={() => setCollapsed(true)}>
Show less
</button>
)}
</div>
)
})
@@ -0,0 +1,815 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import {
IconArrowRight,
IconBuilding,
IconCheck,
IconClose,
IconLock,
IconPaperclip,
IconSend,
IconSparkles,
IconStop,
IconUserRound,
} from './SvgIcons'
import type { OutgoingAttachmentPayload } from '../types/chat'
import type { TaskPreferredAgent } from '../types/kanban'
import type { SavedOrgSummary } from '../types/visual'
import { getContextUsageMetrics } from '../lib/contextUsage'
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_TOTAL_SIZE = 20 * 1024 * 1024
const ACCEPTED_TYPES = 'image/*,video/mp4,video/mpeg,video/quicktime,video/webm,.mp4,.mpeg,.mpg,.mov,.webm,.txt,.md,.pdf,.csv,.json,.yaml,.yml,.py,.js,.ts,.tsx,.jsx,.html,.css,.java,.c,.cpp,.go,.rs,.rb,.sh,.xml,.toml,.docx,.xlsx,.pptx'
type AttachmentTransferState = 'reading' | 'ready' | 'error'
type ComposerExecMode = 'task' | 'company' | 'org' | 'custom'
type ComposerCompanyProfile = 'corporate' | 'custom'
type ComposerModeOption = 'task' | 'company'
type CompanyArchitectureOption = '' | 'corporate' | `org:${string}`
const TASK_AGENT_LABELS: Record<TaskPreferredAgent, string> = {
native: 'OpenOPC Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
interface PendingAttachment {
id: string
file: File
filename: string
mime_type: string
size_bytes: number
preview_url: string
base64_data?: string
progress_percent: number
transfer_state: AttachmentTransferState
error?: string
}
function readFileAsBase64(file: File, onProgress?: (progress: number) => void): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onprogress = (event) => {
if (!event.lengthComputable) return
onProgress?.(Math.round((event.loaded / event.total) * 100))
}
reader.onload = () => {
const result = reader.result as string
onProgress?.(100)
resolve(result.split(',')[1] || '')
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
function attachmentBadgeLabel(mime: string, filename: string): string {
const extension = filename.includes('.') ? filename.split('.').pop()?.toUpperCase() ?? '' : ''
if (mime.startsWith('image/')) return 'IMG'
if (mime.startsWith('video/')) return 'VID'
if (mime === 'application/pdf') return 'PDF'
if (mime.includes('wordprocessingml')) return 'DOC'
if (mime.includes('spreadsheetml') || extension === 'CSV') return 'XLS'
if (mime.includes('presentationml')) return 'PPT'
if (mime.includes('json')) return 'JSON'
if (mime.includes('yaml') || extension === 'YML' || extension === 'YAML') return 'YAML'
if (mime.startsWith('text/')) return 'TXT'
if (['PY', 'TS', 'TSX', 'JS', 'JSX', 'GO', 'RS', 'RB', 'JAVA', 'C', 'CPP', 'HTML', 'CSS', 'SH'].includes(extension)) return extension
return extension || 'FILE'
}
function attachmentToneClass(mime: string, filename: string): string {
const label = attachmentBadgeLabel(mime, filename)
if (label === 'IMG') return 'image'
if (label === 'VID') return 'video'
if (label === 'PDF') return 'pdf'
if (label === 'DOC' || label === 'XLS' || label === 'PPT') return 'office'
if (label === 'JSON' || label === 'YAML') return 'data'
if (label === 'TXT') return 'text'
if (['PY', 'TS', 'TSX', 'JS', 'JSX', 'GO', 'RS', 'RB', 'JAVA', 'C', 'CPP', 'HTML', 'CSS', 'SH'].includes(label)) return 'code'
return 'generic'
}
function AttachmentProgressRing({
progress,
state,
error,
}: {
progress: number
state: AttachmentTransferState
error?: string
}) {
const radius = 11
const circumference = 2 * Math.PI * radius
const normalized = error ? 100 : state === 'ready' ? 100 : Math.max(0, Math.min(progress, 100))
const dashOffset = circumference * (1 - normalized / 100)
return (
<span
className={`attachment-progress-ring${error ? ' error' : state === 'ready' ? ' ready' : ''}`}
aria-label={error ? 'Attachment preparation failed' : state === 'ready' ? 'Attachment ready to send' : `Preparing attachment ${normalized}%`}
role="img"
>
<svg viewBox="0 0 28 28" aria-hidden="true">
<circle className="attachment-progress-track" cx="14" cy="14" r={radius} />
<circle
className="attachment-progress-value"
cx="14"
cy="14"
r={radius}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
<span className="attachment-progress-center">
{error ? '!' : state === 'ready' ? <IconCheck /> : normalized}
</span>
</span>
)
}
function ContextRing({
usedPct,
usedTokens,
windowTokens,
}: {
usedPct: number
usedTokens?: number
windowTokens?: number
}) {
const radius = 11
const circumference = 2 * Math.PI * radius
const clamped = Math.max(0, Math.min(usedPct, 100))
const dashOffset = circumference * (1 - clamped / 100)
const isLow = clamped >= 80
const isCritical = clamped >= 90
const usageLabel = typeof usedTokens === 'number' && typeof windowTokens === 'number'
? `${clamped}% used (${usedTokens.toLocaleString()}/${windowTokens.toLocaleString()})`
: `${clamped}% used`
return (
<span
className={`composer-context-ring${isLow ? ' low' : ''}${isCritical ? ' critical' : ''}`}
title={`Context window: ${usageLabel}`}
aria-label={`Context window ${usageLabel}`}
role="img"
>
<svg viewBox="0 0 28 28" aria-hidden="true">
<circle className="composer-context-ring-track" cx="14" cy="14" r={radius} />
<circle
className="composer-context-ring-value"
cx="14"
cy="14"
r={radius}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
<span className="composer-context-ring-label">{clamped}</span>
</span>
)
}
interface MessageComposerProps {
disabled?: boolean
placeholder?: string
channelId?: string
execMode?: string
companyProfile?: string
taskPreferredAgent?: TaskPreferredAgent
agentStatus?: string
currentTool?: string
displayTool?: string
activeAgentCount?: number
runtimeControlState?: 'running' | 'suspending' | 'suspended' | 'resuming' | 'idle'
canStop?: boolean
autoFocus?: boolean
contextTokens?: number
contextWindow?: number
contextRemainingPct?: number
savedOrgs?: SavedOrgSummary[] | null
activeSavedOrg?: string | null
selectedOrgId?: string | null
/**
* When true the mode/agent pickers freeze into a read-only chip: the chat
* has committed to its current execution identity (i.e. messages have been
* sent) and the identity is no longer changeable from this composer. The
* chip exposes a hover hint pointing users at "start a new chat" instead.
*/
lockedMode?: boolean
onSend: (content: string, attachments?: OutgoingAttachmentPayload[]) => void
onModeChange?: (mode: ComposerExecMode, profile?: ComposerCompanyProfile, orgId?: string) => void
onTaskAgentChange?: (preferredAgent: TaskPreferredAgent) => void
onSavedOrgsRefresh?: () => void
onSavedOrgLoad?: (name: string) => void
onStop?: () => void
/**
* Spawn a brand-new chat in the requested mode, preserving the user inside
* the same project. Wired from the locked-mode chip popover so users can
* "continue in a different mode" without having to find the global new-chat
* button. When omitted, the popover degrades gracefully to text-only.
*/
onContinueInNewChat?: (mode: ComposerExecMode, profile?: ComposerCompanyProfile, orgId?: string) => void
}
interface ModeAlternative {
key: string
mode: ComposerExecMode
profile?: ComposerCompanyProfile
orgId?: string
label: string
description: string
icon: ReactElement
}
function savedOrgLabel(org: SavedOrgSummary): string {
return org.organization_name?.trim() || org.name
}
export function MessageComposer({
disabled,
placeholder,
channelId,
execMode,
companyProfile,
taskPreferredAgent = 'native',
agentStatus,
currentTool,
displayTool,
activeAgentCount,
runtimeControlState,
canStop,
autoFocus = true,
contextTokens,
contextWindow,
contextRemainingPct,
savedOrgs,
activeSavedOrg,
selectedOrgId,
lockedMode = false,
onSend,
onModeChange,
onTaskAgentChange,
onSavedOrgsRefresh,
onSavedOrgLoad,
onStop,
onContinueInNewChat,
}: MessageComposerProps) {
const [text, setText] = useState('')
const [focused, setFocused] = useState(false)
const [pending, setPending] = useState<PendingAttachment[]>([])
const [lightbox, setLightbox] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const isStopping = runtimeControlState === 'suspending'
const isRuntimeActive = runtimeControlState === 'running'
|| runtimeControlState === 'suspending'
|| runtimeControlState === 'resuming'
const isSuspended = runtimeControlState === 'suspended'
const hasRuntimeControlState = runtimeControlState != null
const isWorking = isRuntimeActive || (!hasRuntimeControlState && agentStatus != null && agentStatus !== 'idle')
const stopEnabled = (canStop ?? true) && !isStopping && !isSuspended
const contextUsage = useMemo(
() => getContextUsageMetrics({ contextTokens, contextWindow, contextRemainingPct }),
[contextRemainingPct, contextTokens, contextWindow],
)
useEffect(() => {
const ta = textareaRef.current
if (!ta) return
ta.style.height = 'auto'
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`
}, [text])
useEffect(() => {
setText('')
setPending(prev => {
prev.forEach(attachment => {
if (attachment.preview_url) URL.revokeObjectURL(attachment.preview_url)
})
return []
})
if (!disabled && autoFocus) setTimeout(() => textareaRef.current?.focus(), 50)
}, [channelId, disabled, autoFocus])
const updateAttachment = useCallback((id: string, updater: (attachment: PendingAttachment) => PendingAttachment) => {
setPending(prev => prev.map(attachment => attachment.id === id ? updater(attachment) : attachment))
}, [])
const prepareAttachment = useCallback(async (attachmentId: string, file: File) => {
try {
const base64 = await readFileAsBase64(file, (progress) => {
updateAttachment(attachmentId, (attachment) => ({
...attachment,
progress_percent: progress,
transfer_state: 'reading',
}))
})
updateAttachment(attachmentId, (attachment) => ({
...attachment,
base64_data: base64,
progress_percent: 100,
transfer_state: 'ready',
}))
} catch {
updateAttachment(attachmentId, (attachment) => ({
...attachment,
error: 'Failed to prepare file',
progress_percent: 0,
transfer_state: 'error',
}))
}
}, [updateAttachment])
const addFiles = useCallback((files: FileList | File[]) => {
const arr = Array.from(files)
let runningTotal = pending.reduce((sum, attachment) => sum + attachment.size_bytes, 0)
const newPending: PendingAttachment[] = arr.map(file => {
let error: string | undefined
if (file.size > MAX_FILE_SIZE) error = `Too large (${formatSize(file.size)})`
else if (runningTotal + file.size > MAX_TOTAL_SIZE) error = 'Total size exceeds 20MB'
else runningTotal += file.size
return {
id: crypto.randomUUID(),
file,
filename: file.name || 'upload',
mime_type: file.type || 'application/octet-stream',
size_bytes: file.size,
preview_url: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
progress_percent: 0,
transfer_state: error ? 'error' : 'reading',
error,
}
})
setPending(prev => [...prev, ...newPending])
newPending
.filter(attachment => !attachment.error)
.forEach(attachment => { void prepareAttachment(attachment.id, attachment.file) })
}, [pending, prepareAttachment])
const removeAttachment = useCallback((id: string) => {
setPending(prev => {
const item = prev.find(attachment => attachment.id === id)
if (item?.preview_url) URL.revokeObjectURL(item.preview_url)
return prev.filter(attachment => attachment.id !== id)
})
}, [])
const handleSend = useCallback(() => {
if (disabled || isWorking) return
const content = text.trim()
const preparing = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'reading')
const ready = pending.filter(attachment => !attachment.error && !!attachment.base64_data)
if (preparing.length > 0) return
if (!content && ready.length === 0) return
const attachments = ready.map(attachment => ({
filename: attachment.filename,
data: attachment.base64_data!,
mime_type: attachment.mime_type,
}))
onSend(content || 'Sent with attachments', attachments.length ? attachments : undefined)
setText('')
pending.forEach(attachment => {
if (attachment.preview_url) URL.revokeObjectURL(attachment.preview_url)
})
setPending([])
}, [disabled, isWorking, text, pending, onSend])
const handlePaste = useCallback((event: React.ClipboardEvent) => {
const files = event.clipboardData?.files
if (files && files.length > 0) {
event.preventDefault()
addFiles(files)
}
}, [addFiles])
const normalizedCompanyProfile = String(companyProfile ?? '').trim().toLowerCase()
const normalizedExecMode: ComposerExecMode = execMode === 'company'
? 'company'
: execMode === 'org' || execMode === 'custom' || normalizedCompanyProfile === 'custom'
? 'org'
: 'task'
const savedOrgOptions = useMemo(
() => (savedOrgs ?? []).filter(org => !!org.name && org.name !== 'corporate'),
[savedOrgs],
)
const activeSavedOrgOption = activeSavedOrg
? savedOrgOptions.find(org => org.name === activeSavedOrg)
: undefined
const selectedOrgOption = selectedOrgId
? savedOrgOptions.find(org => org.name === selectedOrgId)
: undefined
const activeSavedOrgLabel = activeSavedOrgOption
? savedOrgLabel(activeSavedOrgOption)
: activeSavedOrg || ''
const selectedOrgLabel = selectedOrgOption
? savedOrgLabel(selectedOrgOption)
: selectedOrgId || activeSavedOrgLabel
const selectedOrgValue = selectedOrgOption?.name || selectedOrgId || activeSavedOrgOption?.name || ''
const selectedModeOption: ComposerModeOption = normalizedExecMode === 'task' ? 'task' : 'company'
const selectedCompanyArchitecture: CompanyArchitectureOption = normalizedExecMode === 'org'
? (selectedOrgValue ? `org:${selectedOrgValue}` : '')
: 'corporate'
const companyArchitectureLabel = normalizedExecMode === 'org'
? selectedOrgLabel
? `Company / ${selectedOrgLabel}`
: 'Company / Saved org'
: 'Company / Corporate'
const modeLabel = selectedModeOption === 'task' ? 'Task' : companyArchitectureLabel
const showModePicker = !!execMode && !!onModeChange
const showTaskAgentPicker = normalizedExecMode === 'task' && !!onTaskAgentChange
// Build the list of "Continue in a new chat" alternatives, excluding the
// mode the current chat is already locked to. We surface up to three options
// so the popover stays compact; the order is stable so users build muscle
// memory for it.
const continueAlternatives: ModeAlternative[] = useMemo(() => {
const currentKey = normalizedExecMode === 'task'
? 'task'
: normalizedExecMode === 'org'
? `org:${selectedOrgValue || 'selected'}`
: 'company:corporate'
const continueOrgName = selectedOrgValue || activeSavedOrgOption?.name || ''
const continueOrgLabel = selectedOrgLabel || activeSavedOrgLabel || continueOrgName
const all: ModeAlternative[] = [
{
key: 'task',
mode: 'task',
label: 'Task',
description: 'A single agent handles the request',
icon: <IconUserRound />,
},
{
key: 'company:corporate',
mode: 'company',
profile: 'corporate',
label: 'Company / Corporate',
description: 'A team of roles collaborates',
icon: <IconBuilding />,
},
]
if (continueOrgName) {
all.push({
key: `org:${continueOrgName}`,
mode: 'org',
profile: 'custom',
orgId: continueOrgName,
label: `Company / ${continueOrgLabel}`,
description: 'A saved company architecture collaborates',
icon: <IconSparkles />,
})
}
return all.filter(option => option.key !== currentKey)
}, [activeSavedOrgLabel, activeSavedOrgOption?.name, normalizedExecMode, selectedOrgLabel, selectedOrgValue])
const preparingAttachmentCount = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'reading').length
const readyAttachmentCount = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'ready').length
const visibleTool = displayTool || currentTool
const statusText = (() => {
if (!isWorking) return null
if (isStopping) return 'Stopping...'
if (activeAgentCount && activeAgentCount > 1) return `${activeAgentCount} agents working`
if (visibleTool) return `Running ${visibleTool}`
if (agentStatus === 'reflecting') return 'Thinking...'
return 'Working...'
})()
return (
<>
<div className={`msg-composer${focused ? ' focused' : ''}${isWorking ? ' working' : ''}`}>
{isWorking && statusText && (
<div className="composer-status">
<div className="composer-status-indicator" />
<span className="composer-status-text">{statusText}</span>
<button className="composer-stop-btn" onClick={onStop} title="Stop" disabled={!stopEnabled || !onStop}>
<IconStop />
<span>{isStopping ? 'Stopping...' : 'Stop'}</span>
</button>
</div>
)}
{pending.length > 0 && (
<div className="composer-attachments">
{pending.map(attachment => (
<div key={attachment.id} className={`attachment-chip${attachment.error ? ' error' : ''}`}>
{attachment.preview_url ? (
<img
className="attachment-thumb"
src={attachment.preview_url}
alt={attachment.filename}
onClick={() => setLightbox(attachment.preview_url)}
/>
) : (
<span className={`attachment-file-icon tone-${attachmentToneClass(attachment.mime_type, attachment.filename)}`}>
{attachmentBadgeLabel(attachment.mime_type, attachment.filename)}
</span>
)}
<span className="attachment-chip-info">
<span className="attachment-chip-name">{attachment.filename}</span>
<span className="attachment-chip-size">
{attachment.error
? attachment.error
: attachment.transfer_state === 'reading'
? `Preparing ${attachment.progress_percent}%`
: `${formatSize(attachment.size_bytes)} - Ready`}
</span>
</span>
<AttachmentProgressRing
progress={attachment.progress_percent}
state={attachment.transfer_state}
error={attachment.error}
/>
<button
className="attachment-chip-remove"
onClick={() => removeAttachment(attachment.id)}
title="Remove"
>
<IconClose />
</button>
</div>
))}
</div>
)}
<div className="composer-input-area">
<textarea
ref={textareaRef}
value={text}
onChange={event => setText(event.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onPaste={handlePaste}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}}
placeholder={placeholder ?? 'Message...'}
rows={1}
disabled={disabled}
/>
<div className="composer-bottom">
<button
className="composer-attach-btn"
onClick={() => fileInputRef.current?.click()}
title="Attach files"
disabled={disabled}
>
<IconPaperclip />
</button>
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_TYPES}
style={{ display: 'none' }}
onChange={event => {
if (event.target.files) addFiles(event.target.files)
event.target.value = ''
}}
/>
{(showModePicker || execMode) && (
<div className="composer-config-group" data-locked={lockedMode ? 'true' : undefined}>
{showModePicker && !lockedMode ? (
<label
className="composer-mode-inline"
data-kind="mode"
title="Execution mode for this chat and new work started from it"
>
<span className="composer-mode-inline-label">Mode</span>
<span className="composer-mode-select-wrap">
<select
className="composer-mode-select"
value={selectedModeOption}
onChange={(event) => {
const value = event.target.value as ComposerModeOption
if (value === 'task') {
onModeChange?.('task')
return
}
onModeChange?.('company', 'corporate')
}}
onFocus={() => onSavedOrgsRefresh?.()}
onPointerDown={() => onSavedOrgsRefresh?.()}
disabled={disabled}
aria-label="Execution mode"
>
<option value="task">Task</option>
<option value="company">Company</option>
</select>
</span>
</label>
) : showModePicker && lockedMode ? (
<div
className="composer-mode-chip"
data-kind="mode"
tabIndex={0}
role="group"
aria-label={`Mode locked to ${modeLabel}.${onContinueInNewChat ? ' Use the menu to start a new chat in a different mode.' : ' Start a new chat to use a different mode.'}`}
>
<span className="composer-mode-chip-icon" aria-hidden="true">
<IconLock />
</span>
<span className="composer-mode-chip-label">{modeLabel}</span>
<div className="composer-mode-chip-popover" role="dialog" aria-label="Mode info">
<div className="composer-mode-chip-popover-title">
Mode is fixed for this chat
</div>
<div className="composer-mode-chip-popover-body">
Once the first message is sent, this chat is committed to{' '}
<strong>{modeLabel}</strong>.
</div>
{onContinueInNewChat && continueAlternatives.length > 0 && (
<>
<div className="composer-mode-chip-popover-divider" aria-hidden="true" />
<div className="composer-mode-chip-popover-action-title">
Continue in a new chat
</div>
<div className="composer-mode-chip-popover-actions">
{continueAlternatives.map(alt => (
<button
key={alt.key}
type="button"
className="composer-mode-chip-popover-action"
onClick={() => onContinueInNewChat(alt.mode, alt.profile, alt.orgId)}
aria-label={`Start a new chat in ${alt.label}`}
>
<span className="composer-mode-chip-popover-action-icon" aria-hidden="true">
{alt.icon}
</span>
<span className="composer-mode-chip-popover-action-text">
<span className="composer-mode-chip-popover-action-label">
{alt.label}
</span>
<span className="composer-mode-chip-popover-action-desc">
{alt.description}
</span>
</span>
<span className="composer-mode-chip-popover-action-arrow" aria-hidden="true">
<IconArrowRight />
</span>
</button>
))}
</div>
</>
)}
</div>
</div>
) : (
<span className="composer-mode" data-kind="mode">{modeLabel}</span>
)}
{selectedModeOption === 'company' && showModePicker && !lockedMode && (
<>
<span className="composer-config-divider" aria-hidden="true" />
<label
className="composer-mode-inline"
data-kind="org"
title="Company architecture for this chat"
>
<span className="composer-mode-inline-label">Company</span>
<span className="composer-mode-select-wrap">
<select
className="composer-mode-select"
value={selectedCompanyArchitecture}
onChange={(event) => {
const value = event.target.value as CompanyArchitectureOption
if (value === 'corporate') {
onModeChange?.('company', 'corporate')
return
}
if (value.startsWith('org:')) {
const orgName = value.slice(4)
if (orgName) onModeChange?.('org', 'custom', orgName)
}
}}
onFocus={() => onSavedOrgsRefresh?.()}
onPointerDown={() => onSavedOrgsRefresh?.()}
disabled={disabled}
aria-label="Company architecture"
>
<option value="corporate">Corporate</option>
{!selectedCompanyArchitecture && (
<option value="" disabled>Select saved org</option>
)}
{selectedCompanyArchitecture
&& selectedCompanyArchitecture !== 'corporate'
&& !savedOrgOptions.some(org => `org:${org.name}` === selectedCompanyArchitecture) && (
<option value={selectedCompanyArchitecture}>{selectedOrgLabel || selectedOrgValue}</option>
)}
{savedOrgOptions.length === 0 ? (
<option value="" disabled>No saved orgs</option>
) : savedOrgOptions.map(org => (
<option key={org.name} value={`org:${org.name}`}>
{savedOrgLabel(org)}
</option>
))}
</select>
</span>
</label>
</>
)}
{normalizedExecMode === 'task' && (
<>
<span className="composer-config-divider" aria-hidden="true" />
{showTaskAgentPicker && !lockedMode ? (
<label
className="composer-mode-inline"
data-kind="agent"
title="Execution agent for this task-mode chat"
>
<span className="composer-mode-inline-label">Agent</span>
<span className="composer-mode-select-wrap">
<select
className="composer-mode-select"
value={taskPreferredAgent}
onChange={(event) => onTaskAgentChange?.(event.target.value as TaskPreferredAgent)}
disabled={disabled}
aria-label="Task mode agent"
>
{Object.entries(TASK_AGENT_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</span>
</label>
) : showTaskAgentPicker && lockedMode ? (
<span
className="composer-mode-chip"
data-kind="agent"
tabIndex={0}
role="status"
aria-label={`Agent locked to ${TASK_AGENT_LABELS[taskPreferredAgent]}. Start a new chat to use a different agent.`}
>
<span className="composer-mode-chip-icon" aria-hidden="true">
<IconLock />
</span>
<span className="composer-mode-chip-label">
{TASK_AGENT_LABELS[taskPreferredAgent]}
</span>
<span className="composer-mode-chip-popover" role="tooltip">
<span className="composer-mode-chip-popover-title">
Agent is fixed for this chat
</span>
<span className="composer-mode-chip-popover-body">
The execution agent is committed once the chat starts. Start a new
chat to switch agents.
</span>
</span>
</span>
) : (
<span className="composer-mode" data-kind="agent">{TASK_AGENT_LABELS[taskPreferredAgent]}</span>
)}
</>
)}
</div>
)}
<span className="composer-hint">
{preparingAttachmentCount > 0
? `Preparing ${preparingAttachmentCount} attachment${preparingAttachmentCount > 1 ? 's' : ''}`
: readyAttachmentCount > 0
? `${readyAttachmentCount} attachment${readyAttachmentCount > 1 ? 's' : ''} ready`
: 'Shift+Enter for new line'}
</span>
{typeof contextUsage.usedPct === 'number' && (
<ContextRing
usedPct={contextUsage.usedPct}
usedTokens={contextUsage.usedTokens}
windowTokens={contextUsage.windowTokens}
/>
)}
<button
className="composer-send-btn"
onClick={handleSend}
disabled={disabled || preparingAttachmentCount > 0 || (!text.trim() && readyAttachmentCount === 0) || isWorking}
>
<IconSend />
</button>
</div>
</div>
</div>
{lightbox && (
<div className="lightbox-overlay" onClick={() => setLightbox(null)}>
<img className="lightbox-img" src={lightbox} alt="Preview" />
</div>
)}
</>
)
}
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict'
import { buildNarrativeMessageItems, copyTextToClipboard, parseProjectUpdatePayload, shouldReleaseStickToBottomOnScroll } from './MessageList'
import type { ChatMessage } from '../types/chat'
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 1200,
nextScrollTop: 900,
atBottom: false,
userScrolling: false,
programmaticScroll: false,
}),
true,
'scrollbar drag upward should release stick-to-bottom even without wheel/pointer events',
)
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 1200,
nextScrollTop: 900,
atBottom: false,
userScrolling: false,
programmaticScroll: true,
}),
false,
'programmatic scrolls should not release stick-to-bottom',
)
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 900,
nextScrollTop: 900,
atBottom: false,
userScrolling: true,
programmaticScroll: false,
}),
true,
'explicit user scroll state should release stick-to-bottom while away from bottom',
)
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 900,
nextScrollTop: 1200,
atBottom: true,
userScrolling: true,
programmaticScroll: false,
}),
false,
'scrolling back to bottom should keep follow mode available',
)
const parsedUpdate = parseProjectUpdatePayload(JSON.stringify({
summary: 'Completed the final Chinese memo with source checks.',
deliverables: [
{ name: 'source_credibility.md', path: '/workspace/source_credibility.md', status: 'complete' },
],
acceptance_status: [
{ criterion: 'Chinese memo', met: true },
{ criterion: 'Citations', met: true },
],
risks: ['Refresh market data after close.'],
next_actions: ['Use the memo in CEO aggregation.'],
}))
assert.equal(parsedUpdate?.kind, 'report')
assert.equal(parsedUpdate?.deliverables[0]?.name, 'source_credibility.md')
assert.equal(parsedUpdate?.acceptanceSummary, '2/2 acceptance checks met')
assert.deepEqual(parsedUpdate?.risks, ['Refresh market data after close.'])
const prefixedPayload = JSON.stringify({
summary: 'Focused QA recheck completed.',
deliverables: [
{ name: 'qa_recheck.md', path: '/workspace/qa_recheck.md', status: 'complete' },
],
})
const parsedPrefixedUpdate = parseProjectUpdatePayload(`**Report #1: Recheck remediated screen**: ${prefixedPayload}`)
assert.equal(parsedPrefixedUpdate?.kind, 'report')
assert.equal(parsedPrefixedUpdate?.title, 'Report #1: Recheck remediated screen')
assert.equal(parsedPrefixedUpdate?.summary, 'Focused QA recheck completed.')
const baseMessage = (id: string, content: string, timestamp: number, sender = 'system'): ChatMessage => ({
id,
channelId: 'session:root',
sender,
senderName: sender === 'user' ? 'You' : 'OPC',
content,
timestamp,
mentions: [],
metadata: {},
})
const narrativeItems = buildNarrativeMessageItems([
baseMessage('m1', '[Company:cto::execute::abc] starting Research source reliability', 1000),
baseMessage('m2', '[Delegating to codex] task=Research source reliability | cmd=codex exec ...', 1100),
baseMessage('m2b', 'Status digest: Research source reliability', 1150, 'cto'),
baseMessage('m3', 'The user-visible result is ready.', 1200, 'cto'),
baseMessage('m4', '[External status] codex started pid=123', 1300),
], { isCompanyRuntime: true, detailMode: 'summary' })
assert.equal(narrativeItems.length, 3)
assert.equal(narrativeItems[0].kind, 'ops-bundle')
assert.equal(narrativeItems[0].kind === 'ops-bundle' ? narrativeItems[0].events.length : 0, 3)
assert.equal(narrativeItems[1].kind, 'message')
assert.equal(narrativeItems[2].kind, 'ops-bundle')
const dedupedProjectUpdates = buildNarrativeMessageItems([
baseMessage('u1', prefixedPayload, 2000, 'qa_analyst'),
baseMessage('u2', `**Report #1: Recheck remediated screen**: ${prefixedPayload}`, 2000, 'qa_analyst'),
], { isCompanyRuntime: true, detailMode: 'summary' })
assert.equal(dedupedProjectUpdates.length, 1)
assert.equal(dedupedProjectUpdates[0].kind, 'message')
assert.equal(dedupedProjectUpdates[0].kind === 'message' ? dedupedProjectUpdates[0].msg.id : '', 'u1')
const longResult = 'Completed the focused recheck and produced the QA artifact with caveats for downstream aggregation.'
const dedupedNarrativeMessages = buildNarrativeMessageItems([
baseMessage('n1', longResult, 3000, 'qa_analyst'),
baseMessage('n2', `Recheck remediated ten-bagger candidate screen: ${longResult}`, 3000, 'qa_analyst'),
], { isCompanyRuntime: true, detailMode: 'summary' })
assert.equal(dedupedNarrativeMessages.length, 1)
assert.equal(dedupedNarrativeMessages[0].kind === 'message' ? dedupedNarrativeMessages[0].msg.id : '', 'n1')
const duplicatedResultSurface = buildNarrativeMessageItems([
{
...baseMessage('r1', longResult, 4000, 'chao'),
metadata: { source: 'engine', transcript_kind: 'child_task_result' },
},
{
...baseMessage('r2', `Deliver final result to user: ${longResult}`, 4500, 'system'),
senderName: 'Company Member',
metadata: { source: 'runtime_event', kind: 'worker_notification', notification_kind: 'task_complete' },
},
], { isCompanyRuntime: true, detailMode: 'summary' })
assert.equal(duplicatedResultSurface.length, 1)
assert.equal(duplicatedResultSurface[0].kind === 'message' ? duplicatedResultSurface[0].msg.id : '', 'r1')
const fullItems = buildNarrativeMessageItems([
baseMessage('m1', '[Company:cto::execute::abc] starting Research source reliability', 1000),
], { isCompanyRuntime: true, detailMode: 'full' })
assert.equal(fullItems[0].kind, 'message')
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document')
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
clipboard: {
writeText: async () => {
throw new Error('clipboard denied')
},
},
},
})
let selectedValue = ''
let appendedNode: any = null
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
body: {
appendChild: (node: any) => {
appendedNode = node
},
removeChild: (node: any) => {
assert.equal(node, appendedNode)
appendedNode = null
},
},
createElement: () => ({
value: '',
style: {},
setAttribute: () => {},
focus: () => {},
select: function () {
selectedValue = this.value
},
setSelectionRange: () => {},
}),
execCommand: (command: string) => command === 'copy',
},
})
assert.equal(await copyTextToClipboard('fallback copy text'), true)
assert.equal(selectedValue, 'fallback copy text')
assert.equal(appendedNode, null)
if (originalNavigator) {
Object.defineProperty(globalThis, 'navigator', originalNavigator)
} else {
delete (globalThis as any).navigator
}
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument)
} else {
delete (globalThis as any).document
}
console.log('MessageList.test.tsx: OK (scroll + narrative timeline helpers)')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { RecruitmentPanel } from './RecruitmentPanel'
const markup = renderToStaticMarkup(
React.createElement(RecruitmentPanel, {
meta: {
checkpoint_type: 'company_recruitment_confirmation',
checkpoint_id: 'cp-recruit',
company_profile: 'corporate',
summary: 'Company mode has a pending staffing decision before execution.',
proposals: [
{
role_id: 'senior_engineer',
status: 'proposed_hire',
rationale: 'Selected the strongest backend option.',
role_labels: ['Senior Engineer'],
candidate: {
template_id: 'engineering-backend-architect',
template_name: 'Backend Architect',
category: 'engineering',
domains: ['backend', 'api'],
proposed_name: 'Backend Architect',
rationale: 'Strong API architecture fit.',
},
existing_employee_ids: [],
default_agent: 'codex',
selected_agent: 'codex',
},
],
recruitment_rationales: [
{
role_id: 'senior_engineer',
role_label: 'Senior Engineer',
status: 'proposed_hire',
selection_label: 'Backend Architect',
rationale: 'Strong API architecture fit.',
},
],
staffing_roles: [
{
role_id: 'senior_engineer',
role_label: 'Senior Engineer',
default_selection: { kind: 'template', id: 'engineering-backend-architect' },
default_agent: 'codex',
selected_agent: 'codex',
same_role_employee_ids: [],
},
],
staffing_pool: {
employees: [],
templates: [
{
template_id: 'engineering-backend-architect',
template_name: 'Backend Architect',
category: 'engineering',
domains: ['backend', 'api'],
},
],
},
staffing_selections: {
senior_engineer: { kind: 'template', id: 'engineering-backend-architect' },
},
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(markup, /Recruitment Review/)
assert.match(markup, /Strong API architecture fit/)
assert.match(markup, /ckpt-staffing-grid/)
assert.match(markup, /Backend Architect/)
assert.match(markup, /Approve/)
assert.match(markup, /Send Feedback/)
assert.doesNotMatch(markup, /Deny/)
const source = readFileSync(new URL('./RecruitmentPanel.tsx', import.meta.url), 'utf8')
assert.match(source, /buildReplyMetadata\('approve'\)/)
assert.match(source, /buildReplyMetadata\('feedback'\)/)
assert.match(source, /recruitment_agent: recruitmentAgent/)
assert.match(source, /hasSubmittedCheckpointMetadata/, 'responded recruitment cards must detect persisted reply metadata')
assert.match(source, /setRoleAgents\(buildRoleAgentsFromMeta\(meta, roles\)\)/, 'responded recruitment cards must sync displayed agent choices from reply metadata')
console.log('RecruitmentPanel.test.tsx: OK (recruitment review uses staffing-style UI)')
@@ -0,0 +1,399 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import type {
CheckpointReplyMetadata,
ChatMessageMeta,
RecruitmentProposalEntry,
StaffingEmployeeOption,
StaffingRoleEntry,
StaffingSelectionValue,
StaffingTemplateOption,
} from '../types/chat'
import type { TaskPreferredAgent } from '../types/kanban'
const TASK_AGENT_LABELS: Record<TaskPreferredAgent, string> = {
native: 'OpenOPC Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
const DEFAULT_ROLE_AGENT: TaskPreferredAgent = 'codex'
const DEFAULT_RECRUITMENT_AGENT: TaskPreferredAgent = 'native'
const TASK_AGENT_OPTIONS: TaskPreferredAgent[] = ['codex', 'native', 'claude_code', 'cursor', 'opencode']
const RECRUITMENT_AGENT_OPTIONS: TaskPreferredAgent[] = ['native', 'codex', 'claude_code', 'cursor', 'opencode']
type StaffingOption =
| { kind: 'employee'; id: string; name: string; subtitle: string; category: string; searchText: string }
| { kind: 'template'; id: string; name: string; subtitle: string; category: string; searchText: string }
| { kind: 'fallback'; id: ''; name: string; subtitle: string; category: string; searchText: string }
interface RecruitmentPanelProps {
meta: ChatMessageMeta
onReply: (text: string, metadata?: CheckpointReplyMetadata) => void
responded: boolean
}
function normalizeSelection(value: StaffingSelectionValue | undefined): StaffingSelectionValue {
if (!value) return { kind: 'fallback' }
if (value.kind === 'employee') {
const id = String(value.id ?? value.employee_id ?? '').trim()
return id ? { kind: 'employee', id } : { kind: 'fallback' }
}
if (value.kind === 'template') {
const id = String(value.id ?? value.template_id ?? '').trim()
return id ? { kind: 'template', id } : { kind: 'fallback' }
}
return { kind: 'fallback' }
}
function selectionKey(value: StaffingSelectionValue | undefined): string {
const normalized = normalizeSelection(value)
return normalized.kind === 'fallback' ? 'fallback:' : `${normalized.kind}:${normalized.id ?? ''}`
}
function buildOptions(
role: StaffingRoleEntry,
employees: StaffingEmployeeOption[],
templates: StaffingTemplateOption[],
): StaffingOption[] {
const roleId = String(role.role_id ?? '').trim()
const roleLabel = String(role.role_label ?? '').trim()
const roleText = `${roleId} ${roleLabel} ${role.role_responsibility ?? ''}`.toLowerCase()
const sameRoleIds = new Set((role.same_role_employee_ids ?? []).map(item => String(item ?? '').trim()).filter(Boolean))
const employeeOptions = employees.map((employee): StaffingOption & { rank: number } => {
const id = String(employee.employee_id ?? '').trim()
const name = String(employee.employee_name ?? id).trim() || id
const employeeRole = String(employee.role_id ?? '').trim()
const category = String(employee.category ?? '').trim()
const subtitle = [employeeRole, category].filter(Boolean).join(' · ') || id
return {
kind: 'employee',
id,
name,
subtitle,
category,
searchText: `${id} ${name} ${employeeRole} ${category} ${(employee.domains ?? []).join(' ')} ${(employee.tags ?? []).join(' ')}`.toLowerCase(),
rank: sameRoleIds.has(id) || employeeRole === roleId ? 0 : 2,
}
}).filter(option => option.id).sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
const templateOptions = templates.map((template): StaffingOption & { rank: number } => {
const id = String(template.template_id ?? '').trim()
const name = String(template.template_name ?? id).trim() || id
const category = String(template.category ?? '').trim()
const subtitle = [category, id].filter(Boolean).join(' · ') || id
const templateText = `${id} ${name} ${category} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase()
const rank = roleText.split(/[^a-z0-9]+/).filter(token => token.length >= 3).reduce(
(score, token) => score + (templateText.includes(token) ? 1 : 0),
0,
)
return {
kind: 'template',
id,
name,
subtitle,
category,
searchText: templateText,
rank,
}
}).filter(option => option.id).sort((a, b) => b.rank - a.rank || a.name.localeCompare(b.name))
return [
...employeeOptions,
...templateOptions,
{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' },
]
}
function optionForSelection(options: StaffingOption[], selection: StaffingSelectionValue | undefined): StaffingOption {
const key = selectionKey(selection)
return options.find(option => `${option.kind}:${option.id}` === key) ?? options[0]
}
function optionMatches(option: StaffingOption, query: string): boolean {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return true
return terms.every(term => option.searchText.includes(term))
}
function buildRolesFromProposals(proposals: RecruitmentProposalEntry[]): StaffingRoleEntry[] {
return proposals.map((proposal) => {
const roleId = String(proposal.role_id ?? '').trim()
const existingId = String(proposal.existing_employee?.employee_id ?? '').trim()
const templateId = String(proposal.candidate?.template_id ?? '').trim()
const defaultSelection: StaffingSelectionValue = existingId
? { kind: 'employee', id: existingId }
: templateId
? { kind: 'template', id: templateId }
: { kind: 'fallback' }
return {
role_id: roleId,
role_label: proposal.role_labels?.[0] ?? roleId,
role_responsibility: '',
default_selection: defaultSelection,
same_role_employee_ids: proposal.existing_employee_ids ?? [],
fallback_available: true,
default_agent: proposal.default_agent ?? DEFAULT_ROLE_AGENT,
selected_agent: proposal.selected_agent ?? proposal.default_agent ?? DEFAULT_ROLE_AGENT,
default_source: 'recruitment',
}
}).filter(role => role.role_id)
}
function selectedRecruitmentName(
proposal: RecruitmentProposalEntry | undefined,
selected: StaffingOption,
): string {
if (selected.kind === 'template' && proposal?.candidate?.template_id === selected.id) {
return proposal.candidate.proposed_name || proposal.candidate.template_name || selected.name
}
if (selected.kind === 'employee' && proposal?.existing_employee?.employee_id === selected.id) {
return proposal.existing_employee.employee_name || selected.name
}
return selected.name
}
function buildSelectionsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, StaffingSelectionValue> {
const initial: Record<string, StaffingSelectionValue> = {}
const persisted = meta.staffing_selections ?? {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) continue
initial[roleId] = normalizeSelection(persisted[roleId] ?? role.default_selection)
}
return initial
}
function buildRoleAgentsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, TaskPreferredAgent> {
const persisted = meta.recruitment_role_agents ?? {}
const initial: Record<string, TaskPreferredAgent> = {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) continue
initial[roleId] = persisted[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
}
return initial
}
function hasSubmittedCheckpointMetadata(meta: ChatMessageMeta): boolean {
return Boolean(
String(meta.checkpoint_response_message_id ?? '').trim()
|| String(meta.checkpoint_responded_at ?? '').trim()
|| String(meta.checkpoint_reply_kind ?? '').trim()
)
}
export const RecruitmentPanel = React.memo(function RecruitmentPanel({
meta, onReply, responded,
}: RecruitmentPanelProps) {
const proposals = meta.proposals ?? []
const proposalByRole = useMemo(() => {
const next: Record<string, RecruitmentProposalEntry> = {}
for (const proposal of proposals) {
const roleId = String(proposal.role_id ?? '').trim()
if (roleId) next[roleId] = proposal
}
return next
}, [proposals])
const roles = useMemo(
() => (meta.staffing_roles?.length ? meta.staffing_roles : buildRolesFromProposals(proposals)),
[meta.staffing_roles, proposals],
)
const employees = meta.staffing_pool?.employees ?? []
const templates = meta.staffing_pool?.templates ?? []
const rationales = meta.recruitment_rationales ?? []
const optionsByRole = useMemo(() => {
const next: Record<string, StaffingOption[]> = {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (roleId) next[roleId] = buildOptions(role, employees, templates)
}
return next
}, [employees, roles, templates])
const [queries, setQueries] = useState<Record<string, string>>({})
const [feedback, setFeedback] = useState('')
const [selections, setSelections] = useState<Record<string, StaffingSelectionValue>>(() => buildSelectionsFromMeta(meta, roles))
const [roleAgents, setRoleAgents] = useState<Record<string, TaskPreferredAgent>>(() => buildRoleAgentsFromMeta(meta, roles))
const [recruitmentAgent, setRecruitmentAgent] = useState<TaskPreferredAgent>(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
const isResponded = responded
useEffect(() => {
if (!isResponded || !hasSubmittedCheckpointMetadata(meta)) return
setSelections(buildSelectionsFromMeta(meta, roles))
setRoleAgents(buildRoleAgentsFromMeta(meta, roles))
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
}, [isResponded, meta, roles])
useEffect(() => {
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
}, [meta.recruitment_agent])
const buildReplyMetadata = useCallback((kind: NonNullable<CheckpointReplyMetadata['checkpoint_reply_kind']>): CheckpointReplyMetadata => {
const checkpointId = String(meta.checkpoint_id ?? '').trim()
if (!checkpointId) {
throw new Error('Recruitment checkpoint reply requires checkpoint_id metadata.')
}
const checkpointType = String(meta.checkpoint_type ?? '').trim()
return {
response_to_checkpoint_id: checkpointId,
response_to_checkpoint_type: checkpointType || 'company_recruitment_confirmation',
checkpoint_reply_kind: kind,
staffing_selections: selections,
recruitment_agent: recruitmentAgent,
recruitment_role_agents: roles.reduce<Record<string, TaskPreferredAgent>>((acc, role) => {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) return acc
acc[roleId] = roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
return acc
}, {}),
}
}, [meta.checkpoint_id, meta.checkpoint_type, recruitmentAgent, roleAgents, roles, selections])
const handleApprove = useCallback(() => {
if (isResponded) return
onReply('approve', buildReplyMetadata('approve'))
}, [buildReplyMetadata, isResponded, onReply])
const handleFeedback = useCallback(() => {
if (isResponded || !feedback.trim()) return
onReply(feedback.trim(), buildReplyMetadata('feedback'))
setFeedback('')
}, [buildReplyMetadata, isResponded, feedback, onReply])
return (
<div className="ckpt-panel ckpt-recruitment">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-recruit">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6" cy="5" r="3" />
<path d="M2 14c0-2.2 1.8-4 4-4s4 1.8 4 4" />
<path d="M12 5v4M10 7h4" />
</svg>
</div>
<div className="ckpt-title">Recruitment Review</div>
<span className="ckpt-badge ckpt-badge-profile">{meta.company_profile || 'corporate'}</span>
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
</div>
{meta.summary && <div className="ckpt-summary">{meta.summary}</div>}
<div className="ckpt-recruiter-agent">
<label className="ckpt-agent-label" htmlFor={`recruitment-recruiter-agent-${meta.checkpoint_id}`}>
Recruiter Agent
</label>
<select
id={`recruitment-recruiter-agent-${meta.checkpoint_id}`}
className="ckpt-agent-select"
value={recruitmentAgent}
onChange={event => setRecruitmentAgent(event.target.value as TaskPreferredAgent)}
disabled={isResponded}
>
{RECRUITMENT_AGENT_OPTIONS.map(agent => (
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
))}
</select>
</div>
{rationales.length > 0 && (
<div className="ckpt-recruitment-reasons">
{rationales.map(item => (
<div key={item.role_id} className="ckpt-recruitment-reason">
<div className="ckpt-proposal-header">
<span className="ckpt-role-name">{item.role_id}</span>
{item.role_label && item.role_label !== item.role_id && <span className="ckpt-field-tag">{item.role_label}</span>}
{item.selection_label && <span className="ckpt-badge ckpt-badge-template">{item.selection_label}</span>}
</div>
{item.rationale && <div className="ckpt-rationale">{item.rationale}</div>}
</div>
))}
</div>
)}
<div className="ckpt-staffing-grid">
{roles.map(role => {
const roleId = String(role.role_id ?? '').trim()
const options = optionsByRole[roleId] ?? [{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' }]
const selected = optionForSelection(options, selections[roleId])
const proposal = proposalByRole[roleId]
const query = queries[roleId] ?? ''
const visibleOptions = options.filter(option => optionMatches(option, query)).slice(0, 8)
return (
<div key={roleId} className="ckpt-staffing-card">
<div className="ckpt-proposal-header">
<span className="ckpt-role-name">{roleId}</span>
<span className={`ckpt-badge ckpt-badge-${selected.kind}`}>{selected.kind}</span>
</div>
{role.role_label && role.role_label !== roleId && (
<div className="ckpt-role-labels">
<span className="ckpt-field-tag">{role.role_label}</span>
</div>
)}
<div className="ckpt-staffing-selected">
<div className="ckpt-cand-name">{selectedRecruitmentName(proposal, selected)}</div>
<div className="ckpt-cand-meta">
<span className="ckpt-cand-category">{selected.category}</span>
<span className="ckpt-domain-tag">{selected.subtitle}</span>
</div>
</div>
<input
className="ckpt-staffing-search"
value={query}
onChange={event => setQueries(current => ({ ...current, [roleId]: event.target.value }))}
placeholder="Search employees or templates..."
disabled={isResponded}
/>
<div className="ckpt-staffing-options">
{visibleOptions.map(option => {
const active = `${option.kind}:${option.id}` === selectionKey(selections[roleId])
return (
<button
key={`${option.kind}:${option.id}`}
className={`ckpt-staffing-option${active ? ' active' : ''}`}
onClick={() => setSelections(current => ({ ...current, [roleId]: { kind: option.kind, id: option.id } }))}
disabled={isResponded}
title={option.subtitle}
>
<span className="ckpt-staffing-option-kind">{option.kind}</span>
<span className="ckpt-staffing-option-name">{option.name}</span>
</button>
)
})}
</div>
<div className="ckpt-agent-picker">
<label className="ckpt-agent-label" htmlFor={`recruit-agent-${meta.checkpoint_id}-${roleId}`}>
Execution Agent
</label>
<select
id={`recruit-agent-${meta.checkpoint_id}-${roleId}`}
className="ckpt-agent-select"
value={roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT}
onChange={event => setRoleAgents(current => ({ ...current, [roleId]: event.target.value as TaskPreferredAgent }))}
disabled={isResponded}
>
{TASK_AGENT_OPTIONS.map(agent => (
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
))}
</select>
</div>
</div>
)
})}
</div>
{!isResponded && (
<div className="ckpt-actions ckpt-actions-inline-feedback">
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove}>Approve</button>
<textarea
className="ckpt-feedback-input ckpt-feedback-inline-input"
placeholder="Feedback to refine recruitment..."
value={feedback}
onChange={e => setFeedback(e.target.value)}
rows={2}
/>
<button className="ckpt-btn ckpt-btn-feedback" onClick={handleFeedback} disabled={!feedback.trim()}>
Send Feedback
</button>
</div>
)}
</div>
)
})
@@ -0,0 +1,123 @@
import React, { useCallback } from 'react'
import type { ChatMessageMeta } from '../types/chat'
interface ReorgPanelProps {
meta: ChatMessageMeta
onReply: (text: string) => void
responded: boolean
}
const SCOPE_LABELS: Record<string, string> = {
task_adjustment: 'Task Adjustment',
runtime_replan: 'Runtime Replan',
org_mutation: 'Org Mutation',
}
const RISK_COLORS: Record<string, string> = {
low: 'var(--green)',
medium: 'var(--yellow)',
high: 'var(--red)',
}
export const ReorgPanel = React.memo(function ReorgPanel({
meta, onReply, responded,
}: ReorgPanelProps) {
const isResponded = responded
const handleApprove = useCallback(() => {
if (isResponded) return
onReply('approve')
}, [isResponded, onReply])
const handleDeny = useCallback(() => {
if (isResponded) return
onReply('deny')
}, [isResponded, onReply])
const roleChanges = meta.role_changes ?? []
const projectionChanges = meta.work_item_projection_changes ?? []
const scope = meta.scope || 'org_mutation'
const risk = meta.risk_level || 'medium'
const impact = meta.impact_summary || {}
return (
<div className="ckpt-panel ckpt-reorg">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-reorg">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="1" width="5" height="5" rx="1" />
<rect x="10" y="1" width="5" height="5" rx="1" />
<rect x="5.5" y="10" width="5" height="5" rx="1" />
<path d="M3.5 6v2.5a1 1 0 001 1h7a1 1 0 001-1V6" />
<path d="M8 9.5V10" />
</svg>
</div>
<div className="ckpt-title">{meta.title || 'Company Reorg'}</div>
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
</div>
<div className="ckpt-reorg-badges">
<span className="ckpt-badge ckpt-badge-scope">{SCOPE_LABELS[scope] || scope}</span>
<span className="ckpt-badge" style={{ color: RISK_COLORS[risk] || 'var(--text-secondary)', borderColor: RISK_COLORS[risk] || 'var(--border)' }}>
Risk: {risk.charAt(0).toUpperCase() + risk.slice(1)}
</span>
</div>
{meta.summary && <div className="ckpt-summary">{meta.summary}</div>}
{meta.rationale && <div className="ckpt-rationale">{meta.rationale}</div>}
{roleChanges.length > 0 && (
<div className="ckpt-changes-section">
<div className="ckpt-changes-title">Role Changes</div>
{roleChanges.map((rc, i) => (
<div key={i} className="ckpt-change-row">
<span className={`ckpt-change-action ckpt-action-${rc.action}`}>{rc.action}</span>
<span className="ckpt-change-id">{rc.role_id}</span>
{rc.replacement_role_id && (
<>
<span className="ckpt-change-arrow">&rarr;</span>
<span className="ckpt-change-id">{rc.replacement_role_id}</span>
</>
)}
{rc.reason && <span className="ckpt-change-reason">{rc.reason}</span>}
</div>
))}
</div>
)}
{projectionChanges.length > 0 && (
<div className="ckpt-changes-section">
<div className="ckpt-changes-title">Work Item Projection Changes</div>
{projectionChanges.map((change, i) => (
<div key={i} className="ckpt-change-row">
<span className={`ckpt-change-action ckpt-action-${change.action}`}>{change.action}</span>
<span className="ckpt-change-id">{change.work_item_projection_id}</span>
{change.replacement_work_item_projection_id && (
<>
<span className="ckpt-change-arrow">&rarr;</span>
<span className="ckpt-change-id">{change.replacement_work_item_projection_id}</span>
</>
)}
{change.reason && <span className="ckpt-change-reason">{change.reason}</span>}
</div>
))}
</div>
)}
{Object.keys(impact).length > 0 && (
<div className="ckpt-impact">
{impact.affected_tasks != null && <span>Tasks affected: {impact.affected_tasks}</span>}
{impact.affected_roles != null && <span>Roles affected: {impact.affected_roles}</span>}
{impact.migration_count != null && <span>Migrations: {impact.migration_count}</span>}
</div>
)}
{!isResponded && (
<div className="ckpt-actions">
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove}>Approve Reorg</button>
<button className="ckpt-btn ckpt-btn-deny" onClick={handleDeny}>Deny</button>
</div>
)}
</div>
)
})
@@ -0,0 +1,443 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { Session } from '../types/kanban'
import { IconPlus, IconSearch, IconActivity, IconShield, IconTrash, IconWorkItem } from './SvgIcons'
import { getSessionRuntimeStatus } from '../lib/sessionRuntime'
import { deriveCompanyRuntimeDisplayStatus } from '../lib/workItemSessions'
interface SessionSidebarProps {
sessions: Session[]
activeSessionId: string | null
activeChannel?: string | null
secretaryChannelId?: string
unreadCounts?: Record<string, number>
onSelect: (taskId: string | null) => void
onCreateSession: () => void
onDeleteSession: (taskId: string) => void
onSelectSecretary?: () => void
}
const STATUS_DOT: Record<string, string> = {
idle: 'status-idle',
done: 'status-done',
pending: 'status-pending',
failed: 'status-failed',
cancelled: 'status-cancelled',
blocked: 'status-blocked',
}
function sessionDotClass(session: Session): string {
const displayStatus = deriveCompanyRuntimeDisplayStatus(session) ?? session.status
const runtimeStatus = getSessionRuntimeStatus({ ...session, status: displayStatus })
if (runtimeStatus === 'tool_active') return 'status-tool-active'
if (runtimeStatus === 'reflecting') return 'status-reflecting'
if (displayStatus === 'running') return 'status-running-idle'
return STATUS_DOT[displayStatus] ?? 'status-pending'
}
function relativeTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60_000) return 'just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
return `${Math.floor(diff / 86_400_000)}d`
}
function dateGroup(ts: number): string {
const now = new Date()
const d = new Date(ts)
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today.getTime() - 86_400_000)
if (d >= today) return 'Today'
if (d >= yesterday) return 'Yesterday'
return 'Earlier'
}
interface SessionTree {
session: Session
children: Session[]
}
type SidebarRow =
| { kind: 'group'; group: 'Today' | 'Yesterday' | 'Earlier' }
| { kind: 'primary'; node: SessionTree }
| { kind: 'child'; parentTaskId: string; child: Session; childIndex: number; childCount: number }
| { kind: 'child-count'; node: SessionTree }
function buildSessionTree(sessions: Session[]): SessionTree[] {
const childMap = new Map<string, Session[]>()
const primarySessions: Session[] = []
for (const s of sessions) {
if (s.mode === 'child' && s.parentSessionId) {
const siblings = childMap.get(s.parentSessionId) ?? []
siblings.push(s)
childMap.set(s.parentSessionId, siblings)
} else {
primarySessions.push(s)
}
}
return primarySessions.map(p => ({
session: p,
children: Array.from(new Map(
[...(p.sessionId ? (childMap.get(p.sessionId) ?? []) : []), ...(childMap.get(p.taskId) ?? [])]
.map(child => [child.taskId, child]),
).values()),
}))
}
function SessionItem({
session,
isActive,
isChild,
isLast,
unreadCount,
onSelect,
onDelete,
}: {
session: Session
isActive: boolean
isChild?: boolean
isLast?: boolean
unreadCount?: number
onSelect: () => void
onDelete: () => void
}) {
const [showDelete, setShowDelete] = useState(false)
const [confirming, setConfirming] = useState(false)
const agentLabel = session.assigneeIds.length > 0
? session.assigneeIds[0].replace(/^agent-/, '')
: null
const displayStatus = deriveCompanyRuntimeDisplayStatus(session) ?? session.status
useEffect(() => {
if (!confirming) return
const timer = setTimeout(() => setConfirming(false), 3000)
return () => clearTimeout(timer)
}, [confirming])
return (
<button
className={`session-item${isActive ? ' active' : ''}${isChild ? ' session-item-child' : ''}`}
onClick={onSelect}
onMouseEnter={() => setShowDelete(true)}
onMouseLeave={() => setShowDelete(false)}
>
{isChild && (
<span className={`session-tree-line${isLast ? ' last' : ''}`} />
)}
<span className={`session-dot ${sessionDotClass(session)}`} />
<div className="session-item-content">
<span className="session-item-title">
{isChild && agentLabel && (
<span className="session-agent-tag">{agentLabel}</span>
)}
{!isChild && session.isCompanyRuntime && (
<span className="session-runtime-badge" title="Company runtime"><IconWorkItem /></span>
)}
{session.title}
</span>
<span className="session-item-meta">
{displayStatus} · {relativeTime(session.updatedAt)}
</span>
</div>
{!!unreadCount && unreadCount > 0 && (
<span className="session-unread-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
{showDelete && !isChild && !confirming && (
<button
className="session-delete-btn"
onClick={e => { e.stopPropagation(); setConfirming(true) }}
title="Delete"
>
<IconTrash />
</button>
)}
{confirming && (
<span className="session-confirm-delete" onClick={e => e.stopPropagation()}>
<button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>Delete</button>
<button className="session-confirm-no" onClick={() => setConfirming(false)}>Cancel</button>
</span>
)}
</button>
)
}
export function SessionSidebar({ sessions, activeSessionId, activeChannel, secretaryChannelId, unreadCounts, onSelect, onCreateSession, onDeleteSession, onSelectSecretary }: SessionSidebarProps) {
const [search, setSearch] = useState('')
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const hasRuntimeSessions = sessions.some(session =>
session.isCompanyRuntime
|| session.mode === 'child'
|| session.execMode === 'company'
|| session.execMode === 'org'
|| session.execMode === 'custom'
)
const toggleCollapse = useCallback((taskId: string) => {
setCollapsed(prev => {
const next = new Set(prev)
if (next.has(taskId)) next.delete(taskId)
else next.add(taskId)
return next
})
}, [])
const filtered = useMemo(() => {
if (!search.trim()) return sessions
const q = search.toLowerCase()
return sessions.filter(s => s.title.toLowerCase().includes(q))
}, [sessions, search])
const tree = useMemo(() => buildSessionTree(filtered), [filtered])
const grouped = useMemo(() => {
const groups: Record<string, SessionTree[]> = { Today: [], Yesterday: [], Earlier: [] }
for (const node of tree) {
const g = dateGroup(node.session.createdAt)
groups[g]?.push(node)
}
return groups
}, [tree])
const rows = useMemo<SidebarRow[]>(() => {
const nextRows: SidebarRow[] = []
for (const group of ['Today', 'Yesterday', 'Earlier'] as const) {
const items = grouped[group]
if (!items || items.length === 0) continue
nextRows.push({ kind: 'group', group })
for (const node of items) {
nextRows.push({ kind: 'primary', node })
const hasChildren = node.children.length > 0
const isCollapsed = collapsed.has(node.session.taskId)
if (hasChildren && isCollapsed) {
nextRows.push({ kind: 'child-count', node })
} else if (hasChildren) {
node.children.forEach((child, idx) => {
nextRows.push({
kind: 'child',
parentTaskId: node.session.taskId,
child,
childIndex: idx,
childCount: node.children.length,
})
})
}
}
}
return nextRows
}, [collapsed, grouped])
const useVirtualRows = rows.length > 120
const listRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => listRef.current,
estimateSize: (index) => {
const row = rows[index]
if (row?.kind === 'group') return 26
if (row?.kind === 'child-count') return 30
return 54
},
overscan: 8,
})
const renderPrimaryRow = useCallback((node: SessionTree) => {
const hasChildren = node.children.length > 0
const isCollapsed = collapsed.has(node.session.taskId)
return (
<div className="session-tree-node">
<div className="session-tree-primary">
{hasChildren && (
<button
className={`session-expand-btn${isCollapsed ? ' collapsed' : ''}`}
onClick={e => {
e.stopPropagation()
toggleCollapse(node.session.taskId)
}}
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
/>
)}
<SessionItem
session={node.session}
isActive={node.session.taskId === activeSessionId}
unreadCount={unreadCounts?.[node.session.channelId]}
onSelect={() => onSelect(node.session.taskId)}
onDelete={() => onDeleteSession(node.session.taskId)}
/>
</div>
</div>
)
}, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts])
const renderVirtualRow = useCallback((row: SidebarRow) => {
if (row.kind === 'group') {
return (
<div className="session-group-label">
{hasRuntimeSessions ? `${row.group} Runtime Sessions` : row.group}
</div>
)
}
if (row.kind === 'primary') {
return renderPrimaryRow(row.node)
}
if (row.kind === 'child-count') {
return (
<button
className="session-child-count"
onClick={() => toggleCollapse(row.node.session.taskId)}
>
{row.node.children.length} sub-task{row.node.children.length > 1 ? 's' : ''}
</button>
)
}
return (
<div className="session-children">
<SessionItem
session={row.child}
isActive={row.child.taskId === activeSessionId}
isChild
isLast={row.childIndex === row.childCount - 1}
unreadCount={unreadCounts?.[row.child.channelId]}
onSelect={() => onSelect(row.child.taskId)}
onDelete={() => onDeleteSession(row.child.taskId)}
/>
</div>
)
}, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts])
return (
<div className="session-sidebar">
<button className="session-new-btn" onClick={onCreateSession}>
<IconPlus />
<span>New Chat</span>
</button>
{onSelectSecretary && (
<button
className={`session-nav-btn${activeChannel === secretaryChannelId ? ' active' : ''}`}
onClick={onSelectSecretary}
>
<IconShield />
<span>Secretary</span>
{!!(secretaryChannelId && unreadCounts?.[secretaryChannelId]) && unreadCounts![secretaryChannelId!] > 0 && (
<span className="session-unread-badge">{unreadCounts![secretaryChannelId!] > 99 ? '99+' : unreadCounts![secretaryChannelId!]}</span>
)}
</button>
)}
<div className="session-search-wrap">
<IconSearch />
<input
className="session-search"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div ref={listRef} className={`session-list${useVirtualRows ? ' session-list-virtualized' : ''}`}>
{useVirtualRows ? (
<div
style={{
height: rowVirtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderVirtualRow(rows[virtualRow.index])}
</div>
))}
</div>
) : (['Today', 'Yesterday', 'Earlier'] as const).map(group => {
const items = grouped[group]
if (!items || items.length === 0) return null
return (
<div key={group} className="session-group">
<div className="session-group-label">{hasRuntimeSessions ? `${group} Runtime Sessions` : group}</div>
{items.map(node => {
const hasChildren = node.children.length > 0
const isCollapsed = collapsed.has(node.session.taskId)
return (
<div key={node.session.taskId} className="session-tree-node">
<div className="session-tree-primary">
{hasChildren && (
<button
className={`session-expand-btn${isCollapsed ? ' collapsed' : ''}`}
onClick={e => {
e.stopPropagation()
toggleCollapse(node.session.taskId)
}}
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
/>
)}
<SessionItem
session={node.session}
isActive={node.session.taskId === activeSessionId}
unreadCount={unreadCounts?.[node.session.channelId]}
onSelect={() => onSelect(node.session.taskId)}
onDelete={() => onDeleteSession(node.session.taskId)}
/>
</div>
{hasChildren && !isCollapsed && (
<div className="session-children">
{node.children.map((child, idx) => (
<SessionItem
key={child.taskId}
session={child}
isActive={child.taskId === activeSessionId}
isChild
isLast={idx === node.children.length - 1}
unreadCount={unreadCounts?.[child.channelId]}
onSelect={() => onSelect(child.taskId)}
onDelete={() => onDeleteSession(child.taskId)}
/>
))}
</div>
)}
{hasChildren && isCollapsed && (
<button
className="session-child-count"
onClick={() => toggleCollapse(node.session.taskId)}
>
{node.children.length} sub-task{node.children.length > 1 ? 's' : ''}
</button>
)}
</div>
)
})}
</div>
)
})}
{filtered.length === 0 && (
<div className="session-empty">No sessions yet</div>
)}
</div>
<button
className={`session-nav-btn session-activity-btn${activeSessionId === null && activeChannel !== secretaryChannelId ? ' active' : ''}`}
onClick={() => onSelect(null)}
>
<IconActivity />
<span>Activity</span>
</button>
</div>
)
}
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { StaffingSelectionPanel } from './StaffingSelectionPanel'
const markup = renderToStaticMarkup(
React.createElement(StaffingSelectionPanel, {
meta: {
checkpoint_type: 'company_staffing_selection',
checkpoint_id: 'cp-staffing',
company_profile: 'corporate',
summary: 'Select staff manually, or run automatic recruitment.',
staffing_roles: [
{
role_id: 'senior_engineer',
role_label: 'Senior Engineer',
default_selection: { kind: 'employee', id: 'senior-existing' },
default_agent: 'codex',
selected_agent: 'codex',
},
],
staffing_pool: {
employees: [
{
employee_id: 'senior-existing',
employee_name: 'Existing Engineer',
role_id: 'senior_engineer',
category: 'engineering',
},
],
templates: [
{
template_id: 'engineering-frontend-developer',
template_name: 'Frontend Developer',
category: 'engineering',
},
],
},
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(markup, /Manual Staffing/)
assert.match(markup, /Existing Engineer/)
assert.match(markup, /Frontend Developer/)
assert.match(markup, /Approve/)
assert.match(markup, /Auto Recruit/)
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'StaffingSelectionPanel.tsx'), 'utf8')
assert.match(src, /staffing_action: action/, 'panel replies must send structured staffing_action metadata')
assert.match(src, /staffing_selections: selections/, 'panel replies must send structured staffing selections')
assert.match(src, /recruitment_agent: recruitmentAgent/, 'panel replies must send the selected recruiter agent')
assert.match(src, /hasSubmittedCheckpointMetadata/, 'responded staffing cards must detect persisted reply metadata')
assert.match(src, /setRoleAgents\(buildRoleAgentsFromMeta\(meta, roles\)\)/, 'responded staffing cards must sync displayed agent choices from reply metadata')
console.log('StaffingSelectionPanel.test.tsx: OK (manual staffing panel renders structured choices)')
@@ -0,0 +1,336 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import type {
CheckpointReplyMetadata,
ChatMessageMeta,
StaffingEmployeeOption,
StaffingRoleEntry,
StaffingSelectionValue,
StaffingTemplateOption,
} from '../types/chat'
import type { TaskPreferredAgent } from '../types/kanban'
const TASK_AGENT_LABELS: Record<TaskPreferredAgent, string> = {
native: 'OpenOPC Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
const DEFAULT_ROLE_AGENT: TaskPreferredAgent = 'codex'
const DEFAULT_RECRUITMENT_AGENT: TaskPreferredAgent = 'native'
const TASK_AGENT_OPTIONS: TaskPreferredAgent[] = ['codex', 'native', 'claude_code', 'cursor', 'opencode']
const RECRUITMENT_AGENT_OPTIONS: TaskPreferredAgent[] = ['native', 'codex', 'claude_code', 'cursor', 'opencode']
type StaffingOption =
| { kind: 'employee'; id: string; name: string; subtitle: string; category: string; searchText: string }
| { kind: 'template'; id: string; name: string; subtitle: string; category: string; searchText: string }
| { kind: 'fallback'; id: ''; name: string; subtitle: string; category: string; searchText: string }
interface StaffingSelectionPanelProps {
meta: ChatMessageMeta
onReply: (text: string, metadata?: CheckpointReplyMetadata) => void
responded: boolean
}
function normalizeSelection(value: StaffingSelectionValue | undefined): StaffingSelectionValue {
if (!value) return { kind: 'fallback' }
if (value.kind === 'employee') {
const id = String(value.id ?? value.employee_id ?? '').trim()
return id ? { kind: 'employee', id } : { kind: 'fallback' }
}
if (value.kind === 'template') {
const id = String(value.id ?? value.template_id ?? '').trim()
return id ? { kind: 'template', id } : { kind: 'fallback' }
}
return { kind: 'fallback' }
}
function selectionKey(value: StaffingSelectionValue | undefined): string {
const normalized = normalizeSelection(value)
return normalized.kind === 'fallback' ? 'fallback:' : `${normalized.kind}:${normalized.id ?? ''}`
}
function buildOptions(
role: StaffingRoleEntry,
employees: StaffingEmployeeOption[],
templates: StaffingTemplateOption[],
): StaffingOption[] {
const roleId = String(role.role_id ?? '').trim()
const roleLabel = String(role.role_label ?? '').trim()
const roleText = `${roleId} ${roleLabel} ${role.role_responsibility ?? ''}`.toLowerCase()
const sameRoleIds = new Set((role.same_role_employee_ids ?? []).map(item => String(item ?? '').trim()).filter(Boolean))
const templateScore = (template: StaffingTemplateOption): number => {
const templateText = `${template.template_id ?? ''} ${template.template_name ?? ''} ${template.category ?? ''} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase()
const categoryTerm = String(template.category ?? '').toLowerCase()
let score = 0
for (const token of roleText.split(/[^a-z0-9]+/).filter(token => token.length >= 3)) {
if (templateText.includes(token) || (categoryTerm && token.includes(categoryTerm))) score += 1
}
if (templateText.includes(roleId.replace(/_/g, '-')) || templateText.includes(roleId.replace(/_/g, ' '))) score += 2
return score
}
const employeeOptions = employees.map((employee): StaffingOption & { rank: number } => {
const id = String(employee.employee_id ?? '').trim()
const name = String(employee.employee_name ?? id).trim() || id
const employeeRole = String(employee.role_id ?? '').trim()
const category = String(employee.category ?? '').trim()
const subtitle = [employeeRole, category].filter(Boolean).join(' · ') || id
return {
kind: 'employee',
id,
name,
subtitle,
category,
searchText: `${id} ${name} ${employeeRole} ${category} ${(employee.domains ?? []).join(' ')} ${(employee.tags ?? []).join(' ')}`.toLowerCase(),
rank: sameRoleIds.has(id) || employeeRole === roleId ? 0 : 2,
}
}).filter(option => option.id).sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
const templateOptions = templates.map((template): StaffingOption & { rank: number } => {
const id = String(template.template_id ?? '').trim()
const name = String(template.template_name ?? id).trim() || id
const category = String(template.category ?? '').trim()
const subtitle = [category, id].filter(Boolean).join(' · ') || id
return {
kind: 'template',
id,
name,
subtitle,
category,
searchText: `${id} ${name} ${category} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase(),
rank: templateScore(template),
}
}).filter(option => option.id).sort((a, b) => b.rank - a.rank || a.name.localeCompare(b.name))
return [
...employeeOptions,
...templateOptions,
{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' },
]
}
function optionForSelection(options: StaffingOption[], selection: StaffingSelectionValue | undefined): StaffingOption {
const key = selectionKey(selection)
return options.find(option => `${option.kind}:${option.id}` === key) ?? options[0]
}
function optionMatches(option: StaffingOption, query: string): boolean {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return true
return terms.every(term => option.searchText.includes(term))
}
function buildSelectionsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, StaffingSelectionValue> {
const initial: Record<string, StaffingSelectionValue> = {}
const persisted = meta.staffing_selections ?? {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) continue
initial[roleId] = normalizeSelection(persisted[roleId] ?? role.default_selection)
}
return initial
}
function buildRoleAgentsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, TaskPreferredAgent> {
const persisted = meta.recruitment_role_agents ?? {}
const initial: Record<string, TaskPreferredAgent> = {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) continue
initial[roleId] = persisted[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
}
return initial
}
function hasSubmittedCheckpointMetadata(meta: ChatMessageMeta): boolean {
return Boolean(
String(meta.checkpoint_response_message_id ?? '').trim()
|| String(meta.checkpoint_responded_at ?? '').trim()
|| String(meta.staffing_action ?? '').trim()
)
}
export const StaffingSelectionPanel = React.memo(function StaffingSelectionPanel({
meta, onReply, responded,
}: StaffingSelectionPanelProps) {
const roles = useMemo(() => meta.staffing_roles ?? [], [meta.staffing_roles])
const employees = meta.staffing_pool?.employees ?? []
const templates = meta.staffing_pool?.templates ?? []
const optionsByRole = useMemo(() => {
const next: Record<string, StaffingOption[]> = {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (roleId) next[roleId] = buildOptions(role, employees, templates)
}
return next
}, [employees, roles, templates])
const [queries, setQueries] = useState<Record<string, string>>({})
const [selections, setSelections] = useState<Record<string, StaffingSelectionValue>>(() => buildSelectionsFromMeta(meta, roles))
const [roleAgents, setRoleAgents] = useState<Record<string, TaskPreferredAgent>>(() => buildRoleAgentsFromMeta(meta, roles))
const [recruitmentAgent, setRecruitmentAgent] = useState<TaskPreferredAgent>(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
const isResponded = responded
const recommendAutoRecruit = meta.recommended_action === 'auto_recruit' && templates.length > 0
useEffect(() => {
if (!isResponded || !hasSubmittedCheckpointMetadata(meta)) return
setSelections(buildSelectionsFromMeta(meta, roles))
setRoleAgents(buildRoleAgentsFromMeta(meta, roles))
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
}, [isResponded, meta, roles])
useEffect(() => {
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
}, [meta.recruitment_agent])
const buildReplyMetadata = useCallback((action: 'manual_approve' | 'auto_recruit'): CheckpointReplyMetadata => {
const checkpointId = String(meta.checkpoint_id ?? '').trim()
if (!checkpointId) {
throw new Error('Staffing checkpoint reply requires checkpoint_id metadata.')
}
return {
response_to_checkpoint_id: checkpointId,
response_to_checkpoint_type: 'company_staffing_selection',
staffing_action: action,
staffing_selections: selections,
recruitment_agent: recruitmentAgent,
recruitment_role_agents: roles.reduce<Record<string, TaskPreferredAgent>>((acc, role) => {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) return acc
acc[roleId] = roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
return acc
}, {}),
}
}, [meta.checkpoint_id, recruitmentAgent, roleAgents, roles, selections])
const handleApprove = useCallback(() => {
if (isResponded) return
onReply('approve', buildReplyMetadata('manual_approve'))
}, [buildReplyMetadata, isResponded, onReply])
const handleAutoRecruit = useCallback(() => {
if (isResponded) return
onReply('auto recruit', buildReplyMetadata('auto_recruit'))
}, [buildReplyMetadata, isResponded, onReply])
return (
<div className="ckpt-panel ckpt-staffing">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-staffing">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<path d="M1.5 14c.6-2.5 2.2-4 4.5-4s3.9 1.5 4.5 4" />
<path d="M12.5 3.5v5M10 6h5" />
</svg>
</div>
<div className="ckpt-title">Manual Staffing</div>
<span className="ckpt-badge ckpt-badge-profile">{meta.company_profile || 'corporate'}</span>
{recommendAutoRecruit && !isResponded && <span className="ckpt-badge ckpt-badge-scope">Recruit recommended</span>}
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
</div>
{meta.summary && <div className="ckpt-summary">{meta.summary}</div>}
<div className="ckpt-recruiter-agent">
<label className="ckpt-agent-label" htmlFor={`staffing-recruiter-agent-${meta.checkpoint_id}`}>
Recruiter Agent
</label>
<select
id={`staffing-recruiter-agent-${meta.checkpoint_id}`}
className="ckpt-agent-select"
value={recruitmentAgent}
onChange={event => setRecruitmentAgent(event.target.value as TaskPreferredAgent)}
disabled={isResponded}
>
{RECRUITMENT_AGENT_OPTIONS.map(agent => (
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
))}
</select>
</div>
<div className="ckpt-staffing-grid">
{roles.map(role => {
const roleId = String(role.role_id ?? '').trim()
const options = optionsByRole[roleId] ?? [{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' }]
const selected = optionForSelection(options, selections[roleId])
const query = queries[roleId] ?? ''
const visibleOptions = options.filter(option => optionMatches(option, query)).slice(0, 8)
return (
<div key={roleId} className="ckpt-staffing-card">
<div className="ckpt-proposal-header">
<span className="ckpt-role-name">{roleId}</span>
<span className={`ckpt-badge ckpt-badge-${selected.kind}`}>{selected.kind}</span>
</div>
{role.role_label && role.role_label !== roleId && (
<div className="ckpt-role-labels">
<span className="ckpt-field-tag">{role.role_label}</span>
</div>
)}
<div className="ckpt-staffing-selected">
<div className="ckpt-cand-name">{selected.name}</div>
<div className="ckpt-cand-meta">
<span className="ckpt-cand-category">{selected.category}</span>
<span className="ckpt-domain-tag">{selected.subtitle}</span>
</div>
</div>
<input
className="ckpt-staffing-search"
value={query}
onChange={event => setQueries(current => ({ ...current, [roleId]: event.target.value }))}
placeholder="Search employees or templates..."
disabled={isResponded}
/>
<div className="ckpt-staffing-options">
{visibleOptions.map(option => {
const active = `${option.kind}:${option.id}` === selectionKey(selections[roleId])
return (
<button
key={`${option.kind}:${option.id}`}
className={`ckpt-staffing-option${active ? ' active' : ''}`}
onClick={() => setSelections(current => ({ ...current, [roleId]: { kind: option.kind, id: option.id } }))}
disabled={isResponded}
title={option.subtitle}
>
<span className="ckpt-staffing-option-kind">{option.kind}</span>
<span className="ckpt-staffing-option-name">{option.name}</span>
</button>
)
})}
</div>
<div className="ckpt-agent-picker">
<label className="ckpt-agent-label" htmlFor={`staffing-agent-${meta.checkpoint_id}-${roleId}`}>
Execution Agent
</label>
<select
id={`staffing-agent-${meta.checkpoint_id}-${roleId}`}
className="ckpt-agent-select"
value={roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT}
onChange={event => setRoleAgents(current => ({ ...current, [roleId]: event.target.value as TaskPreferredAgent }))}
disabled={isResponded}
>
{TASK_AGENT_OPTIONS.map(agent => (
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
))}
</select>
</div>
</div>
)
})}
</div>
{!isResponded && (
<div className="ckpt-actions">
{recommendAutoRecruit ? (
<>
<button className="ckpt-btn ckpt-btn-approve" onClick={handleAutoRecruit}>Auto Recruit</button>
<button className="ckpt-btn ckpt-btn-feedback" onClick={handleApprove}>Approve Selections</button>
</>
) : (
<>
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove}>Approve Selections</button>
{templates.length > 0 && <button className="ckpt-btn ckpt-btn-feedback" onClick={handleAutoRecruit}>Auto Recruit</button>}
</>
)}
</div>
)}
</div>
)
})
@@ -0,0 +1,390 @@
/**
* SVG icon set — geometry derived from Lucide (https://lucide.dev), MIT.
*
* We inline the paths instead of pulling in `lucide-react` to keep the
* production bundle tight. The icons follow Lucide conventions:
* - 24×24 design grid
* - 2px stroke (1.5 for very small chrome icons)
* - round caps & joins
* - currentColor everywhere so CSS controls tone
*
* When adding a new icon, copy the geometry from the official Lucide
* source (https://github.com/lucide-icons/lucide/tree/main/icons) so the
* whole set stays visually consistent.
*/
import React from 'react'
export function IconBrain() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C9.5 2 7.5 3.5 7 5.5C5 5.5 3 7.5 3 10C3 12 4.5 13.5 6 14V20C6 21.1 6.9 22 8 22H16C17.1 22 18 21.1 18 20V14C19.5 13.5 21 12 21 10C21 7.5 19 5.5 17 5.5C16.5 3.5 14.5 2 12 2Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 2V8M8 6H16M12 14V18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconTool() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94L6.73 20.15a2.12 2.12 0 0 1-3-3l6.72-6.72a6 6 0 0 1 7.94-7.94L14.7 6.3z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconStop() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<rect x="4" y="4" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.5" />
<rect x="8" y="8" width="8" height="8" rx="1.5" fill="currentColor" />
</svg>
)
}
export function IconSend() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M5 12L3 3L21 12L3 21L5 12ZM5 12H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconCopy() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<rect x="9" y="9" width="13" height="13" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconCheck() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
/**
* Welcome-state chat icon. Lucide `message-square-text`: a squared bubble
* with two transcript lines, which reads as "a real conversation" much
* better than a generic empty bubble at large sizes.
*/
export function IconChat() {
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M13 8H7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M17 12H7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
export function IconPaperclip() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.49" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconChevron({ down }: { down?: boolean }) {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ transition: 'transform 200ms', transform: down ? 'rotate(90deg)' : 'none' }}>
<path d="M9 18L15 12L9 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconPlus() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 5V19M5 12H19" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconSearch() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="1.5" />
<path d="M21 21L16.65 16.65" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconTrash() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 6H5H21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6L18.13 20.11A2 2 0 0 1 16.14 22H7.86a2 2 0 0 1-2-1.89L5 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconBoard() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="3" y="16" width="7" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
</svg>
)
}
export function IconActivity() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M22 12H18L15 21L9 3L6 12H2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconShield() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
/**
* Agent-avatar sparkle. Lucide `sparkles`: one large 4-point burst plus
* two small accent sparks — reads as "AI/magic" without the generic
* single-star look.
*/
export function IconSparkle() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M20 3v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M22 5h-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M4 17v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M5 18H3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
/** Lucide `arrow-right` — two crisp strokes, no diagonal artifacts. */
export function IconArrowRight() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M5 12h14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
<path d="m12 5 7 7-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconGate() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M9 11L12 14L22 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 12V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconZap() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
// ── Work Item / Runtime Panel icons ──────────────────────────────────────
export function IconWorkItem() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M4 18H10V14H4V18Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 14H14V10H8V14Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 10H18V6H12V10Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18 8L21 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconGatePass() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9 12L11 14L15 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconGateReject() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15 9L9 15M9 9L15 15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconClock() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" />
<path d="M12 6V12L16 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconHandoff() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M7 17L2 12L7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M17 7L22 12L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 12H22" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconTimeline() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2V22" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="12" cy="6" r="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="12" cy="12" r="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="12" cy="18" r="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M14 6H20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M14 12H18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M14 18H20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconClose() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
// ── Mode / lock icons (shared by the composer mode chip) ─────────────────
/**
* Lucide `lock` — the classic shackle + body. Size defaults to 12px so it
* tucks nicely inside a chip; pass a size prop to scale.
*/
export function IconLock({ size = 12 }: { size?: number } = {}) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 11V7a5 5 0 0 1 10 0v4"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** Lucide `user-round` — proportional head + shoulders, perfect for Task mode. */
export function IconUserRound({ size = 14 }: { size?: number } = {}) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle
cx="12"
cy="8"
r="5"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M20 21a8 8 0 0 0-16 0"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** Lucide `building-2` — multi-story office building, reads as "company". */
export function IconBuilding({ size = 14 }: { size?: number } = {}) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path
d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M10 6h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M10 10h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M10 14h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M10 18h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
/**
* Compact 3-spark "Sparkles" for the Org mode tile. We reuse the full
* `sparkles` geometry but at a smaller default size — same family, less
* visual weight than the agent avatar usage.
*/
export function IconSparkles({ size = 14 }: { size?: number } = {}) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M20 3v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M22 5h-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M4 17v2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M5 18H3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { TaskHeaderBar } from './TaskHeaderBar'
import type { Session } from '../types/kanban'
import type { AgentInfo } from '../types/visual'
const now = Date.now()
const nexusAgent: AgentInfo = {
agent_id: 'nexus',
name: 'NEXUS Executive Brief',
description: 'Executive briefing agent',
specialties: [],
status: 'idle',
appearance: { palette: 0, hue_shift: 0, seat_zone: 'north' },
}
function makeSession(overrides: Partial<Session>): Session {
return {
projectId: 'project-a',
taskId: 'task-a',
channelId: 'channel-a',
execMode: 'task',
title: 'NEXUS Executive Brief',
status: 'running',
columnId: 'in-progress',
assigneeIds: ['nexus'],
priority: null,
tags: [],
progressLog: [],
createdAt: now - 60_000,
updatedAt: now,
messageCount: 1,
...overrides,
}
}
const companyMarkup = renderToStaticMarkup(
React.createElement(TaskHeaderBar, {
session: makeSession({
execMode: 'org',
isCompanyRuntime: true,
workItemRoleName: 'Chief Analyst',
employeeAssignment: { name: 'NEXUS Executive Brief', employeeId: 'employee-nexus' },
selectedExecutionAgent: 'codex',
displayTool: 'opc-collab delegate_work',
currentTool: undefined,
}),
agents: [nexusAgent],
onTitleChange: () => undefined,
}),
)
assert.match(companyMarkup, /Chief Analyst/, 'company header should keep the role pill')
assert.match(companyMarkup, /NEXUS Executive Brief/, 'company header should keep the employee label')
assert.match(companyMarkup, /Codex/, 'company header should keep the execution agent label')
assert.match(companyMarkup, /opc-collab delegate_work/, 'company header should show stable displayTool while still running')
assert.doesNotMatch(
companyMarkup,
/task-header-avatar/,
'company header must not render a duplicate assignee initial avatar',
)
const taskMarkup = renderToStaticMarkup(
React.createElement(TaskHeaderBar, {
session: makeSession({ execMode: 'task' }),
agents: [nexusAgent],
onTitleChange: () => undefined,
}),
)
assert.match(
taskMarkup,
/task-header-avatar/,
'plain task headers should keep assignee initial avatars',
)
console.log('TaskHeaderBar.test.tsx: OK (company header hides duplicate avatar and keeps stable tool label)')
@@ -0,0 +1,265 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import type { Session } from '../types/kanban'
import type { AgentInfo } from '../types/visual'
import { IconStop, IconBoard, IconTool, IconCheck } from './SvgIcons'
import { getSessionRuntimeStatus, isSessionWorking } from '../lib/sessionRuntime'
import { getWorkItemRoleLabel } from '../lib/workItemIdentity'
interface TaskHeaderBarProps {
session: Session
agents: AgentInfo[]
onTitleChange: (taskId: string, title: string) => void
onViewOnBoard?: () => void
onStop?: () => void
onComplete?: () => void
onResume?: () => void
}
function relativeTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60_000) return 'just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
return `${Math.floor(diff / 86_400_000)}d ago`
}
const STATUS_META: Record<string, { color: string; label: string }> = {
running: { color: 'var(--green)', label: 'Running' },
idle: { color: 'var(--accent)', label: 'Idle' },
done: { color: 'var(--green)', label: 'Done' },
pending: { color: 'var(--text-secondary)', label: 'Pending' },
failed: { color: 'var(--red)', label: 'Failed' },
cancelled: { color: 'var(--text-secondary)', label: 'Cancelled' },
blocked: { color: 'var(--yellow)', label: 'Blocked' },
awaiting_human: { color: 'var(--yellow)', label: 'Waiting for review' },
awaiting_manager_review: { color: 'var(--yellow)', label: 'Manager review' },
awaiting_review: { color: 'var(--yellow)', label: 'Waiting for review' },
awaiting_peer: { color: 'var(--yellow)', label: 'Waiting for peer' },
}
const HUMAN_REVIEW_STATUSES = new Set([
'awaiting_human',
'awaiting_manager_review',
'awaiting_review',
'awaiting_peer',
])
const EXECUTION_AGENT_LABELS: Record<string, string> = {
native: 'Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
export function TaskHeaderBar({ session, agents, onTitleChange, onViewOnBoard, onStop, onComplete, onResume }: TaskHeaderBarProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(session.title)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!editing) setDraft(session.title)
}, [session.title, editing])
const commitTitle = useCallback(() => {
const trimmed = draft.trim()
if (trimmed && trimmed !== session.title) {
onTitleChange(session.taskId, trimmed)
} else {
setDraft(session.title)
}
setEditing(false)
}, [draft, session.title, session.taskId, onTitleChange])
const startEditing = useCallback(() => {
setDraft(session.title)
setEditing(true)
setTimeout(() => inputRef.current?.select(), 0)
}, [session.title])
const assignees = session.assigneeIds
.map(id => agents.find(a => a.agent_id === id))
.filter(Boolean) as AgentInfo[]
const execMode = String(session.execMode ?? '').trim().toLowerCase()
const isCompanyHeaderSession = execMode === 'company'
|| execMode === 'org'
|| execMode === 'custom'
|| !!session.isCompanyRuntime
|| !!session.workItemProjectionId
|| !!session.workItemRoleId
|| !!session.workItemRoleName
const showAssigneeAvatars = assignees.length > 0 && !isCompanyHeaderSession
const runtimeControlState = session.runtimeControlState ?? (session.status === 'running' ? 'running' : 'idle')
const isSuspending = runtimeControlState === 'suspending'
const isResuming = runtimeControlState === 'resuming'
const isSuspended = runtimeControlState === 'suspended'
const isRunning = session.status === 'running' && !isSuspending && !isSuspended && !isResuming
const isAwaitingReview = HUMAN_REVIEW_STATUSES.has(session.status)
const canStop = (session.canStop ?? session.status === 'running') && !isSuspending && !isSuspended && !isResuming
const canResume = (
session.canResume
?? (isSuspended || (!isAwaitingReview && !isRunning && session.status !== 'done' && session.status !== 'pending'))
) && !isSuspending && !isResuming
const meta = STATUS_META[session.status] ?? STATUS_META.pending
const statusLabel = isSuspending ? 'Stopping' : isSuspended ? 'Suspended' : isResuming ? 'Resuming' : meta.label
const roleLabel = getWorkItemRoleLabel(session)
const runtimeStatus = getSessionRuntimeStatus(session)
const isWorking = isSessionWorking(session)
const liveTool = session.displayTool || session.currentTool
// Sticky tool label tied to the RUN lifecycle (not the transient agentStatus).
// The native runtime reports an 'idle'/'reflecting' state with no current_tool
// between consecutive tool calls; reacting to that blanks the pill for a frame
// and makes the command flicker once per call. Instead, keep showing the last
// non-empty command for as long as the session is running, and drop it only
// when the run stops — so the pill holds steady and just swaps to the next tool.
const [stickyTool, setStickyTool] = useState<string | undefined>(liveTool || undefined)
useEffect(() => {
if (!isRunning) { setStickyTool(undefined); return }
if (liveTool) setStickyTool(liveTool)
}, [isRunning, liveTool])
const hasApprovalMetrics = typeof session.pendingPermissionCount === 'number' && session.pendingPermissionCount > 0
const showRuntimeMetrics = hasApprovalMetrics
const statusDotColor = isSuspending
? 'var(--yellow)'
: isSuspended
? 'var(--text-secondary)'
: runtimeStatus === 'tool_active'
? 'var(--green)'
: runtimeStatus === 'reflecting'
? 'var(--yellow)'
: meta.color
return (
<div className="task-header-shell">
<div className="task-header-bar">
<div className="task-header-left">
{editing ? (
<input
ref={inputRef}
className="task-title-input"
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={e => {
if (e.key === 'Enter') commitTitle()
if (e.key === 'Escape') { setDraft(session.title); setEditing(false) }
}}
/>
) : (
<span className="task-title" onClick={startEditing} title="Click to edit">
{session.title}
</span>
)}
<span className="task-status-pill" data-status={session.status} data-working={isWorking ? 'true' : 'false'}>
<span className="task-status-dot" style={{ background: statusDotColor }} />
{statusLabel}
</span>
{isRunning && stickyTool && (
<span className="task-tool-pill">
<IconTool />
<code>{stickyTool}</code>
{session.currentTool && typeof session.toolElapsedMs === 'number' && session.toolElapsedMs > 0 && (
<span>{session.toolElapsedMs}ms</span>
)}
</span>
)}
{session.lastToolSummary && (
<span className="task-projection-pill" title={session.lastToolSummary}>
{session.lastToolSummary.slice(0, 48)}
</span>
)}
{/* Projection id (e.g. "attention::seat::team::cto::cto::review::f42cf81f")
is an internal debug identifier — useful in the Info tab but
adds noise to the header bar. Surface it via title-tooltip on
the role pill rather than as a wide chip. */}
{roleLabel && (
<span className="task-role-pill" title={`Role: ${roleLabel}`}>
{roleLabel}
</span>
)}
{session.employeeAssignment?.name && (
<span className="task-employee-pill" title={`Employee: ${session.employeeAssignment.name}${session.employeeAssignment.category ? ` (${session.employeeAssignment.category})` : ''}`}>
<span className="task-employee-icon">&#x1F464;</span>
{session.employeeAssignment.name}
</span>
)}
{session.selectedExecutionAgent && (
<span
className="task-agent-pill"
title={`Execution Agent: ${EXECUTION_AGENT_LABELS[session.selectedExecutionAgent] ?? session.selectedExecutionAgent}`}
>
<span className="task-agent-icon">&#x2699;</span>
{EXECUTION_AGENT_LABELS[session.selectedExecutionAgent] ?? session.selectedExecutionAgent}
</span>
)}
</div>
<div className="task-header-right">
{showAssigneeAvatars && (
<div className="task-header-avatars">
{assignees.slice(0, 3).map(a => (
<span key={a.agent_id} className="task-header-avatar" title={a.name}>
{a.name.charAt(0).toUpperCase()}
</span>
))}
</div>
)}
<span className="task-header-time" title={new Date(session.createdAt).toLocaleString()}>
{relativeTime(session.createdAt)}
</span>
{(canStop || isSuspending) && onStop && (
<button className="task-stop-btn" onClick={onStop} title="Stop task" disabled={!canStop}>
<IconStop />
<span>{isSuspending ? 'Stopping...' : 'Stop'}</span>
</button>
)}
{canResume && onResume && session.status !== 'done' && (
<button
className="task-resume-btn"
onClick={onResume}
title="Resume prior runtime (re-awaken original team, no new plan)"
>
<span>Continue</span>
</button>
)}
{onComplete && (
<button className="task-done-btn" onClick={onComplete} title="Mark task as done">
<IconCheck />
<span>Done</span>
</button>
)}
{onViewOnBoard && (
<button className="task-board-btn" onClick={onViewOnBoard} title="View on Board">
<IconBoard />
</button>
)}
</div>
</div>
{showRuntimeMetrics && (
<div className="task-header-metrics">
{hasApprovalMetrics && (
<div className="task-runtime-metric task-runtime-metric-approval" title="Pending approvals">
<span className="task-runtime-metric-label">Approvals</span>
<span className="task-runtime-metric-value">{session.pendingPermissionCount}</span>
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { TaskUserInputPanel } from './TaskUserInputPanel'
const markup = renderToStaticMarkup(
React.createElement(TaskUserInputPanel, {
meta: {
checkpoint_type: 'task_user_input',
checkpoint_id: 'cp-input',
task_id: 'task-1',
work_item_projection_title: 'Engineer Input',
summary: 'Need one missing decision.',
prompt: 'Please answer:\n\n- Which provider?\n- Which tier?\n\n```txt\nstripe\n```',
questions: ['Which provider should be used?'],
required_fields: ['provider'],
context_note: 'Known context:\n\n- User wants checkout',
requesting_role_id: 'engineer',
requesting_task_id: 'task-1',
requesting_work_item_id: 'work-item-1',
seat_id: 'seat::team::engineering::engineer',
active_subagents: [{ id: 'sub-1' }],
permission_requests: [{ id: 'perm-1' }],
worktree_path: '/tmp/work',
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(markup, /Engineer Input/)
assert.match(markup, /<li>Which provider\?<\/li>/)
assert.match(markup, /<code class="language-txt">/)
assert.match(markup, /<summary>Runtime State<\/summary>/)
assert.match(markup, /Requester: <code>engineer<\/code>/)
assert.match(markup, /Work item: <code>work-item-1<\/code>/)
assert.match(markup, /Active subagents: 1/)
const choiceMarkup = renderToStaticMarkup(
React.createElement(TaskUserInputPanel, {
meta: {
checkpoint_type: 'task_user_input',
checkpoint_id: 'cp-choice',
task_id: 'task-2',
work_item_projection_title: 'Deployment Input',
summary: 'Need a deployment decision.',
prompt: 'Choose a region before continuing.',
questions: ['Which deployment region should be used?'],
input_questions: [
{
id: 'deployment_region',
header: 'Deployment region',
question: 'Which deployment region should I target?\n\n- Pick one if there is a clear preference.',
options: [
{ id: 'a', label: 'US East', description: 'Use us-east-1' },
{ id: 'b', label: 'EU West', description: 'Use eu-west-1' },
{ id: 'c', label: 'Asia', description: 'Use ap-east-1' },
],
allow_freeform: true,
required: true,
},
],
required_fields: ['deployment_region'],
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(choiceMarkup, /Deployment region/)
assert.match(choiceMarkup, /ckpt-choice-option/)
assert.match(choiceMarkup, /US East/)
assert.match(choiceMarkup, /EU West/)
assert.match(choiceMarkup, /Asia/)
assert.match(choiceMarkup, /Other/)
assert.match(choiceMarkup, /disabled=""/)
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'TaskUserInputPanel.tsx'), 'utf8')
assert.doesNotMatch(src, /localResponded|setLocalResponded/, 'panel must wait for server checkpoint metadata before showing responded state')
assert.match(src, /user_input_answers/, 'structured answers must be forwarded to the backend')
const messageListSrc = readFileSync(join(here, 'MessageList.tsx'), 'utf8')
const progressIndex = messageListSrc.indexOf("items.push({ kind: 'progress-block' })")
const pendingIndex = messageListSrc.indexOf("items.push({ kind: 'pending-section' })")
const endIndex = messageListSrc.indexOf("items.push({ kind: 'end-anchor' })")
assert.ok(progressIndex !== -1 && pendingIndex !== -1 && endIndex !== -1)
assert.ok(progressIndex < pendingIndex, 'pending checkpoint cards should render after the progress block')
assert.ok(pendingIndex < endIndex, 'pending checkpoint cards should render before the end anchor')
console.log('TaskUserInputPanel.test.tsx: OK (markdown and choice checkpoint panel)')
@@ -0,0 +1,308 @@
import React, { useCallback, useMemo, useState } from 'react'
import type {
ChatMessageMeta,
CheckpointReplyMetadata,
TaskUserInputAnswer,
TaskUserInputQuestion,
} from '../types/chat'
import { MarkdownBody } from './MarkdownBody'
interface TaskUserInputPanelProps {
meta: ChatMessageMeta
onReply: (text: string, metadata?: Partial<CheckpointReplyMetadata>) => void
responded: boolean
}
interface QuestionState {
selectedOptionId?: string
freeformText: string
}
const OTHER_OPTION_ID = '__other__'
const OPTION_LETTERS = ['A', 'B', 'C']
function cleanText(value: unknown): string {
return String(value ?? '').trim()
}
function normalizeQuestion(raw: TaskUserInputQuestion, index: number): TaskUserInputQuestion | null {
const question = cleanText(raw.question)
const header = cleanText(raw.header)
if (!question && !header) return null
const options = (raw.options ?? [])
.slice(0, 3)
.map((option, optionIndex) => ({
id: cleanText(option.id) || String.fromCharCode(97 + optionIndex),
label: cleanText(option.label),
description: cleanText(option.description),
}))
.filter((option) => option.label)
return {
id: cleanText(raw.id) || `question_${index + 1}`,
header,
question: question || header,
options,
allow_freeform: raw.allow_freeform !== false,
required: raw.required !== false,
}
}
export const TaskUserInputPanel = React.memo(function TaskUserInputPanel({
meta, onReply, responded,
}: TaskUserInputPanelProps) {
const [reply, setReply] = useState('')
const [answers, setAnswers] = useState<Record<string, QuestionState>>({})
const isResponded = responded
const title = String(meta.work_item_projection_title ?? meta.work_item_projection_id ?? 'Input Needed').trim() || 'Input Needed'
const summary = String(meta.summary ?? '').trim()
const prompt = String(meta.prompt ?? '').trim()
const contextNote = String(meta.context_note ?? '').trim()
const resumeHint = String(meta.resume_hint ?? '').trim()
const questions = useMemo(
() => (meta.questions ?? []).map((item) => String(item).trim()).filter(Boolean),
[meta.questions],
)
const inputQuestions = useMemo(
() => (meta.input_questions ?? [])
.map((item, index) => normalizeQuestion(item, index))
.filter((item): item is TaskUserInputQuestion => item !== null),
[meta.input_questions],
)
const usesChoiceMode = inputQuestions.some((question) => (question.options ?? []).length > 0)
const requiredFields = useMemo(
() => (meta.required_fields ?? []).map((item) => String(item).trim()).filter(Boolean),
[meta.required_fields],
)
const activeSubagents = useMemo(
() => (meta.active_subagents ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.active_subagents],
)
const permissionRequests = useMemo(
() => (meta.permission_requests ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.permission_requests],
)
const worktreePath = String(meta.worktree_path ?? '').trim()
const requestingRoleId = String(meta.requesting_role_id ?? '').trim()
const requestingTaskId = String(meta.requesting_task_id ?? '').trim()
const requestingWorkItemId = String(meta.requesting_work_item_id ?? '').trim()
const seatId = String(meta.seat_id ?? '').trim()
const hasRequesterState = !!requestingRoleId || !!requestingTaskId || !!requestingWorkItemId || !!seatId
const hasRuntimeState = hasRequesterState || activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath
const setSelectedOption = useCallback((questionId: string, optionId: string) => {
setAnswers((current) => ({
...current,
[questionId]: {
freeformText: current[questionId]?.freeformText ?? '',
selectedOptionId: optionId,
},
}))
}, [])
const setFreeformAnswer = useCallback((questionId: string, value: string) => {
setAnswers((current) => ({
...current,
[questionId]: {
selectedOptionId: current[questionId]?.selectedOptionId,
freeformText: value,
},
}))
}, [])
const questionComplete = useCallback((question: TaskUserInputQuestion) => {
if (question.required === false) return true
const state = answers[question.id]
const selected = state?.selectedOptionId
if (selected && selected !== OTHER_OPTION_ID) return true
if (question.allow_freeform !== false && cleanText(state?.freeformText)) return true
return false
}, [answers])
const canSubmitStructured = usesChoiceMode && inputQuestions.every(questionComplete)
const handleSubmitLegacy = useCallback(() => {
const text = reply.trim()
if (isResponded || !text) return
onReply(text)
}, [isResponded, onReply, reply])
const handleSubmitStructured = useCallback(() => {
if (isResponded || !canSubmitStructured) return
const answerMetadata: Record<string, TaskUserInputAnswer> = {}
const lines: string[] = []
inputQuestions.forEach((question) => {
const state = answers[question.id] ?? { freeformText: '' }
const selectedOption = (question.options ?? []).find((option) => option.id === state.selectedOptionId)
const freeformText = cleanText(state.freeformText)
const label = selectedOption?.label ?? ''
const answerText = [label, freeformText].filter(Boolean).join('; ')
answerMetadata[question.id] = {
question_id: question.id,
question: question.question,
...(selectedOption ? {
selected_option_id: selectedOption.id,
selected_label: selectedOption.label,
} : {}),
...(freeformText ? { freeform_text: freeformText } : {}),
answer_text: answerText,
}
const displayQuestion = cleanText(question.header) || cleanText(question.question) || question.id
lines.push(`- ${displayQuestion}: ${answerText || '(no answer)'}`)
})
onReply(lines.join('\n'), { user_input_answers: answerMetadata })
}, [answers, canSubmitStructured, inputQuestions, isResponded, onReply])
return (
<div className="ckpt-panel ckpt-user-input">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-user-input">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3.5h10v6H6.5L3 13V3.5Z" />
<path d="M5.5 6h5" />
<path d="M5.5 8h3.5" />
</svg>
</div>
<div className="ckpt-title">{title}</div>
<span className="ckpt-badge ckpt-badge-scope">awaiting input</span>
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
</div>
{summary && (
<div className="ckpt-section">
<div className="ckpt-section-title">Summary</div>
<MarkdownBody content={summary} className="ckpt-markdown" />
</div>
)}
{prompt && (
<div className="ckpt-section">
<div className="ckpt-section-title">Request</div>
<MarkdownBody content={prompt} className="ckpt-markdown" />
</div>
)}
{usesChoiceMode ? (
<div className="ckpt-section">
<div className="ckpt-section-title">Questions</div>
<div className="ckpt-choice-list">
{inputQuestions.map((question) => {
const state = answers[question.id] ?? { freeformText: '' }
const options = question.options ?? []
const showOther = question.allow_freeform !== false
const showOtherInput = showOther && (options.length === 0 || state.selectedOptionId === OTHER_OPTION_ID)
return (
<div className="ckpt-choice-question" key={question.id}>
{question.header && <div className="ckpt-question-header">{question.header}</div>}
<MarkdownBody content={question.question} className="ckpt-markdown" />
{options.length > 0 && (
<div className="ckpt-choice-grid">
{options.map((option, optionIndex) => {
const selected = state.selectedOptionId === option.id
return (
<button
key={option.id}
className={`ckpt-choice-option${selected ? ' is-selected' : ''}`}
onClick={() => setSelectedOption(question.id, option.id)}
>
<span className="ckpt-choice-letter">{OPTION_LETTERS[optionIndex] ?? '?'}</span>
<span className="ckpt-choice-copy">
<span className="ckpt-choice-label">{option.label}</span>
{option.description && <span className="ckpt-choice-desc">{option.description}</span>}
</span>
</button>
)
})}
{showOther && (
<button
className={`ckpt-choice-option${state.selectedOptionId === OTHER_OPTION_ID ? ' is-selected' : ''}`}
onClick={() => setSelectedOption(question.id, OTHER_OPTION_ID)}
>
<span className="ckpt-choice-letter">D</span>
<span className="ckpt-choice-copy">
<span className="ckpt-choice-label">Other</span>
<span className="ckpt-choice-desc">Enter a custom answer</span>
</span>
</button>
)}
</div>
)}
{showOtherInput && (
<textarea
className="ckpt-feedback-input ckpt-other-field"
placeholder="Type your answer..."
value={state.freeformText}
onChange={(event) => setFreeformAnswer(question.id, event.target.value)}
rows={3}
/>
)}
</div>
)
})}
</div>
</div>
) : questions.length > 0 && (
<div className="ckpt-section">
<div className="ckpt-section-title">Questions</div>
<ul className="ckpt-task-list">
{questions.map((question) => <li key={question}>{question}</li>)}
</ul>
</div>
)}
{requiredFields.length > 0 && (
<div className="ckpt-section">
<div className="ckpt-section-title">Required Fields</div>
<div className="ckpt-task-tags">
{requiredFields.map((field) => <span key={field} className="ckpt-field-tag">{field}</span>)}
</div>
</div>
)}
{contextNote && (
<div className="ckpt-section">
<div className="ckpt-section-title">Context</div>
<MarkdownBody content={contextNote} className="ckpt-markdown ckpt-markdown-muted" />
</div>
)}
{resumeHint && <div className="ckpt-escalation-hint">{resumeHint}</div>}
{hasRuntimeState && (
<details className="ckpt-runtime-details">
<summary>Runtime State</summary>
<div className="ckpt-runtime-body">
{requestingRoleId && <div>Requester: <code>{requestingRoleId}</code></div>}
{requestingWorkItemId && <div>Work item: <code>{requestingWorkItemId}</code></div>}
{requestingTaskId && <div>Task: <code>{requestingTaskId}</code></div>}
{seatId && <div>Seat: <code>{seatId}</code></div>}
{worktreePath && <div>Worktree: <code>{worktreePath}</code></div>}
{activeSubagents.length > 0 && <div>Active subagents: {activeSubagents.length}</div>}
{permissionRequests.length > 0 && <div>Pending permission records: {permissionRequests.length}</div>}
</div>
</details>
)}
{!isResponded && (
<div className="ckpt-feedback-area">
{usesChoiceMode ? null : (
<textarea
className="ckpt-feedback-input"
placeholder="Reply with the missing input to continue..."
value={reply}
onChange={(e) => setReply(e.target.value)}
rows={3}
/>
)}
<div className="ckpt-feedback-btns">
<button
className="ckpt-btn ckpt-btn-approve"
onClick={usesChoiceMode ? handleSubmitStructured : handleSubmitLegacy}
disabled={usesChoiceMode ? !canSubmitStructured : !reply.trim()}
>
Send Reply
</button>
</div>
</div>
)}
</div>
)
})
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { WorkItemProgressCard } from './WorkItemProgressCard'
import type { RoleWorkItemSummary } from '../types/kanban'
const currentOwnerRoleWorkItems: Record<string, RoleWorkItemSummary> = {
cto: {
roleKey: 'cto',
roleId: 'cto',
roleName: 'CTO',
runtimeStatus: 'idle',
aggregatedStatus: 'waiting',
workItems: [
{
workItemId: 'wi-review',
phase: 'awaiting_manager_review',
kanbanColumn: 'in-review',
title: 'Implement summary',
kind: 'execute',
isReviewTarget: true,
executorRoleId: 'engineer',
reviewerRoleId: 'cto',
createdAt: 10,
updatedAt: 20,
executionTurnId: 'runtime-task-1',
progressLog: [],
},
],
},
}
const executorRoleWorkItems: Record<string, RoleWorkItemSummary> = {
engineer: {
roleKey: 'engineer',
roleId: 'engineer',
roleName: 'Engineer',
runtimeStatus: 'idle',
aggregatedStatus: 'waiting',
workItems: [
{
workItemId: 'wi-review',
phase: 'awaiting_manager_review',
kanbanColumn: 'in-review',
title: 'Implement summary',
kind: 'execute',
isReviewTarget: true,
executorRoleId: 'engineer',
reviewerRoleId: 'cto',
createdAt: 10,
updatedAt: 20,
executionTurnId: 'runtime-task-1',
progressLog: [],
},
],
},
}
const executorMarkup = renderToStaticMarkup(
React.createElement(WorkItemProgressCard, {
workItemLog: [],
roleWorkItems: currentOwnerRoleWorkItems,
executorRoleWorkItems,
isCompanyRuntime: true,
}),
)
assert.match(executorMarkup, /Execution Progress/)
assert.match(executorMarkup, /Engineer/)
assert.doesNotMatch(executorMarkup, /CTO/)
const fallbackMarkup = renderToStaticMarkup(
React.createElement(WorkItemProgressCard, {
workItemLog: [],
roleWorkItems: currentOwnerRoleWorkItems,
isCompanyRuntime: true,
}),
)
assert.match(fallbackMarkup, /CTO/)
assert.doesNotMatch(fallbackMarkup, /Engineer/)
console.log('WorkItemProgressCard.test.tsx: OK (executor rollup preferred with current-owner fallback)')
@@ -0,0 +1,576 @@
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])
if (isCompanyRuntime && roleSummaries.length === 0) return null
if (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>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict'
import type { ChatMessage } from '../types/chat'
import {
analyzeCheckpointMessages,
checkpointReplyMetadataForComposer,
isCheckpointCardMetadata,
isCheckpointType,
toCheckpointReplyMetadata,
} from './checkpointUtils'
const checkpoint: ChatMessage = {
id: 'msg-checkpoint',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'OPC',
content: 'Please review the delivery.',
timestamp: 1,
mentions: [],
metadata: {
checkpoint_type: 'company_delivery_feedback',
checkpoint_id: 'cp-delivery',
work_item_projection_title: 'CEO Delivery',
feedback_scope: 'final',
},
}
assert.equal(isCheckpointType('company_delivery_feedback'), true)
assert.equal(isCheckpointType('company_staffing_selection'), true)
assert.equal(isCheckpointCardMetadata(checkpoint.metadata), true)
assert.deepEqual(toCheckpointReplyMetadata(checkpoint.metadata), {
response_to_checkpoint_id: 'cp-delivery',
response_to_checkpoint_type: 'company_delivery_feedback',
response_to_escalation_id: undefined,
})
const pending = analyzeCheckpointMessages([checkpoint])
assert.deepEqual([...pending.pendingMessageIds], ['msg-checkpoint'])
assert.deepEqual(pending.latestPendingReplyMetadata, {
response_to_checkpoint_id: 'cp-delivery',
response_to_checkpoint_type: 'company_delivery_feedback',
response_to_escalation_id: undefined,
})
assert.equal(checkpointReplyMetadataForComposer(pending.latestPendingReplyMetadata), undefined)
const legacySelfEvolutionResult: ChatMessage = {
...checkpoint,
id: 'msg-self-evolution-result',
content: 'Self-evolution finished without writing updates because the agents did not return valid evolution patches.',
metadata: {
checkpoint_type: 'company_delivery_feedback',
checkpoint_id: 'cp-delivery',
kind: 'company_self_evolution_result',
self_evolution_completed: true,
},
}
assert.equal(isCheckpointCardMetadata(legacySelfEvolutionResult.metadata), false)
const ignoredSelfEvolutionResult = analyzeCheckpointMessages([legacySelfEvolutionResult])
assert.deepEqual([...ignoredSelfEvolutionResult.pendingMessageIds], [])
assert.deepEqual([...ignoredSelfEvolutionResult.respondedMessageIds], [])
assert.equal(checkpointReplyMetadataForComposer({
response_to_checkpoint_id: 'esc-approval',
response_to_checkpoint_type: 'human_escalation',
response_to_escalation_id: 'esc-approval',
}), undefined)
assert.deepEqual(checkpointReplyMetadataForComposer({
response_to_checkpoint_id: 'cp-staffing',
response_to_checkpoint_type: 'company_staffing_selection',
}), {
response_to_checkpoint_id: 'cp-staffing',
response_to_checkpoint_type: 'company_staffing_selection',
})
const duplicatePending = analyzeCheckpointMessages([
checkpoint,
{
...checkpoint,
id: 'msg-checkpoint-duplicate',
channelId: 'session:task-2',
timestamp: 2,
},
])
assert.deepEqual([...duplicatePending.pendingMessageIds], ['msg-checkpoint'])
assert.deepEqual([...duplicatePending.respondedMessageIds], [])
assert.deepEqual([...duplicatePending.duplicateMessageIds], ['msg-checkpoint-duplicate'])
const duplicateResponded = analyzeCheckpointMessages([
{
...checkpoint,
metadata: {
...checkpoint.metadata,
checkpoint_status: 'responded',
},
},
{
...checkpoint,
id: 'msg-checkpoint-duplicate',
channelId: 'session:task-2',
timestamp: 2,
metadata: {
...checkpoint.metadata,
checkpoint_status: 'responded',
},
},
])
assert.deepEqual([...duplicateResponded.respondedMessageIds], ['msg-checkpoint'])
assert.deepEqual([...duplicateResponded.duplicateMessageIds], ['msg-checkpoint-duplicate'])
const replyBeforeEngineResolution = analyzeCheckpointMessages([
checkpoint,
{
id: 'msg-user',
channelId: 'session:task-1',
sender: 'user',
senderName: 'You',
content: 'Please make one more change.',
timestamp: 2,
mentions: [],
metadata: {
response_to_checkpoint_id: 'cp-delivery',
response_to_checkpoint_type: 'company_delivery_feedback',
},
},
])
assert.deepEqual([...replyBeforeEngineResolution.respondedMessageIds], ['msg-checkpoint'])
assert.deepEqual([...replyBeforeEngineResolution.pendingMessageIds], [])
assert.equal(replyBeforeEngineResolution.latestPendingReplyMetadata, undefined)
const responded = analyzeCheckpointMessages([
{
...checkpoint,
metadata: {
...checkpoint.metadata,
checkpoint_status: 'responded',
checkpoint_response_message_id: 'msg-user',
},
},
])
assert.deepEqual([...responded.respondedMessageIds], ['msg-checkpoint'])
const expiredApproval: ChatMessage = {
id: 'msg-expired-approval',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'OPC',
content: 'Approve external_agent?',
timestamp: 3,
mentions: [],
metadata: {
checkpoint_type: 'human_escalation',
checkpoint_id: 'esc-expired',
escalation_id: 'esc-expired',
escalation_type: 'decision_needed',
prompt: 'Approve external_agent?',
options: [{ id: 'approve_once', label: 'Approve once' }],
checkpoint_status: 'timeout',
},
}
const staleApproval: ChatMessage = {
...expiredApproval,
id: 'msg-stale-approval',
metadata: {
...expiredApproval.metadata,
checkpoint_id: 'esc-stale',
escalation_id: 'esc-stale',
checkpoint_status: 'stale',
},
}
const supersededRecruitment: ChatMessage = {
...checkpoint,
id: 'msg-superseded-recruitment',
metadata: {
...checkpoint.metadata,
checkpoint_type: 'company_recruitment_confirmation',
checkpoint_id: 'cp-recruit-old',
checkpoint_status: 'superseded',
},
}
const ignoredDelivery: ChatMessage = {
...checkpoint,
id: 'msg-ignored-delivery',
metadata: {
...checkpoint.metadata,
checkpoint_status: 'ignored',
},
}
const terminal = analyzeCheckpointMessages([expiredApproval, staleApproval, supersededRecruitment, ignoredDelivery])
assert.deepEqual([...terminal.pendingMessageIds], [])
assert.deepEqual([...terminal.respondedMessageIds], ['msg-expired-approval', 'msg-stale-approval', 'msg-superseded-recruitment', 'msg-ignored-delivery'])
console.log('checkpointUtils.test.ts: OK (checkpoint pending/reply/terminal status handling)')
@@ -0,0 +1,161 @@
import type { ChatMessage, ChatMessageMeta, CheckpointReplyMetadata } from '../types/chat'
const CHECKPOINT_TYPES = new Set([
'company_work_item_gate',
'company_delivery_feedback',
'company_staffing_selection',
'company_recruitment_confirmation',
'company_reorg_pending',
'human_escalation',
'task_user_input',
])
const TERMINAL_CHECKPOINT_STATUSES = new Set([
'responded',
'resolved',
'timeout',
'timed_out',
'expired',
'stale',
'superseded',
'ignored',
'cancelled',
'canceled',
'invalid',
])
export function isCheckpointType(value: string | undefined): boolean {
return CHECKPOINT_TYPES.has(String(value ?? '').trim())
}
export function isCheckpointCardMetadata(meta: ChatMessageMeta | undefined): boolean {
if (!isCheckpointType(meta?.checkpoint_type)) {
return false
}
if (meta?.self_evolution_completed) {
return false
}
if (String(meta?.kind ?? '').trim() === 'company_self_evolution_result') {
return false
}
return true
}
export function isCheckpointResolved(meta: ChatMessageMeta | undefined): boolean {
const status = String(meta?.checkpoint_status ?? '').trim().toLowerCase()
if (TERMINAL_CHECKPOINT_STATUSES.has(status)) {
return true
}
return !!String(meta?.checkpoint_response_message_id ?? '').trim()
}
export function toCheckpointReplyMetadata(meta: ChatMessageMeta | undefined): CheckpointReplyMetadata | undefined {
const checkpointId = String(meta?.checkpoint_id ?? '').trim()
if (!checkpointId) {
return undefined
}
const checkpointType = String(meta?.checkpoint_type ?? '').trim()
const escalationId = String(meta?.escalation_id ?? '').trim()
return {
response_to_checkpoint_id: checkpointId,
response_to_checkpoint_type: checkpointType || undefined,
response_to_escalation_id: escalationId || undefined,
}
}
export function checkpointReplyMetadataForComposer(
meta: CheckpointReplyMetadata | undefined,
): CheckpointReplyMetadata | undefined {
const checkpointType = String(meta?.response_to_checkpoint_type ?? '').trim()
if (checkpointType === 'company_delivery_feedback' || checkpointType === 'human_escalation') {
return undefined
}
return meta
}
export function isResponseForCheckpoint(message: ChatMessage, checkpointMeta: ChatMessageMeta | undefined): boolean {
if (message.sender !== 'user') {
return false
}
const checkpointId = String(checkpointMeta?.checkpoint_id ?? '').trim()
if (!checkpointId) {
return false
}
const replyMeta = message.metadata
if (String(replyMeta?.response_to_checkpoint_id ?? '').trim() === checkpointId) {
return true
}
const checkpointType = String(checkpointMeta?.checkpoint_type ?? '').trim()
const escalationId = String(checkpointMeta?.escalation_id ?? '').trim()
return checkpointType === 'human_escalation'
&& !!escalationId
&& String(replyMeta?.response_to_escalation_id ?? '').trim() === escalationId
}
export function analyzeCheckpointMessages(messages: ChatMessage[]): {
pendingMessageIds: Set<string>
respondedMessageIds: Set<string>
duplicateMessageIds: Set<string>
latestPendingReplyMetadata?: CheckpointReplyMetadata
} {
const pendingMessageIds = new Set<string>()
const respondedMessageIds = new Set<string>()
const duplicateMessageIds = new Set<string>()
let latestPendingReplyMetadata: CheckpointReplyMetadata | undefined
const latestCheckpointReplyIndex = new Map<string, number>()
const latestEscalationReplyIndex = new Map<string, number>()
const seenCheckpointIds = new Set<string>()
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.sender !== 'user') continue
const replyMeta = message.metadata
const checkpointId = String(replyMeta?.response_to_checkpoint_id ?? '').trim()
if (checkpointId) {
latestCheckpointReplyIndex.set(checkpointId, i)
}
const escalationId = String(replyMeta?.response_to_escalation_id ?? '').trim()
if (escalationId) {
latestEscalationReplyIndex.set(escalationId, i)
}
}
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const checkpointMeta = message.metadata
if (!isCheckpointCardMetadata(checkpointMeta)) {
continue
}
const checkpointId = String(checkpointMeta?.checkpoint_id ?? '').trim()
const checkpointType = String(checkpointMeta?.checkpoint_type ?? '').trim()
const escalationId = String(checkpointMeta?.escalation_id ?? '').trim()
if (checkpointId && seenCheckpointIds.has(checkpointId)) {
duplicateMessageIds.add(message.id)
continue
}
if (checkpointId) {
seenCheckpointIds.add(checkpointId)
}
const hasLaterCheckpointReply = !!checkpointId && (latestCheckpointReplyIndex.get(checkpointId) ?? -1) > i
const hasLaterEscalationReply = checkpointType === 'human_escalation'
&& !!escalationId
&& (latestEscalationReplyIndex.get(escalationId) ?? -1) > i
if (isCheckpointResolved(checkpointMeta) || hasLaterCheckpointReply || hasLaterEscalationReply) {
respondedMessageIds.add(message.id)
continue
}
pendingMessageIds.add(message.id)
latestPendingReplyMetadata = toCheckpointReplyMetadata(checkpointMeta) ?? latestPendingReplyMetadata
}
return {
pendingMessageIds,
respondedMessageIds,
duplicateMessageIds,
latestPendingReplyMetadata,
}
}