Initial commit
This commit is contained in:
@@ -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">→</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">→</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">👤</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">⚙</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">✓</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">●</span>
|
||||
}
|
||||
if (status === 'failed') return <span className="wi-projection-icon">✗</span>
|
||||
if (status === 'waiting') return <span className="wi-projection-icon">●</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">→</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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user