Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,142 @@
/**
* Structural regression test for App.tsx WS handler registrations.
*
* Guards the 5 onOrgSaved* callbacks that MUST all be registered in the
* socket-handlers object. A missing one is silent (TypeScript-level the
* callback is optional) and results in broken UX: earlier bug report
* "can't switch saved orgs" was caused by the 3 of these being absent.
*
* Also guards:
* - Toast state wiring
* - useCallback stability for client.org* calls (no inline arrows in
* <OrgTab> props)
*
* Runs with `tsx` against node:assert/strict — matches repo convention
* for zero-framework tests.
*
* Usage:
* tsx opc/plugins/office_ui/frontend_src/App.test.tsx
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'App.tsx'), 'utf8')
// 1. All five onOrgSaved* handlers registered in the socket-handlers object.
for (const key of [
'onOrgSavedList',
'onOrgSavedSaveAs',
'onOrgSavedCreate',
'onOrgSavedLoad',
'onOrgSavedDelete',
]) {
assert.match(
src,
new RegExp(`^\\s+${key}:\\s*\\(payload\\)`, 'm'),
`App.tsx must register "${key}:" in the socket handlers object`,
)
}
// 2. Toast state + JSX render present.
assert.match(src, /const \[orgToast, setOrgToast\] = useState/, 'orgToast state must exist')
assert.match(src, /setOrgToast\(null\)/, 'orgToast auto-clear must exist')
assert.match(src, /org-toast org-toast--/, 'org-toast JSX must render class variant')
// 3. versionAtLoad tracking wired in onOrgInfo.
assert.match(
src,
/setSavedOrgVersionAtLoad\(prev =>/,
'onOrgInfo must capture versionAtLoad via functional setState',
)
// 4. useCallback for client.org* — no inline arrows in <OrgTab> props.
assert.doesNotMatch(
src,
/onSavedOrg[A-Z][a-zA-Z]*={\(/,
'OrgTab JSX must not use inline arrow functions for onSavedOrg* props',
)
for (const name of [
'handleSavedOrgsList',
'handleSavedOrgSaveAs',
'handleSavedOrgCreate',
'handleSavedOrgLoad',
'handleSavedOrgDelete',
]) {
assert.match(
src,
new RegExp(`const ${name} = useCallback`),
`App.tsx must declare "${name}" as useCallback`,
)
}
// 5. onOrgConfigImport narrowing comment present.
assert.match(
src,
/Fires only on manual YAML import/,
'onOrgConfigImport must carry the narrowing comment',
)
// 6. project_index_push is an index-only seed. It must not hydrate chat,
// kanban, or full runtime stores; full runtime state belongs to collab_sync.
assert.match(
src,
/const isProjectIndexPush = type === 'project_index_push' \|\| syncScope === 'index'/,
'project_index_push must be detected by event type and sync_scope',
)
assert.match(
src,
/if \(isProjectIndexPush\) \{[\s\S]*?preserveExistingWhenIncomingPartial: true[\s\S]*?clientRef\.current\?\.collabSync\(syncProjectId[\s\S]*?return[\s\S]*?\}\s+const cs2 = chatStoreRef\.current/,
'project_index_push must preserve existing session detail, request full collab_sync, and return before chat/kanban hydration',
)
assert.doesNotMatch(
src,
/preserveTasksWhenIncomingEmpty: isProjectIndexPush/,
'project_index_push must not call BoardStore.initFromBackend as a partial full-sync workaround',
)
// 7. Runtime tool display has two channels: currentTool is active-only,
// displayTool is the stable "last visible command" shown while the session
// remains running. This prevents a half-second header/composer flash when
// current_tool is cleared by tool_completed.
assert.match(
src,
/function runtimeStatusClearsDisplayTool/,
'App.tsx must centralize terminal status clearing for stable displayTool',
)
assert.match(
src,
/boardRuntimePatch\.displayTool = currentTool/,
'agent_runtime_update must copy a non-empty current_tool into board displayTool',
)
assert.match(
src,
/sessionRuntimePatch\.displayTool = currentTool/,
'agent_runtime_update must copy a non-empty current_tool into session displayTool',
)
assert.match(
src,
/runtimeStatusClearsDisplayTool\(payload\.status\)/,
'agent_runtime_update must clear displayTool only on terminal or idle statuses',
)
assert.match(
src,
/toolName \? \{ displayTool: toolName \}/,
'runtime events carrying a non-empty tool_name must update stable displayTool (empty tool_name keeps the sticky last command)',
)
// 8. Assistant streaming drafts must disappear at real terminal boundaries.
assert.match(
src,
/evt\.type === 'turn_completed' \|\| evt\.type === 'turn_failed' \|\| evt\.type === 'checkpoint_saved'/,
'runtime terminal/checkpoint events must clear task-mode Live Reply drafts',
)
assert.match(
src,
/detailHasFinalForDraft[\s\S]*runtime_v2_assistant[\s\S]*ss\.clearDraft\(detailTaskId\)/,
'session_detail backfill of the final runtime assistant turn must clear matching Live Reply drafts',
)
console.log('App.test.tsx: OK (org handlers + snapshot boundary + runtime displayTool/draft contract)')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,313 @@
import React, { useMemo, useState } from 'react'
import type { ProgressEntry, ProgressEntryType } from '../types/kanban'
import { progressEntryKey } from '../lib/progressEntryKey'
import { IconBrain, IconTool, IconChevron, IconSparkle, IconShield, IconArrowRight, IconGate, IconZap, IconWorkItem, IconGatePass, IconGateReject, IconClock, IconHandoff } from './SvgIcons'
interface AgentProgressBlockProps {
entries: ProgressEntry[]
agentStatus?: string
currentTool?: string
toolElapsedMs?: number
lastToolSummary?: string
sessionStatus?: string
expandedByDefault?: boolean
}
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled'])
export const INLINE_PROGRESS_ENTRY_TYPES = new Set<ProgressEntryType>(['thinking', 'tool_call', 'autonomy', 'needs_input', 'verification'])
const ENTRY_CONFIG: Record<ProgressEntryType, { icon: React.ReactNode; color: string; label: string }> = {
thinking: { icon: <IconBrain />, color: 'var(--accent)', label: 'Thinking' },
tool_call: { icon: <IconTool />, color: 'var(--green)', label: 'Tool' },
autonomy: { icon: <IconShield />, color: 'var(--yellow)', label: 'Autonomy' },
handoff: { icon: <IconArrowRight />, color: 'var(--accent)', label: 'Handoff' },
gate_result: { icon: <IconGate />, color: 'var(--green)', label: 'Gate' },
status_change: { icon: <IconZap />, color: 'var(--text-secondary)', label: 'Status' },
work_item_started: { icon: <IconWorkItem />, color: 'var(--accent)', label: 'Work item' },
gate_approved: { icon: <IconGatePass />, color: 'var(--green)', label: 'Gate Passed' },
gate_rejected: { icon: <IconGateReject />, color: 'var(--red)', label: 'Rejected' },
awaiting_manager_review: { icon: <IconClock />, color: 'var(--yellow)', label: 'Awaiting Manager Review' },
awaiting_human: { icon: <IconClock />, color: 'var(--yellow)', label: 'Awaiting Human Review' },
awaiting_review: { icon: <IconClock />, color: 'var(--yellow)', label: 'Awaiting Review' },
awaiting_peer: { icon: <IconClock />, color: 'var(--yellow)', label: 'Awaiting Peer' },
work_item_failed: { icon: <IconZap />, color: 'var(--red)', label: 'Failed' },
deadlock: { icon: <IconHandoff />, color: 'var(--red)', label: 'Deadlock' },
needs_input: { icon: <IconClock />, color: 'var(--yellow)', label: 'Needs Input' },
verification: { icon: <IconShield />, color: 'var(--accent)', label: 'Verification' },
}
const COLLAPSED_COUNT = 5
function elapsed(ts: number): string {
const sec = Math.floor((Date.now() - ts) / 1000)
if (sec < 5) return 'just now'
if (sec < 60) return `${sec}s ago`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
return `${Math.floor(min / 60)}h ago`
}
function normalizeNestedJson(value: unknown): unknown {
if (typeof value === 'string') {
const trimmed = value.trim()
if (
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|| (trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
try {
return normalizeNestedJson(JSON.parse(trimmed))
} catch {
return value
}
}
return value
}
if (Array.isArray(value)) {
return value.map(normalizeNestedJson)
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, normalizeNestedJson(nestedValue)]),
)
}
return value
}
function formatToolDetail(detail: string): string {
const trimmed = detail.trim()
if (!trimmed) return detail
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return detail
try {
return JSON.stringify(normalizeNestedJson(JSON.parse(trimmed)), null, 2)
} catch {
return detail
}
}
export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElapsedMs, lastToolSummary, sessionStatus, expandedByDefault }: AgentProgressBlockProps) {
const [expanded, setExpanded] = useState(!!expandedByDefault)
const isTerminal = !!sessionStatus && TERMINAL_STATUSES.has(sessionStatus)
const isThinking = !isTerminal && agentStatus === 'reflecting'
const isToolActive = !isTerminal && agentStatus === 'tool_active'
const isWorking = isThinking || isToolActive
const filteredEntries = useMemo(() => {
return entries
}, [entries])
const visibleEntries = useMemo(() => {
if (expanded || filteredEntries.length <= COLLAPSED_COUNT) return filteredEntries
return filteredEntries.slice(-COLLAPSED_COUNT)
}, [filteredEntries, expanded])
const hiddenCount = filteredEntries.length - visibleEntries.length
if (filteredEntries.length === 0 && !isWorking && !isTerminal) return null
return (
<div className="ptl-block">
{/* ── Live status indicator ──────────────────────── */}
{isWorking && (
<div className={`ptl-live ${isToolActive ? 'ptl-live-tool' : 'ptl-live-think'}`}>
<span className="ptl-live-icon">
{isToolActive ? <IconTool /> : <IconSparkle />}
</span>
<span className="ptl-live-text">
{isToolActive ? 'Running' : 'Thinking'}
</span>
{isToolActive && currentTool && (
<code className="ptl-live-tool-name">{currentTool}</code>
)}
{isToolActive && typeof toolElapsedMs === 'number' && toolElapsedMs > 0 && (
<span className="ptl-live-elapsed">
{toolElapsedMs < 1000 ? `${toolElapsedMs}ms` : `${(toolElapsedMs / 1000).toFixed(1)}s`}
</span>
)}
<span className="ptl-live-shimmer" />
</div>
)}
{/* ── Last tool result summary ──────────────────── */}
{lastToolSummary && !isToolActive && (
<div className="ptl-last-tool-summary">
<span className="ptl-last-tool-label">Last tool result:</span>
<span className="ptl-last-tool-text">{lastToolSummary}</span>
</div>
)}
{/* ── Collapse toggle (above timeline) ──────────── */}
{hiddenCount > 0 && (
<button className="ptl-expand" onClick={() => setExpanded(true)}>
<IconChevron />
<span>{hiddenCount} earlier step{hiddenCount > 1 ? 's' : ''}</span>
</button>
)}
{/* ── Timeline entries ───────────────────────────── */}
{visibleEntries.length > 0 && (
<div className="ptl-timeline">
{visibleEntries.map((entry, i) => {
const isLast = i === visibleEntries.length - 1
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
return (
<div key={progressEntryKey(entry, i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
<div className="ptl-connector">
<div className="ptl-dot" style={{ color: cfg.color }}>
{cfg.icon}
</div>
{!isLast && <div className="ptl-line" />}
</div>
<div className="ptl-content">
<AgentProgressEntryCard entry={entry} />
</div>
</div>
)
})}
</div>
)}
{/* ── Terminal state completion indicator ──────────── */}
{isTerminal && !isWorking && (
<div className={`ptl-completion ptl-completion-${sessionStatus}`}>
<span className="ptl-completion-icon">
{sessionStatus === 'done' ? '\u2713' : sessionStatus === 'failed' ? '\u2717' : '\u2014'}
</span>
<span className="ptl-completion-text">
{sessionStatus === 'done' ? 'Completed' : sessionStatus === 'failed' ? 'Failed' : 'Cancelled'}
</span>
</div>
)}
{/* ── Collapse button (when expanded) ────────────── */}
{expanded && filteredEntries.length > COLLAPSED_COUNT && (
<button className="ptl-expand" onClick={() => setExpanded(false)}>
<IconChevron down />
<span>Show less</span>
</button>
)}
</div>
)
}
export const AgentProgressEntryCard = React.memo(function AgentProgressEntryCard({ entry }: { entry: ProgressEntry }) {
const [expanded, setExpanded] = useState(false)
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
const hasToolDetail = entry.type === 'tool_call' && !!entry.detail
if (entry.type === 'tool_call') {
return (
<div className={`ptl-tool-card${expanded ? ' expanded' : ''}`}>
<button
className={`ptl-row ptl-tool-toggle${hasToolDetail ? ' clickable' : ''}`}
onClick={() => {
if (!hasToolDetail) return
setExpanded(prev => !prev)
}}
type="button"
>
<span className="ptl-label" style={{ color: cfg.color }}>{cfg.label}</span>
<code className="ptl-tool-badge">{entry.summary}</code>
<span className="ptl-time">{elapsed(entry.timestamp)}</span>
{hasToolDetail && (
<span className="ptl-tool-chevron">
<IconChevron down={expanded} />
</span>
)}
</button>
{expanded && entry.detail && (
<pre className="ptl-tool-card-detail">{formatToolDetail(entry.detail)}</pre>
)}
</div>
)
}
if (entry.type === 'autonomy') {
const hasDetail = !!entry.detail
return (
<div className={`ptl-tool-card ptl-autonomy-card${expanded ? ' expanded' : ''}`}>
<button
className={`ptl-row ptl-tool-toggle${hasDetail ? ' clickable' : ''}`}
onClick={() => { if (hasDetail) setExpanded(prev => !prev) }}
type="button"
>
<span className="ptl-label" style={{ color: cfg.color }}>{cfg.label}</span>
<code className="ptl-tool-badge">{entry.summary}</code>
<span className="ptl-time">{elapsed(entry.timestamp)}</span>
{hasDetail && (
<span className="ptl-tool-chevron">
<IconChevron down={expanded} />
</span>
)}
</button>
{expanded && entry.detail && (
<div className="ptl-tool-card-detail ptl-autonomy-detail">{entry.detail}</div>
)}
</div>
)
}
if (entry.type === 'thinking') {
return (
<div className="ptl-tool-card">
<button
className="ptl-row ptl-tool-toggle clickable"
onClick={() => setExpanded(prev => !prev)}
type="button"
>
<span className="ptl-label" style={{ color: cfg.color }}>{cfg.label}</span>
<span className="ptl-summary">{entry.summary}</span>
<span className="ptl-time">{elapsed(entry.timestamp)}</span>
<span className="ptl-tool-chevron">
<IconChevron down={expanded} />
</span>
</button>
{expanded && (
<div className="ptl-tool-card-detail ptl-thinking-detail">{entry.detail || entry.summary}</div>
)}
</div>
)
}
if (entry.type === 'verification') {
const hasDetail = !!entry.detail
return (
<div className={`ptl-tool-card ptl-verification-card${expanded ? ' expanded' : ''}`}>
<button
className={`ptl-row ptl-tool-toggle${hasDetail ? ' clickable' : ''}`}
onClick={() => { if (hasDetail) setExpanded(prev => !prev) }}
type="button"
>
<span className="ptl-label" style={{ color: cfg.color }}>{cfg.label}</span>
<span className="ptl-summary">{entry.summary || 'Verification'}</span>
<span className="ptl-time">{elapsed(entry.timestamp)}</span>
{hasDetail && (
<span className="ptl-tool-chevron">
<IconChevron down={expanded} />
</span>
)}
</button>
{expanded && entry.detail && (
<div className="ptl-tool-card-detail ptl-verification-detail">{entry.detail}</div>
)}
</div>
)
}
return (
<>
<div className="ptl-row">
<span className="ptl-label" style={{ color: cfg.color }}>{cfg.label}</span>
<span className="ptl-summary">{entry.summary}</span>
<span className="ptl-time">{elapsed(entry.timestamp)}</span>
</div>
{entry.detail && (
<div className="ptl-detail">{entry.detail}</div>
)}
</>
)
})
@@ -0,0 +1,737 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type {
AgentAnimStatus,
ProgressEntry,
RoleAggregatedStatus,
RoleWorkItemActivitySection,
RoleWorkItemRow,
RoleWorkItemSummary,
Session,
} from '../types/kanban'
import type { AgentInfo } from '../types/visual'
import { AgentProgressBlock } from './AgentProgressBlock'
import { MarkdownBody } from './MessageList'
import { IconClose, IconHandoff, IconTimeline, IconSearch, IconChevron } from './SvgIcons'
import { TERMINAL_SESSION_STATUSES, getSessionRuntimeStatus, isSessionWorking } from '../lib/sessionRuntime'
import { getWorkItemRoleLabel } from '../lib/workItemIdentity'
interface AgentWorkPanelProps {
sessions: Session[]
/**
* When provided (company-mode primary sessions), the panel renders one
* row per role with the per-role aggregated status from the backend.
* Falls back to the legacy ``sessions`` view when undefined / empty.
*/
roleWorkItems?: Record<string, RoleWorkItemSummary>
isCompanyRuntime?: boolean
agents: AgentInfo[]
onOpenChildDetail?: (taskId: string) => void
onOpenExecutionPanel?: (taskId: string) => void
}
interface AgentWorkPanelLegacyViewProps {
sessions: Session[]
agents: AgentInfo[]
onOpenChildDetail?: (taskId: string) => void
onOpenExecutionPanel?: (taskId: string) => void
}
type StatusFilter = 'all' | 'active' | 'idle' | 'pending'
function agentStatusOf(s: Session): AgentAnimStatus {
return getSessionRuntimeStatus(s)
}
function statusSortKey(s: Session): number {
const st = agentStatusOf(s)
if (st === 'tool_active') return 0
if (st === 'reflecting') return 1
if (s.status === 'running') return 2
if (s.status === 'pending') return 3
if (s.status === 'done') return 5
if (s.status === 'failed') return 6
return 4
}
function elapsed(ts: number): string {
const sec = Math.floor((Date.now() - ts) / 1000)
if (sec < 5) return 'now'
if (sec < 60) return `${sec}s`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m`
return `${Math.floor(min / 60)}h`
}
function lastActivity(entries: ProgressEntry[]): string {
if (entries.length === 0) return '\u2014'
return elapsed(entries[entries.length - 1].timestamp)
}
function activitySummary(s: Session): string {
if (s.status === 'done') return 'Completed'
if (s.status === 'failed') return 'Failed'
if (s.status === 'cancelled') return 'Cancelled'
const st = agentStatusOf(s)
if (st === 'tool_active' && s.currentTool) return s.currentTool
if (st === 'reflecting') return 'Thinking\u2026'
if (s.status === 'pending') return 'Pending'
const log = s.progressLog
if (log.length > 0) {
const last = log[log.length - 1]
if (last.type === 'work_item_started') return `Work item: ${last.summary}`
if (last.type === 'tool_call') return last.summary
if (last.detail) return last.detail.replace(/\s+/g, ' ').trim()
return last.summary
}
return 'Idle'
}
function terminalIcon(status: string): string | null {
if (status === 'done') return '\u2713'
if (status === 'failed') return '\u2717'
if (status === 'cancelled') return '\u2014'
return null
}
function terminalClass(status: string): string {
if (status === 'done') return 'awp-terminal-done'
if (status === 'failed') return 'awp-terminal-failed'
if (status === 'cancelled') return 'awp-terminal-cancelled'
return ''
}
const EXECUTION_AGENT_LABELS: Record<string, string> = {
native: 'Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
const RESULT_BANNER: Record<string, { cls: string; text: string }> = {
done: { cls: 'awp-result-done', text: 'Work item completed successfully' },
failed: { cls: 'awp-result-failed', text: 'Work item failed' },
cancelled: { cls: 'awp-result-cancelled', text: 'Work item cancelled' },
}
/** Role-aggregated panel: one row per role, sourced from the
* DelegationWorkItem rollup. Renders when ``roleWorkItems`` is non-empty;
* the legacy session-based panel below is preserved verbatim as a fallback
* for non-company runs.
*/
const ROLE_STATUS_LABEL: Record<RoleAggregatedStatus, string> = {
active: 'Working',
waiting: 'Waiting',
pending: 'Pending',
done: 'Completed',
failed: 'Failed',
}
const ROLE_STATUS_SORT: Record<RoleAggregatedStatus, number> = {
active: 0,
waiting: 1,
pending: 2,
done: 3,
failed: 4,
}
function summarizeRoleActivity(summary: RoleWorkItemSummary): string {
if (summary.runtimeStatus === 'tool_active') return 'Running tool…'
if (summary.runtimeStatus === 'reflecting') return 'Thinking…'
const label = ROLE_STATUS_LABEL[summary.aggregatedStatus] ?? 'Pending'
if (summary.aggregatedStatus === 'done') {
return `${label} · ${summary.workItems.length} work item${summary.workItems.length === 1 ? '' : 's'}`
}
return label
}
function lastRoleActivity(summary: RoleWorkItemSummary): string {
if (summary.workItems.length === 0) return '—'
const ts = summary.workItems.reduce((max, w) => Math.max(max, w.updatedAt), 0)
if (!ts) return '—'
return elapsed(ts)
}
function roleWorkItemSessionStatus(row: RoleWorkItemRow): string | undefined {
if (row.phase === 'failed') return 'failed'
if (row.phase === 'cancelled') return 'cancelled'
if (row.kanbanColumn === 'done') return 'done'
return undefined
}
function WorkItemActivitySections({
sections,
fallbackEntries,
sessionStatus,
}: {
sections?: RoleWorkItemActivitySection[]
fallbackEntries?: ProgressEntry[]
sessionStatus?: string
}) {
const visibleSections = (sections ?? []).filter(section => (
(section.entries?.length ?? 0) > 0 || !!section.runtimeTaskId
))
if (visibleSections.length > 0) {
return (
<div className="wf-role-turn-activity-sections">
{visibleSections.map((section, index) => {
const entries = section.entries ?? []
const key = `${section.runtimeTaskId || section.kind}:${index}`
return (
<section key={key} className="wf-role-turn-activity-section">
<div className="wf-role-turn-activity-section-head">
<span className="wf-role-turn-activity-section-title">{section.title}</span>
{section.roleName && (
<span className="wf-role-turn-activity-section-role">{section.roleName}</span>
)}
{entries.length > 0 && (
<span className="wf-role-turn-activity-section-count">{entries.length}</span>
)}
</div>
{entries.length > 0 ? (
<AgentProgressBlock
entries={entries}
sessionStatus={sessionStatus}
expandedByDefault
/>
) : (
<div className="wf-role-turn-empty-activity">No runtime activity yet</div>
)}
</section>
)
})}
</div>
)
}
if (!fallbackEntries || fallbackEntries.length === 0) {
return <div className="wf-role-turn-empty-activity">No runtime activity yet</div>
}
return (
<AgentProgressBlock
entries={fallbackEntries}
sessionStatus={sessionStatus}
expandedByDefault
/>
)
}
function AgentWorkPanelRoleView({
roleWorkItems,
onOpenChildDetail,
onOpenExecutionPanel,
}: {
roleWorkItems: Record<string, RoleWorkItemSummary>
onOpenChildDetail?: (taskId: string) => void
onOpenExecutionPanel?: (taskId: string) => void
}) {
const [selectedRoleKey, setSelectedRoleKey] = useState<string | null>(null)
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null)
const [filter, setFilter] = useState<StatusFilter>('all')
const [search, setSearch] = useState('')
const [showSearch, setShowSearch] = useState(false)
const summaries = useMemo(() => Object.values(roleWorkItems), [roleWorkItems])
const sorted = useMemo(() => {
let list = summaries.slice()
if (filter === 'active') {
list = list.filter(s => s.aggregatedStatus === 'active')
} else if (filter === 'idle') {
list = list.filter(s => s.aggregatedStatus === 'done' || s.aggregatedStatus === 'failed')
} else if (filter === 'pending') {
list = list.filter(s => s.aggregatedStatus === 'pending' || s.aggregatedStatus === 'waiting')
}
if (search.trim()) {
const q = search.toLowerCase()
list = list.filter(s =>
s.roleName.toLowerCase().includes(q)
|| s.roleId.toLowerCase().includes(q)
|| s.workItems.some(w => w.title.toLowerCase().includes(q)),
)
}
list.sort((a, b) => {
const aRank = ROLE_STATUS_SORT[a.aggregatedStatus] ?? 99
const bRank = ROLE_STATUS_SORT[b.aggregatedStatus] ?? 99
if (aRank !== bRank) return aRank - bRank
return a.roleName.localeCompare(b.roleName)
})
return list
}, [summaries, filter, search])
// Reset selection if the role disappears (rare, but possible during run
// teardown). Stale selection would otherwise crash the detail view.
useEffect(() => {
if (selectedRoleKey && !summaries.some(s => s.roleKey === selectedRoleKey)) {
setSelectedRoleKey(null)
setSelectedWorkItemId(null)
}
}, [summaries, selectedRoleKey])
const activeCount = summaries.filter(s => s.aggregatedStatus === 'active').length
const selected = selectedRoleKey ? roleWorkItems[selectedRoleKey] ?? null : null
const selectedWorkItem: RoleWorkItemRow | null = (selected && selectedWorkItemId)
? selected.workItems.find(w => w.workItemId === selectedWorkItemId) ?? null
: null
if (summaries.length === 0) return null
return (
<div className="awp">
<div className="awp-header">
<span className="awp-title">
Agents
<span className="awp-count">
{activeCount > 0
? `${activeCount}/${summaries.length} active`
: `${summaries.length}`}
</span>
</span>
<div className="awp-controls">
<select
className="awp-filter"
value={filter}
onChange={e => setFilter(e.target.value as StatusFilter)}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="idle">Idle / Done</option>
<option value="pending">Pending</option>
</select>
<button
className={`awp-search-toggle${showSearch ? ' active' : ''}`}
onClick={() => setShowSearch(v => !v)}
title="Search agents"
>
<IconSearch />
</button>
</div>
</div>
{showSearch && (
<div className="awp-search-bar">
<input
className="awp-search-input"
type="text"
placeholder="Search agents or work items..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
)}
<div className="awp-list">
{sorted.map((summary) => {
const isSelected = selectedRoleKey === summary.roleKey
const isActive = summary.aggregatedStatus === 'active'
const isLive = summary.runtimeStatus === 'reflecting' || summary.runtimeStatus === 'tool_active'
const isTerminal = summary.aggregatedStatus === 'done' || summary.aggregatedStatus === 'failed'
return (
<button
key={summary.roleKey}
className={`awp-row${isSelected ? ' awp-row-selected' : ''}${isActive ? ' awp-row-active' : ''}${isTerminal ? ` awp-row-terminal awp-terminal-${summary.aggregatedStatus}` : ''}`}
onClick={() => {
setSelectedRoleKey(prev => (prev === summary.roleKey ? null : summary.roleKey))
setSelectedWorkItemId(null)
}}
title={`${summary.roleName}${ROLE_STATUS_LABEL[summary.aggregatedStatus]}`}
>
<span className={`awp-dot${isLive ? ' awp-dot-active' : ''} awp-dot-${summary.aggregatedStatus}`} />
<div className="awp-row-info">
<span className="awp-row-name">{summary.roleName}</span>
<span className="awp-row-projection">
{summary.workItems.length} work item{summary.workItems.length === 1 ? '' : 's'}
</span>
</div>
<span className="awp-row-activity">{summarizeRoleActivity(summary)}</span>
<span className="awp-row-time">{lastRoleActivity(summary)}</span>
</button>
)
})}
{sorted.length === 0 && (
<div className="awp-empty">
{search ? 'No matching agents' : 'No agents in this filter'}
</div>
)}
</div>
{selected && (
<div className="awp-detail">
<div className="awp-detail-header">
<div className="awp-detail-identity">
<div className="awp-detail-avatar">
{selected.roleName.charAt(0).toUpperCase()}
</div>
<div className="awp-detail-meta">
<span className="awp-detail-name">{selected.roleName}</span>
<span className="awp-detail-role">{ROLE_STATUS_LABEL[selected.aggregatedStatus]}</span>
</div>
</div>
<div className="awp-detail-actions">
<button
className="awp-detail-close"
onClick={() => { setSelectedRoleKey(null); setSelectedWorkItemId(null) }}
title="Close detail"
>
<IconClose />
</button>
</div>
</div>
<div className="awp-detail-body">
<div className="awp-detail-section">
<div className="awp-detail-section-label">
<IconTimeline />
<span>Work items</span>
</div>
<ul className="wf-role-turns">
{selected.workItems.map((row) => {
const expanded = selectedWorkItemId === row.workItemId
const columnId = (row.kanbanColumn === 'in-progress')
? 'in_progress'
: (row.kanbanColumn === 'in-review' ? 'in_review' : row.kanbanColumn)
return (
<li key={row.workItemId} className={`wf-role-turn${expanded ? ' wf-role-turn-expanded' : ''}`}>
<button
type="button"
className="wf-role-turn-button"
onClick={() => setSelectedWorkItemId(prev => (prev === row.workItemId ? null : row.workItemId))}
>
<span className={`wf-role-turn-column wf-role-turn-column-${columnId}`}>
{row.kanbanColumn.replace('-', ' ').replace(/^./, c => c.toUpperCase())}
</span>
<span className="wf-role-turn-title">
{row.isReviewTarget && <span className="wf-role-turn-tag">Review</span>}
{row.title}
</span>
<span className="wf-role-turn-time">{elapsed(row.updatedAt)}</span>
<span className="wf-role-turn-chevron">
<IconChevron down={expanded} />
</span>
</button>
{expanded && (
<div className="wf-role-turn-activity">
<WorkItemActivitySections
sections={row.activitySections}
fallbackEntries={row.progressLog}
sessionStatus={roleWorkItemSessionStatus(row)}
/>
{row.executionTurnId && (
<button
type="button"
className="wf-role-turn-open-session"
onClick={(event) => {
event.stopPropagation()
if (row.executionTurnId) {
(onOpenExecutionPanel ?? onOpenChildDetail)?.(row.executionTurnId)
}
}}
>
Open runtime session
</button>
)}
</div>
)}
</li>
)
})}
</ul>
</div>
{selectedWorkItem === null && selected.workItems.length === 0 && (
<div className="awp-detail-empty">No work items yet</div>
)}
</div>
</div>
)}
</div>
)
}
function AgentWorkPanelLegacyView({ sessions, agents, onOpenChildDetail, onOpenExecutionPanel }: AgentWorkPanelLegacyViewProps) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
const [filter, setFilter] = useState<StatusFilter>('all')
const [search, setSearch] = useState('')
const [showSearch, setShowSearch] = useState(false)
// Reset selection when parent session changes (sessions list swaps entirely)
useEffect(() => {
if (selectedTaskId && !sessions.some(s => s.taskId === selectedTaskId)) {
setSelectedTaskId(null)
}
}, [sessions, selectedTaskId])
const sorted = useMemo(() => {
let list = [...sessions]
if (filter === 'active') {
list = list.filter(isSessionWorking)
} else if (filter === 'idle') {
list = list.filter(s =>
TERMINAL_SESSION_STATUSES.has(s.status) || (agentStatusOf(s) === 'idle' && s.status !== 'pending'),
)
} else if (filter === 'pending') {
list = list.filter(s => s.status === 'pending')
}
if (search.trim()) {
const q = search.toLowerCase()
list = list.filter(s => {
const name = s.assigneeIds[0] ?? s.title
return name.toLowerCase().includes(q) || s.title.toLowerCase().includes(q)
})
}
list.sort((a, b) => statusSortKey(a) - statusSortKey(b))
return list
}, [sessions, filter, search])
const activeCount = sessions.filter(isSessionWorking).length
const selected = useMemo(() => {
if (!selectedTaskId) return null
return sessions.find(s => s.taskId === selectedTaskId) ?? null
}, [sessions, selectedTaskId])
const selectedAgent = useMemo(() => {
if (!selected) return undefined
const id = selected.assigneeIds[0]
return id ? agents.find(a => a.agent_id === id) : undefined
}, [selected, agents])
const handleSelect = useCallback((taskId: string) => {
setSelectedTaskId(prev => {
if (prev === taskId) {
onOpenChildDetail?.(taskId)
return prev
}
return taskId
})
}, [onOpenChildDetail])
if (sessions.length === 0) return null
return (
<div className="awp">
{/* Header */}
<div className="awp-header">
<span className="awp-title">
Agents
<span className="awp-count">
{activeCount > 0
? `${activeCount}/${sessions.length} active`
: `${sessions.length}`}
</span>
</span>
<div className="awp-controls">
<select
className="awp-filter"
value={filter}
onChange={e => setFilter(e.target.value as StatusFilter)}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="idle">Idle / Done</option>
<option value="pending">Pending</option>
</select>
<button
className={`awp-search-toggle${showSearch ? ' active' : ''}`}
onClick={() => setShowSearch(v => !v)}
title="Search agents"
>
<IconSearch />
</button>
</div>
</div>
{showSearch && (
<div className="awp-search-bar">
<input
className="awp-search-input"
type="text"
placeholder="Search agents..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
)}
{/* Compact list */}
<div className="awp-list">
{sorted.map(s => {
const isActive = isSessionWorking(s)
const isTerminal = TERMINAL_SESSION_STATUSES.has(s.status)
const isSelected = selectedTaskId === s.taskId
const agentName = s.assigneeIds[0]?.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) ?? s.title
const projectionLabel = s.workItemProjectionId?.replace(/_/g, ' ') ?? ''
const tIcon = terminalIcon(s.status)
const tCls = terminalClass(s.status)
return (
<button
key={s.taskId}
className={`awp-row${isSelected ? ' awp-row-selected' : ''}${isActive ? ' awp-row-active' : ''}${isTerminal ? ` awp-row-terminal ${tCls}` : ''}`}
onClick={() => handleSelect(s.taskId)}
onDoubleClick={() => onOpenChildDetail?.(s.taskId)}
title={onOpenChildDetail ? 'Click to inspect, click again or double-click to open full context' : undefined}
>
{tIcon ? (
<span className={`awp-terminal-icon ${tCls}`}>{tIcon}</span>
) : (
<span className={`awp-dot${isActive ? ' awp-dot-active' : ''}`} />
)}
<div className="awp-row-info">
<span className="awp-row-name">{agentName}</span>
{projectionLabel && <span className="awp-row-projection">{projectionLabel}</span>}
</div>
<span className="awp-row-activity">{activitySummary(s)}</span>
<span className="awp-row-time">{lastActivity(s.progressLog)}</span>
</button>
)
})}
{sorted.length === 0 && (
<div className="awp-empty">
{search ? 'No matching agents' : 'No agents in this filter'}
</div>
)}
</div>
{/* Detail view for selected agent */}
{selected && (
<div className="awp-detail">
<div className="awp-detail-header">
<div className="awp-detail-identity">
<div className={`awp-detail-avatar${TERMINAL_SESSION_STATUSES.has(selected.status) ? ` ${terminalClass(selected.status)}` : ''}`}>
{(selectedAgent?.name ?? selected.assigneeIds[0] ?? '?').charAt(0).toUpperCase()}
</div>
<div className="awp-detail-meta">
<span className="awp-detail-name">
{selectedAgent?.name ?? selected.assigneeIds[0] ?? selected.title}
</span>
{getWorkItemRoleLabel(selected) && (
<span className="awp-detail-role">
{getWorkItemRoleLabel(selected)}
</span>
)}
{selected.employeeAssignment?.name && (
<span className="awp-detail-employee">{selected.employeeAssignment.name}</span>
)}
{selected.selectedExecutionAgent && (
<span className="awp-detail-employee">
Agent: {EXECUTION_AGENT_LABELS[selected.selectedExecutionAgent] ?? selected.selectedExecutionAgent}
</span>
)}
{selected.workItemProjectionId && (
<span className="awp-detail-projection">
{selected.workItemProjectionId.replace(/_/g, ' ')}
</span>
)}
</div>
</div>
<div className="awp-detail-actions">
{onOpenChildDetail && (
<button
className="awp-detail-expand"
onClick={() => onOpenChildDetail(selected.taskId)}
title="Open full context"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M2.5 3.25h9a1 1 0 011 1v5.5a1 1 0 01-1 1h-4l-2.75 2v-2h-2.25a1 1 0 01-1-1v-5.5a1 1 0 011-1Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
<path d="M4.5 5.5h5M4.5 7.5h3.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
</button>
)}
{onOpenExecutionPanel && (
<button
className="awp-detail-expand"
onClick={() => onOpenExecutionPanel(selected.taskId)}
title="Open execution panel"
>
<IconTimeline />
</button>
)}
<button
className="awp-detail-close"
onClick={() => setSelectedTaskId(null)}
title="Close detail"
>
<IconClose />
</button>
</div>
</div>
<div className="awp-detail-body">
{selected.handoffContext && (
<div className="awp-detail-section">
<div className="awp-detail-section-label">
<IconHandoff />
<span>Handoff</span>
</div>
<div className="msg-content-agent-card">
<MarkdownBody content={selected.handoffContext} className="awp-detail-handoff awp-detail-handoff-markdown" />
</div>
</div>
)}
<div className="awp-detail-section">
<div className="awp-detail-section-label">
<IconTimeline />
<span>Activity</span>
</div>
<AgentProgressBlock
entries={selected.progressLog}
agentStatus={selected.agentStatus}
currentTool={selected.currentTool}
toolElapsedMs={selected.toolElapsedMs}
lastToolSummary={selected.lastToolSummary}
sessionStatus={selected.status}
expandedByDefault
/>
{selected.progressLog.length === 0 && !selected.agentStatus && !TERMINAL_SESSION_STATUSES.has(selected.status) && (
<div className="awp-detail-empty">No activity recorded yet</div>
)}
</div>
{TERMINAL_SESSION_STATUSES.has(selected.status) && RESULT_BANNER[selected.status] && (
<div className={`awp-result-banner ${RESULT_BANNER[selected.status].cls}`}>
<span className="awp-result-icon">{terminalIcon(selected.status)}</span>
<span>{RESULT_BANNER[selected.status].text}</span>
</div>
)}
</div>
</div>
)}
</div>
)
}
export function AgentWorkPanel({
sessions,
roleWorkItems,
isCompanyRuntime = false,
agents,
onOpenChildDetail,
onOpenExecutionPanel,
}: AgentWorkPanelProps) {
// Prefer the work-item-driven view whenever the backend provides it:
// it is the single source of truth for "1 row = 1 work item".
if (roleWorkItems && Object.keys(roleWorkItems).length > 0) {
return (
<AgentWorkPanelRoleView
roleWorkItems={roleWorkItems}
onOpenChildDetail={onOpenChildDetail}
onOpenExecutionPanel={onOpenExecutionPanel}
/>
)
}
if (isCompanyRuntime) return null
return (
<AgentWorkPanelLegacyView
sessions={sessions}
agents={agents}
onOpenChildDetail={onOpenChildDetail}
onOpenExecutionPanel={onOpenExecutionPanel}
/>
)
}
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict'
import type { ChatMessage } from '../types/chat'
import { mapBackendMessage } from '../lib/collabSync'
import { analyzeCheckpointMessages } from './checkpointUtils'
import { __chatStoreTestUtils } from './ChatStore'
const syntheticCheckpoint: ChatMessage = {
id: 'checkpoint::cp-delivery',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'Company Member',
content: 'Human review requested.',
timestamp: 1,
mentions: [],
metadata: {
checkpoint_type: 'company_delivery_feedback',
checkpoint_id: 'cp-delivery',
summary: 'Pending review',
},
}
const backendCheckpointUpdate: ChatMessage = {
id: 'db-message-1',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'Company Member',
content: 'Human review requested.',
timestamp: 2,
mentions: [],
metadata: {
checkpoint_type: 'company_delivery_feedback',
checkpoint_id: 'cp-delivery',
checkpoint_status: 'ignored',
checkpoint_reply_kind: 'ignore',
},
}
const mergedCheckpoint = __chatStoreTestUtils.dedupeMessages([
syntheticCheckpoint,
backendCheckpointUpdate,
])
assert.equal(mergedCheckpoint.length, 1)
assert.equal(mergedCheckpoint[0].id, 'db-message-1')
assert.equal(mergedCheckpoint[0].metadata?.checkpoint_status, 'ignored')
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).pendingMessageIds], [])
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).respondedMessageIds], ['db-message-1'])
const terminalSyntheticCheckpoint: ChatMessage = {
...syntheticCheckpoint,
timestamp: 2,
metadata: {
...syntheticCheckpoint.metadata,
checkpoint_status: 'ignored',
checkpoint_reply_kind: 'ignore',
},
}
const mergedSameIdCheckpoint = __chatStoreTestUtils.dedupeMessages([
syntheticCheckpoint,
terminalSyntheticCheckpoint,
])
assert.equal(mergedSameIdCheckpoint.length, 1)
assert.equal(mergedSameIdCheckpoint[0].id, 'checkpoint::cp-delivery')
assert.equal(mergedSameIdCheckpoint[0].metadata?.checkpoint_status, 'ignored')
assert.deepEqual([...analyzeCheckpointMessages(mergedSameIdCheckpoint).pendingMessageIds], [])
const optimisticUserMessage: ChatMessage = {
id: 'msg-local',
channelId: 'session:task-1',
sender: 'user',
senderName: 'You',
content: 'New requirement',
timestamp: 3,
mentions: [],
metadata: {
ui_message_id: 'ui-1',
},
}
const backendUserMessage: ChatMessage = {
id: 'db-user-1',
channelId: 'session:task-1',
sender: 'user',
senderName: 'You',
content: 'New requirement',
timestamp: 4,
mentions: [],
metadata: {
ui_message_id: 'ui-1',
},
}
const mergedUserMessage = __chatStoreTestUtils.dedupeMessages([
optimisticUserMessage,
backendUserMessage,
])
assert.equal(mergedUserMessage.length, 1)
assert.equal(mergedUserMessage[0].metadata?.ui_message_id, 'ui-1')
const nativeCompanyRawTurn: ChatMessage = {
id: 'native-raw-1',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'Task Generalist',
content: '最终分析已经完成,结论如下。',
timestamp: 5,
mentions: [],
metadata: {
source: 'engine',
transcript_kind: 'runtime_v2_assistant',
},
}
const companyRoleResult: ChatMessage = {
id: 'role-result-1',
channelId: 'session:task-1',
sender: 'chao',
senderName: 'Chao',
content: '最终分析已经完成,结论如下。',
timestamp: 6,
mentions: [],
metadata: {
source: 'engine',
transcript_kind: 'company_role_result',
},
}
const mergedNativeCompanyDuplicate = __chatStoreTestUtils.dedupeMessages([
nativeCompanyRawTurn,
companyRoleResult,
])
assert.equal(mergedNativeCompanyDuplicate.length, 1)
assert.equal(mergedNativeCompanyDuplicate[0].id, 'role-result-1')
assert.equal(mergedNativeCompanyDuplicate[0].senderName, 'Chao')
const mappedTaskGeneralistMessage = mapBackendMessage({
message_id: 'legacy-task-generalist',
channel_id: 'session:task-1',
sender: 'task_generalist',
sender_name: 'Task Generalist',
content: 'Legacy native task result.',
created_at: 10,
metadata: {
transcript_kind: 'runtime_v2_company_assistant',
},
})
assert.equal(mappedTaskGeneralistMessage.senderName, 'OPC')
console.log('ChatStore.test.ts: OK (optimistic, checkpoint, and company result identity merging)')
@@ -0,0 +1,495 @@
import { useCallback, useMemo, useReducer, useState } from 'react'
import type { ChatChannel, ChatMessage } from '../types/chat'
function uid(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
}
const DUPLICATE_WINDOW_MS = 2000
const RESULT_SURFACE_PRIORITY: Record<string, number> = {
child_task_result: 80,
child_task_result_retry: 79,
company_role_result: 75,
company_role_result_retry: 74,
child_result: 70,
runtime_v2_assistant: 60,
runtime_v2_company_assistant: 20,
top_level_reply: 40,
worker_notification: 10,
}
function messageMetadata(message: ChatMessage): Record<string, unknown> {
return (message.metadata ?? {}) as Record<string, unknown>
}
function normalizeMessageContent(content: string): string {
const normalized = String(content ?? '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.map(line => line.trimEnd())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
const titleStripped = stripNarrativeTitlePrefix(normalized)
const paragraphs = titleStripped.split(/\n{2,}/).map(part => part.trim()).filter(Boolean)
if (paragraphs.length > 1 && /^Verification:\s/i.test(paragraphs[paragraphs.length - 1])) {
return paragraphs.slice(0, -1).join('\n\n').trim()
}
return titleStripped
}
function stripNarrativeTitlePrefix(content: string): string {
const trimmed = String(content || '').trim()
const markdownTitle = trimmed.match(/^\*\*(.{8,160}?)\*\*:\s+([\s\S]+)$/)
if (markdownTitle) {
const body = markdownTitle[2].trim()
if (body.length >= 80) return body
}
const colonIndex = trimmed.indexOf(': ')
if (colonIndex < 8 || colonIndex > 160) return trimmed
const prefix = trimmed.slice(0, colonIndex).replace(/\*/g, '').trim()
const body = trimmed.slice(colonIndex + 2).trim()
if (body.length < 80) return trimmed
if (!/[A-Za-z\u4e00-\u9fff]/.test(prefix)) return trimmed
if (/^(https?|file)$/i.test(prefix)) return trimmed
return body
}
function messageIdentityKeys(message: ChatMessage): Set<string> {
const metadata = messageMetadata(message)
const keys = new Set<string>()
const checkpointType = typeof metadata.checkpoint_type === 'string' ? metadata.checkpoint_type.trim() : ''
const checkpointId = typeof metadata.checkpoint_id === 'string' ? metadata.checkpoint_id.trim() : ''
for (const value of [
message.id,
typeof metadata.ui_message_id === 'string' ? metadata.ui_message_id : '',
checkpointType && checkpointId ? `checkpoint:${checkpointType}:${checkpointId}` : '',
]) {
const normalized = String(value ?? '').trim()
if (normalized) keys.add(normalized)
}
return keys
}
function isDerivedIdentityKey(value: string): boolean {
return value.startsWith('checkpoint:')
}
function messageTimestamp(message: ChatMessage): number {
return typeof message.timestamp === 'number' ? message.timestamp : 0
}
function messageRoleBucket(message: ChatMessage): 'user' | 'assistant' {
const sender = String(message.sender ?? '').trim().toLowerCase()
const metadata = messageMetadata(message)
const role = typeof metadata.role === 'string' ? metadata.role.trim().toLowerCase() : ''
if (sender === 'user' || role === 'user') return 'user'
return 'assistant'
}
function messagePreferenceScore(message: ChatMessage): number {
const metadata = messageMetadata(message)
const sender = String(message.sender ?? '').trim().toLowerCase()
let score = 0
const resultPriority = resultSurfacePriority(message)
if (resultPriority) score += 1000 + resultPriority
if (metadata.source === 'engine') score += 100
if (sender && sender !== 'system') score += 20
if (sender && !['assistant', 'system', 'user'].includes(sender)) score += 5
if (message.replyToId) score += 2
score += Math.min(Object.keys(metadata).length, 10)
return score
}
function messageHasEngineSource(message: ChatMessage): boolean {
return String(messageMetadata(message).source ?? '').trim().toLowerCase() === 'engine'
}
function resultSurfacePriority(message: ChatMessage): number {
const metadata = messageMetadata(message)
const transcriptKind = String(metadata.transcript_kind ?? '').trim()
if (transcriptKind) return RESULT_SURFACE_PRIORITY[transcriptKind] ?? 0
const kind = String(metadata.kind ?? '').trim()
return RESULT_SURFACE_PRIORITY[kind] ?? 0
}
function isResultSurface(message: ChatMessage): boolean {
return resultSurfacePriority(message) > 0
}
function messagesShareIdentity(existing: ChatMessage, candidate: ChatMessage): boolean {
if (existing.channelId !== candidate.channelId) return false
const existingIds = messageIdentityKeys(existing)
const candidateIds = messageIdentityKeys(candidate)
for (const id of existingIds) {
if (candidateIds.has(id)) return true
}
return false
}
function messagesSemanticallyMatch(existing: ChatMessage, candidate: ChatMessage): boolean {
if (messagesShareIdentity(existing, candidate)) return true
if (existing.channelId !== candidate.channelId) return false
if (messageRoleBucket(existing) !== messageRoleBucket(candidate)) return false
if (normalizeMessageContent(existing.content) !== normalizeMessageContent(candidate.content)) return false
const bothResultSurfaces = isResultSurface(existing) && isResultSurface(candidate)
if (!bothResultSurfaces && String(existing.replyToId ?? '') !== String(candidate.replyToId ?? '')) return false
if (!(messageHasEngineSource(existing) || messageHasEngineSource(candidate))) return false
const existingTs = messageTimestamp(existing)
const candidateTs = messageTimestamp(candidate)
if (!bothResultSurfaces && existingTs && candidateTs && Math.abs(existingTs - candidateTs) > DUPLICATE_WINDOW_MS) {
return false
}
return true
}
function mergeDuplicateMessages(
existing: ChatMessage,
candidate: ChatMessage,
preferCandidate = false,
): ChatMessage {
let preferred = existing
let secondary = candidate
if (preferCandidate) {
preferred = candidate
secondary = existing
} else if (messagePreferenceScore(candidate) > messagePreferenceScore(existing)) {
preferred = candidate
secondary = existing
}
const mentions: string[] = []
for (const values of [secondary.mentions, preferred.mentions]) {
for (const value of values ?? []) {
if (!mentions.includes(value)) mentions.push(value)
}
}
const existingIds = messageIdentityKeys(existing)
const candidateIds = messageIdentityKeys(candidate)
let canonicalId = ''
for (const id of existingIds) {
if (candidateIds.has(id) && !isDerivedIdentityKey(id)) {
canonicalId = id
break
}
}
const normalizedContent = normalizeMessageContent(preferred.content)
const content = normalizedContent && normalizedContent === normalizeMessageContent(secondary.content)
? normalizedContent
: preferred.content
return {
...secondary,
...preferred,
...(canonicalId ? { id: canonicalId } : {}),
content,
metadata: { ...messageMetadata(secondary), ...messageMetadata(preferred) },
mentions,
timestamp: messageTimestamp(preferred) || messageTimestamp(secondary),
}
}
function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
const deduped: ChatMessage[] = []
// Map from identity key → index in deduped for O(1) identity lookups
const identityKeyToIdx = new Map<string, number>()
for (const message of [...messages].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
const candidateIds = messageIdentityKeys(message)
let matchIndex = -1
let preferCandidate = false
// O(1) identity lookup via Map instead of O(n) backward scan
for (const id of candidateIds) {
const idx = identityKeyToIdx.get(id)
if (idx !== undefined) {
matchIndex = idx
preferCandidate = deduped[idx].id === message.id
break
}
}
// Semantic match: scan backwards with early-exit when only short-window matches remain.
if (matchIndex === -1) {
const candidateTs = messageTimestamp(message)
const candidateIsResultSurface = isResultSurface(message)
for (let i = deduped.length - 1; i >= 0; i--) {
const existingTs = messageTimestamp(deduped[i])
if (
!candidateIsResultSurface
&& candidateTs > 0
&& existingTs > 0
&& candidateTs - existingTs > DUPLICATE_WINDOW_MS
) break
if (messagesSemanticallyMatch(deduped[i], message)) {
matchIndex = i
break
}
}
}
const insertIdx = matchIndex === -1 ? deduped.length : matchIndex
if (matchIndex === -1) {
deduped.push(message)
} else {
deduped[matchIndex] = mergeDuplicateMessages(deduped[matchIndex], message, preferCandidate)
}
// Register all identity keys for the merged/inserted message for fast future lookups
for (const id of messageIdentityKeys(deduped[insertIdx])) {
if (!identityKeyToIdx.has(id)) identityKeyToIdx.set(id, insertIdx)
}
}
return deduped
}
export const __chatStoreTestUtils = {
dedupeMessages,
}
type ChannelAction =
| { type: 'SET'; channels: ChatChannel[] }
| { type: 'ADD'; channel: ChatChannel }
| { type: 'REMOVE'; channelId: string }
| { type: 'REMOVE_PARTICIPANT'; agentId: string }
| { type: 'CLEAR' }
function channelReducer(state: ChatChannel[], action: ChannelAction): ChatChannel[] {
switch (action.type) {
case 'SET': return action.channels
case 'CLEAR': return []
case 'ADD': return state.some(ch => ch.id === action.channel.id) ? state : [...state, action.channel]
case 'REMOVE': return state.filter(ch => ch.id !== action.channelId)
case 'REMOVE_PARTICIPANT': return state.map(ch => ({
...ch,
participants: ch.participants.filter(p => p !== action.agentId),
}))
default: return state
}
}
type MessageAction =
| { type: 'SET'; messages: ChatMessage[] }
| { type: 'ADD'; message: ChatMessage }
| { type: 'MERGE'; messages: ChatMessage[] }
| { type: 'MARK_SENDER_DELETED'; senderId: string }
| { type: 'REMOVE_BY_CHANNEL'; channelId: string }
| { type: 'REMOVE_BY_TASK_ID'; taskId: string }
| { type: 'CLEAR' }
function messageReducer(state: ChatMessage[], action: MessageAction): ChatMessage[] {
switch (action.type) {
case 'SET': {
// Backend snapshots (collab_sync / collab_sync_push) arrive frequently
// while agents are running. A naive replace drops any client-side
// optimistic message (id prefixed `msg-` from sendMessage) that the
// backend has not round-tripped yet, which causes the composer's sent
// text to flicker in and out and user input lines to disappear for
// a beat. Preserve those local-only messages here until the backend
// snapshot catches up.
const incoming = dedupeMessages(action.messages)
if (state.length === 0) return incoming
const localOnly = state.filter(existing =>
typeof existing.id === 'string' &&
existing.id.startsWith('msg-') &&
!incoming.some(inc => messagesSemanticallyMatch(existing, inc))
)
if (localOnly.length === 0) return incoming
return dedupeMessages([...incoming, ...localOnly])
}
case 'CLEAR':
return []
case 'ADD': {
// Fast path: only scan recent messages within the dedup window — avoids O(n²) full dedup
// for the common case of a single new message arriving from the WebSocket.
// Identity matches (same message_id) are checked across the full list so that
// metadata-only updates (e.g. checkpoint_status changes) always merge in-place
// regardless of how old the original message is.
const candidateTs = messageTimestamp(action.message)
const candidateIds = messageIdentityKeys(action.message)
let pastWindow = false
for (let i = state.length - 1; i >= 0; i--) {
const existingTs = messageTimestamp(state[i])
if (!pastWindow && candidateTs > 0 && existingTs > 0 && candidateTs - existingTs > DUPLICATE_WINDOW_MS) {
pastWindow = true
}
if (messagesShareIdentity(state[i], action.message)) {
const updated = [...state]
updated[i] = mergeDuplicateMessages(state[i], action.message, state[i].id === action.message.id)
return updated
}
if ((isResultSurface(action.message) || !pastWindow) && messagesSemanticallyMatch(state[i], action.message)) {
const updated = [...state]
updated[i] = mergeDuplicateMessages(state[i], action.message)
return updated
}
}
return [...state, action.message]
}
case 'MERGE': {
if (action.messages.length === 0) return state
return dedupeMessages([...state, ...action.messages])
}
case 'MARK_SENDER_DELETED': return state.map(m =>
m.sender === action.senderId ? { ...m, senderDeleted: true, senderName: '[已删除的 Agent]' } : m
)
case 'REMOVE_BY_CHANNEL': return state.filter(m => m.channelId !== action.channelId)
case 'REMOVE_BY_TASK_ID': return state.filter(m =>
m.channelId !== `session:${action.taskId}` &&
!((m.metadata as Record<string, unknown>)?.task_id === action.taskId)
)
default: return state
}
}
export interface ChatStoreState {
scopeProjectId: string
channels: ChatChannel[]
messages: ChatMessage[]
sendMessage: (opts: { channelId: string; sender: string; senderName: string; content: string; replyToId?: string; metadata?: ChatMessage['metadata'] }) => ChatMessage
getChannelMessages: (channelId: string) => ChatMessage[]
getUnreadCount: (channelId: string) => number
markRead: (channelId: string) => void
markSenderDeleted: (agentId: string) => void
removeParticipant: (agentId: string) => void
removeSessionData: (taskId: string) => void
clear: () => void
initFromBackend: (projectId: string, channels: ChatChannel[], messages: ChatMessage[]) => void
addMessageFromBackend: (msg: ChatMessage) => void
mergeMessagesFromBackend: (messages: ChatMessage[]) => void
addChannelFromBackend: (ch: ChatChannel) => void
}
export function useChatStore(): ChatStoreState {
const [channels, dispatchCh] = useReducer(channelReducer, [])
const [messages, dispatchMsg] = useReducer(messageReducer, [])
const [readTimestamps, setReadTimestamps] = useState<Record<string, number>>({})
const [scopeProjectId, setScopeProjectId] = useState<string>('default')
const messagesByChannel = useMemo<Record<string, ChatMessage[]>>(() => {
const buckets: Record<string, ChatMessage[]> = {}
for (const message of messages) {
if (!buckets[message.channelId]) buckets[message.channelId] = []
buckets[message.channelId].push(message)
}
return buckets
}, [messages])
const unreadCounts = useMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {}
for (const message of messages) {
if (message.sender === 'user') continue
const lastRead = readTimestamps[message.channelId] ?? 0
if (message.timestamp <= lastRead) continue
counts[message.channelId] = (counts[message.channelId] ?? 0) + 1
}
return counts
}, [messages, readTimestamps])
const sendMessage = useCallback((opts: {
channelId: string; sender: string; senderName: string; content: string;
replyToId?: string; metadata?: ChatMessage['metadata']
}) => {
const msg: ChatMessage = {
id: `msg-${uid()}`,
channelId: opts.channelId,
sender: opts.sender,
senderName: opts.senderName,
content: opts.content,
timestamp: Date.now(),
replyToId: opts.replyToId,
mentions: [],
metadata: opts.metadata,
}
dispatchMsg({ type: 'ADD', message: msg })
return msg
}, [])
const getChannelMessages = useCallback((channelId: string) => {
return messagesByChannel[channelId] ?? []
}, [messagesByChannel])
const getUnreadCount = useCallback((channelId: string) => {
return unreadCounts[channelId] ?? 0
}, [unreadCounts])
const markRead = useCallback((channelId: string) => {
setReadTimestamps(prev => ({ ...prev, [channelId]: Date.now() }))
}, [])
const markSenderDeleted = useCallback((agentId: string) => {
dispatchMsg({ type: 'MARK_SENDER_DELETED', senderId: agentId })
}, [])
const removeParticipant = useCallback((agentId: string) => {
dispatchCh({ type: 'REMOVE_PARTICIPANT', agentId })
}, [])
const removeSessionData = useCallback((taskId: string) => {
dispatchCh({ type: 'REMOVE', channelId: `session:${taskId}` })
dispatchMsg({ type: 'REMOVE_BY_TASK_ID', taskId })
}, [])
const clear = useCallback(() => {
dispatchCh({ type: 'CLEAR' })
dispatchMsg({ type: 'CLEAR' })
setReadTimestamps({})
}, [])
const initFromBackend = useCallback((projectId: string, chs: ChatChannel[], msgs: ChatMessage[]) => {
const nextProjectId = projectId || 'default'
const projectChanged = nextProjectId !== scopeProjectId
setScopeProjectId(nextProjectId)
dispatchCh({ type: 'SET', channels: chs })
// Backend `collab_sync` / `collab_sync_push` payloads carry the
// "current window" of messages, not the full history. Dispatching
// SET here would drop any older messages that were loaded earlier
// via `session_detail` (limit: 200) — the very first user-typed
// project-goal message sits at the top of the channel and is the
// first to fall out of this window. Every subsequent push would
// then wipe it, and the next `session_detail` refresh would merge
// it back, producing a ~1s flicker cycle on the top of the list.
// MERGE instead so the snapshot is additive, not destructive.
if (projectChanged) {
dispatchMsg({ type: 'SET', messages: msgs })
} else {
dispatchMsg({ type: 'MERGE', messages: msgs })
}
// Mark all loaded messages as read so they don't show as unread (#17)
const latest: Record<string, number> = {}
for (const m of msgs) {
if (!latest[m.channelId] || m.timestamp > latest[m.channelId]) {
latest[m.channelId] = m.timestamp
}
}
setReadTimestamps(prev => projectChanged ? latest : ({ ...prev, ...latest }))
}, [scopeProjectId])
const addMessageFromBackend = useCallback((msg: ChatMessage) => {
dispatchMsg({ type: 'ADD', message: msg })
}, [])
const mergeMessagesFromBackend = useCallback((msgs: ChatMessage[]) => {
dispatchMsg({ type: 'MERGE', messages: msgs })
}, [])
const addChannelFromBackend = useCallback((ch: ChatChannel) => {
dispatchCh({ type: 'ADD', channel: ch })
}, [])
return useMemo(() => ({
scopeProjectId, channels, messages,
sendMessage, getChannelMessages, getUnreadCount, markRead,
markSenderDeleted, removeParticipant, removeSessionData, clear, initFromBackend,
addMessageFromBackend, mergeMessagesFromBackend, addChannelFromBackend,
}), [scopeProjectId, channels, messages, sendMessage, getChannelMessages, getUnreadCount, markRead,
markSenderDeleted, removeParticipant, removeSessionData, clear, initFromBackend,
addMessageFromBackend, mergeMessagesFromBackend, addChannelFromBackend])
}
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { DeliveryFeedbackPanel } from './DeliveryFeedbackPanel'
const markup = renderToStaticMarkup(
React.createElement(DeliveryFeedbackPanel, {
meta: {
checkpoint_type: 'company_delivery_feedback',
checkpoint_id: 'cp-delivery',
work_item_projection_title: 'CEO Delivery',
feedback_scope: 'final',
prompt: 'This final delivery is ready for review.\n\n- Inspect the build\n- Confirm acceptance\n\n```txt\nready\n```',
options: [
{ id: 'approve', label: 'Fully Agree / 完全同意' },
{ id: 'ignore', label: 'Ignore / 忽略' },
{ id: 'feedback', label: 'Feedback / 反馈' },
],
permission_requests: [{ id: 'perm-1' }],
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(markup, /CEO Delivery \(for self-evolution\)/)
assert.match(markup, /Fully Agree/)
assert.match(markup, /Ignore/)
assert.match(markup, /Feedback for self-evolution/)
assert.match(markup, /<li>Inspect the build<\/li>/)
assert.match(markup, /<code class="language-txt">/)
assert.match(markup, /<summary>Runtime State<\/summary>/)
assert.equal((markup.match(/class="ckpt-btn /g) ?? []).length, 3)
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'DeliveryFeedbackPanel.tsx'), 'utf8')
assert.match(src, /metadata\.self_evolution_trigger = true/, 'delivery card replies must explicitly trigger self-evolution')
assert.match(src, /kind === 'approve' \|\| kind === 'feedback'/, 'ignore must not trigger self-evolution metadata')
assert.match(src, /buildReplyMetadata\('ignore'\)/, 'delivery card must send an explicit ignore checkpoint reply')
assert.match(src, /submittingAction/, 'delivery card actions must be locally locked while awaiting server metadata')
assert.match(src, /disabled=\{actionsDisabled\}/, 'delivery card must disable controls immediately after a card action')
assert.doesNotMatch(src, /ckpt-btn-deny/, 'delivery self-evolution card must not render a deny action')
assert.doesNotMatch(src, /localResponded|setLocalResponded/, 'panel must wait for server checkpoint metadata before showing responded state')
console.log('DeliveryFeedbackPanel.test.tsx: OK (markdown delivery review panel)')
@@ -0,0 +1,165 @@
import React, { useCallback, useMemo, useState } from 'react'
import type { ChatMessageMeta, CheckpointReplyMetadata } from '../types/chat'
import { MarkdownBody } from './MarkdownBody'
interface DeliveryFeedbackPanelProps {
meta: ChatMessageMeta
onReply: (text: string, metadata?: CheckpointReplyMetadata) => void
responded: boolean
}
function firstLine(text: string): string {
return text.split('\n').map((line) => line.trim()).find(Boolean) ?? text
}
function checkpointStatusLabel(status: string): string {
switch (status) {
case 'ignored':
return 'Ignored'
case 'timeout':
case 'timed_out':
case 'expired':
return 'Expired'
case 'stale':
case 'invalid':
return 'Inactive'
case 'superseded':
return 'Superseded'
case 'cancelled':
case 'canceled':
return 'Cancelled'
case 'resolved':
return 'Resolved'
default:
return 'Responded'
}
}
export const DeliveryFeedbackPanel = React.memo(function DeliveryFeedbackPanel({
meta, onReply, responded,
}: DeliveryFeedbackPanelProps) {
const isResponded = responded
const [feedback, setFeedback] = useState('')
const [submittingAction, setSubmittingAction] = useState<CheckpointReplyMetadata['checkpoint_reply_kind'] | null>(null)
const checkpointStatus = String(meta.checkpoint_status ?? '').trim().toLowerCase()
const resolvedLabel = checkpointStatusLabel(checkpointStatus)
const prompt = String(meta.prompt ?? meta.summary ?? '').trim()
const baseTitle = String(meta.work_item_projection_title ?? firstLine(prompt) ?? 'Human Review').trim() || 'Human Review'
const title = `${baseTitle} (for self-evolution)`
const summary = String(meta.summary ?? '').trim()
const activeSubagents = useMemo(
() => (meta.active_subagents ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.active_subagents],
)
const permissionRequests = useMemo(
() => (meta.permission_requests ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.permission_requests],
)
const worktreePath = String(meta.worktree_path ?? '').trim()
const hasRuntimeState = activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath
const actionsDisabled = isResponded || submittingAction !== null
const buildReplyMetadata = useCallback((kind: NonNullable<CheckpointReplyMetadata['checkpoint_reply_kind']>, text = ''): CheckpointReplyMetadata => {
const checkpointId = String(meta.checkpoint_id ?? '').trim()
if (!checkpointId) {
throw new Error('Delivery self-evolution reply requires checkpoint_id metadata.')
}
const metadata: CheckpointReplyMetadata = {
response_to_checkpoint_id: checkpointId,
response_to_checkpoint_type: 'company_delivery_feedback',
checkpoint_reply_kind: kind,
}
if (kind === 'approve' || kind === 'feedback') {
metadata.self_evolution_trigger = true
metadata.human_feedback_text = text
}
return metadata
}, [meta.checkpoint_id])
const handleApprove = useCallback(() => {
if (actionsDisabled) return
const metadata = buildReplyMetadata('approve')
setSubmittingAction('approve')
onReply('I fully agree with this delivery.', metadata)
}, [actionsDisabled, buildReplyMetadata, onReply])
const handleFeedback = useCallback(() => {
const text = feedback.trim()
if (actionsDisabled || !text) return
const metadata = buildReplyMetadata('feedback', text)
setSubmittingAction('feedback')
onReply(text, metadata)
setFeedback('')
}, [actionsDisabled, buildReplyMetadata, feedback, onReply])
const handleIgnore = useCallback(() => {
if (actionsDisabled) return
const metadata = buildReplyMetadata('ignore')
setSubmittingAction('ignore')
onReply('Ignore this self-evolution review.', metadata)
}, [actionsDisabled, buildReplyMetadata, onReply])
return (
<div className="ckpt-panel ckpt-delivery-feedback">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-user-input">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 1.5L14 5v4.5c0 2.25-1.8 4.25-6 5-4.2-.75-6-2.75-6-5V5L8 1.5Z" />
<path d="M5.5 8.25L7.25 10l3.25-4" />
</svg>
</div>
<div className="ckpt-title">{title}</div>
<span className="ckpt-badge ckpt-badge-scope">
{String(meta.feedback_scope ?? 'final').replace(/_/g, ' ')}
</span>
{isResponded && <span className="ckpt-badge ckpt-badge-responded">{resolvedLabel}</span>}
</div>
{summary && summary !== title && (
<div className="ckpt-section">
<div className="ckpt-section-title">Summary</div>
<MarkdownBody content={summary} className="ckpt-markdown" />
</div>
)}
{prompt && (
<div className="ckpt-section">
<div className="ckpt-section-title">Review Request</div>
<MarkdownBody content={prompt} className="ckpt-markdown" />
</div>
)}
{hasRuntimeState && (
<details className="ckpt-runtime-details">
<summary>Runtime State</summary>
<div className="ckpt-runtime-body">
{worktreePath && <div>Worktree: <code>{worktreePath}</code></div>}
{activeSubagents.length > 0 && <div>Active subagents: {activeSubagents.length}</div>}
{permissionRequests.length > 0 && <div>Pending permission records: {permissionRequests.length}</div>}
</div>
</details>
)}
{!isResponded && (
<div className="ckpt-actions ckpt-actions-inline-feedback">
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove} disabled={actionsDisabled}>
Fully Agree
</button>
<button className="ckpt-btn ckpt-btn-cancel" onClick={handleIgnore} disabled={actionsDisabled}>
Ignore
</button>
<textarea
className="ckpt-feedback-input ckpt-feedback-inline-input"
placeholder="Feedback for self-evolution..."
value={feedback}
onChange={event => setFeedback(event.target.value)}
disabled={actionsDisabled}
rows={2}
/>
<button className="ckpt-btn ckpt-btn-feedback" onClick={handleFeedback} disabled={actionsDisabled || !feedback.trim()}>
Send Feedback
</button>
</div>
)}
</div>
)
})
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { EscalationPanel } from './EscalationPanel'
const markup = renderToStaticMarkup(
React.createElement(EscalationPanel, {
meta: {
checkpoint_type: 'company_work_item_gate',
checkpoint_id: 'cp-gate',
prompt: 'Gate Review\n\n- Confirm the artifact exists\n- Confirm tests passed\n\n```json\n{"ok": true}\n```',
summary: 'Review the gate evidence.',
options: [
{ id: 'approve', label: 'Approve' },
{ id: 'deny', label: 'Deny' },
],
active_subagents: [{ id: 'sub-1' }],
worktree_path: '/tmp/work',
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(markup, /Gate Review/)
assert.match(markup, /<li>Confirm the artifact exists<\/li>/)
assert.match(markup, /<code class="language-json">/)
assert.match(markup, /<summary>Runtime State<\/summary>/)
assert.equal((markup.match(/<button/g) ?? []).length, 3)
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'EscalationPanel.tsx'), 'utf8')
assert.doesNotMatch(src, /localResponded|setLocalResponded/, 'panel must wait for server checkpoint metadata before showing responded state')
console.log('EscalationPanel.test.tsx: OK (markdown gate panel)')
@@ -0,0 +1,128 @@
import React, { useCallback, useMemo } from 'react'
import type { ChatMessageMeta, HumanEscalationOption } from '../types/chat'
import { MarkdownBody } from './MarkdownBody'
interface EscalationPanelProps {
meta: ChatMessageMeta
onReply: (text: string) => void
responded: boolean
}
function firstLine(text: string): string {
return text.split('\n').map((line) => line.trim()).find(Boolean) ?? text
}
function checkpointStatusLabel(status: string): string {
switch (status) {
case 'timeout':
case 'timed_out':
case 'expired':
return 'Expired'
case 'stale':
case 'invalid':
return 'Inactive'
case 'cancelled':
case 'canceled':
return 'Cancelled'
case 'resolved':
return 'Resolved'
default:
return 'Responded'
}
}
export const EscalationPanel = React.memo(function EscalationPanel({
meta, onReply, responded,
}: EscalationPanelProps) {
const isResponded = responded
const checkpointStatus = String(meta.checkpoint_status ?? '').trim().toLowerCase()
const resolvedLabel = checkpointStatusLabel(checkpointStatus)
const prompt = String(meta.prompt ?? meta.summary ?? '')
const lines = useMemo(
() => prompt.split('\n').map((line) => line.trim()).filter(Boolean),
[prompt],
)
const title = firstLine(prompt).replace(/^\[[^\]]+\]\s*/, '') || 'Action Required'
const details = lines.slice(1).join('\n').trim()
const summary = String(meta.summary ?? '').trim()
const options = (meta.options ?? []).filter((opt): opt is HumanEscalationOption => !!opt?.id)
const activeSubagents = useMemo(
() => (meta.active_subagents ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.active_subagents],
)
const permissionRequests = useMemo(
() => (meta.permission_requests ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.permission_requests],
)
const worktreePath = String(meta.worktree_path ?? '').trim()
const hasRuntimeState = activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath
const handleReply = useCallback((option: HumanEscalationOption) => {
if (isResponded) return
onReply(option.label || option.id)
}, [isResponded, onReply])
return (
<div className="ckpt-panel ckpt-escalation">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-escalation">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 1.5L14.5 13H1.5L8 1.5Z" />
<path d="M8 5.5V9" />
<circle cx="8" cy="11.5" r="0.75" fill="currentColor" stroke="none" />
</svg>
</div>
<div className="ckpt-title">{title}</div>
<span className="ckpt-badge ckpt-badge-scope">
{String(meta.escalation_type ?? 'decision_needed').replace(/_/g, ' ')}
</span>
{isResponded && <span className="ckpt-badge ckpt-badge-responded">{resolvedLabel}</span>}
</div>
{summary && summary !== title && (
<div className="ckpt-section">
<div className="ckpt-section-title">Summary</div>
<MarkdownBody content={summary} className="ckpt-markdown" />
</div>
)}
{details && (
<div className="ckpt-section">
<div className="ckpt-section-title">Request</div>
<MarkdownBody content={details} className="ckpt-markdown" />
</div>
)}
{hasRuntimeState && (
<details className="ckpt-runtime-details">
<summary>Runtime State</summary>
<div className="ckpt-runtime-body">
{worktreePath && <div>Worktree: <code>{worktreePath}</code></div>}
{activeSubagents.length > 0 && <div>Active subagents: {activeSubagents.length}</div>}
{permissionRequests.length > 0 && <div>Pending permission records: {permissionRequests.length}</div>}
</div>
</details>
)}
{!isResponded && options.length > 0 && (
<div className="ckpt-actions ckpt-escalation-actions">
{options.map((option) => (
<button
key={option.id}
className={`ckpt-btn ${option.id.includes('deny') ? 'ckpt-btn-deny' : 'ckpt-btn-approve'}`}
onClick={() => handleReply(option)}
>
{option.label || option.id}
</button>
))}
</div>
)}
{!isResponded && meta.default_action && (
<div className="ckpt-escalation-hint">
Default on timeout: <code>{meta.default_action}</code>
</div>
)}
</div>
)
})
@@ -0,0 +1,122 @@
import React, { useCallback, useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { IconCheck, IconCopy } from './SvgIcons'
const CODE_BLOCK_MAX_LINES = 30
const CODE_BLOCK_PEEK_LINES = 10
function CodeBlock({ className, children }: { className?: string; children?: React.ReactNode }) {
const [copied, setCopied] = useState(false)
const [expanded, setExpanded] = useState(false)
const text = String(children).replace(/\n$/, '')
const lang = className?.replace('language-', '') || ''
const lines = text.split('\n')
const needsTruncation = lines.length > CODE_BLOCK_MAX_LINES
const omittedCount = needsTruncation ? lines.length - CODE_BLOCK_PEEK_LINES * 2 : 0
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}, [text])
return (
<div className="code-block-wrap">
<div className="code-block-header">
<span className="code-block-lang">{lang || 'code'}{needsTruncation ? ` (${lines.length} lines)` : ''}</span>
<button className="code-block-copy" onClick={handleCopy}>
{copied ? <><IconCheck /> <span>Copied</span></> : <><IconCopy /> <span>Copy</span></>}
</button>
</div>
<pre><code className={className}>
{needsTruncation && !expanded ? (
<>
{lines.slice(0, CODE_BLOCK_PEEK_LINES).join('\n') + '\n'}
<span className="code-block-omitted" onClick={() => setExpanded(true)}>
{'... +'}{omittedCount}{' lines (click to expand)'}
</span>
{'\n' + lines.slice(-CODE_BLOCK_PEEK_LINES).join('\n')}
</>
) : (
text
)}
</code></pre>
{needsTruncation && expanded && (
<button className="code-block-collapse-btn" onClick={() => setExpanded(false)}>
Collapse ({lines.length} lines)
</button>
)}
</div>
)
}
const mdComponents = {
code({ className, children, ...props }: any) {
const isBlock = className?.startsWith('language-')
if (isBlock) {
return <CodeBlock className={className}>{children}</CodeBlock>
}
return <code className={className} {...props}>{children}</code>
},
}
const MSG_COLLAPSE_CHAR_THRESHOLD = 3000
const MSG_COLLAPSE_LINE_THRESHOLD = 60
const MSG_PREVIEW_CHARS = 800
function shouldCollapseContent(content: string): boolean {
if (content.length > MSG_COLLAPSE_CHAR_THRESHOLD) return true
let newlines = 0
for (let i = 0; i < content.length; i++) {
if (content[i] === '\n' && ++newlines >= MSG_COLLAPSE_LINE_THRESHOLD) return true
}
return false
}
function truncatePreview(content: string): string {
const cut = content.lastIndexOf('\n', MSG_PREVIEW_CHARS)
return content.slice(0, cut > MSG_PREVIEW_CHARS / 2 ? cut : MSG_PREVIEW_CHARS)
}
type MarkdownCollapseMode = 'auto' | 'never'
export const MarkdownBody = React.memo(function MarkdownBody({
content,
className = 'msg-content-agent',
collapseMode = 'auto',
}: {
content: string
className?: string
collapseMode?: MarkdownCollapseMode
}) {
const collapsible = collapseMode !== 'never' && shouldCollapseContent(content)
const [collapsed, setCollapsed] = useState(collapsible)
useEffect(() => {
setCollapsed(collapseMode !== 'never' && shouldCollapseContent(content))
}, [collapseMode, content])
const displayContent = collapsed ? truncatePreview(content) : content
const lineCount = content.split('\n').length
const charCount = content.length
return (
<div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{displayContent}
</ReactMarkdown>
{collapsible && collapsed && (
<button className="msg-collapse-toggle" onClick={() => setCollapsed(false)}>
Show more ({lineCount} lines, {(charCount / 1000).toFixed(1)}k chars)
</button>
)}
{collapsible && !collapsed && (
<button className="msg-collapse-toggle" onClick={() => setCollapsed(true)}>
Show less
</button>
)}
</div>
)
})
@@ -0,0 +1,815 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import {
IconArrowRight,
IconBuilding,
IconCheck,
IconClose,
IconLock,
IconPaperclip,
IconSend,
IconSparkles,
IconStop,
IconUserRound,
} from './SvgIcons'
import type { OutgoingAttachmentPayload } from '../types/chat'
import type { TaskPreferredAgent } from '../types/kanban'
import type { SavedOrgSummary } from '../types/visual'
import { getContextUsageMetrics } from '../lib/contextUsage'
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_TOTAL_SIZE = 20 * 1024 * 1024
const ACCEPTED_TYPES = 'image/*,video/mp4,video/mpeg,video/quicktime,video/webm,.mp4,.mpeg,.mpg,.mov,.webm,.txt,.md,.pdf,.csv,.json,.yaml,.yml,.py,.js,.ts,.tsx,.jsx,.html,.css,.java,.c,.cpp,.go,.rs,.rb,.sh,.xml,.toml,.docx,.xlsx,.pptx'
type AttachmentTransferState = 'reading' | 'ready' | 'error'
type ComposerExecMode = 'task' | 'company' | 'org' | 'custom'
type ComposerCompanyProfile = 'corporate' | 'custom'
type ComposerModeOption = 'task' | 'company'
type CompanyArchitectureOption = '' | 'corporate' | `org:${string}`
const TASK_AGENT_LABELS: Record<TaskPreferredAgent, string> = {
native: 'OpenOPC Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
interface PendingAttachment {
id: string
file: File
filename: string
mime_type: string
size_bytes: number
preview_url: string
base64_data?: string
progress_percent: number
transfer_state: AttachmentTransferState
error?: string
}
function readFileAsBase64(file: File, onProgress?: (progress: number) => void): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onprogress = (event) => {
if (!event.lengthComputable) return
onProgress?.(Math.round((event.loaded / event.total) * 100))
}
reader.onload = () => {
const result = reader.result as string
onProgress?.(100)
resolve(result.split(',')[1] || '')
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
function attachmentBadgeLabel(mime: string, filename: string): string {
const extension = filename.includes('.') ? filename.split('.').pop()?.toUpperCase() ?? '' : ''
if (mime.startsWith('image/')) return 'IMG'
if (mime.startsWith('video/')) return 'VID'
if (mime === 'application/pdf') return 'PDF'
if (mime.includes('wordprocessingml')) return 'DOC'
if (mime.includes('spreadsheetml') || extension === 'CSV') return 'XLS'
if (mime.includes('presentationml')) return 'PPT'
if (mime.includes('json')) return 'JSON'
if (mime.includes('yaml') || extension === 'YML' || extension === 'YAML') return 'YAML'
if (mime.startsWith('text/')) return 'TXT'
if (['PY', 'TS', 'TSX', 'JS', 'JSX', 'GO', 'RS', 'RB', 'JAVA', 'C', 'CPP', 'HTML', 'CSS', 'SH'].includes(extension)) return extension
return extension || 'FILE'
}
function attachmentToneClass(mime: string, filename: string): string {
const label = attachmentBadgeLabel(mime, filename)
if (label === 'IMG') return 'image'
if (label === 'VID') return 'video'
if (label === 'PDF') return 'pdf'
if (label === 'DOC' || label === 'XLS' || label === 'PPT') return 'office'
if (label === 'JSON' || label === 'YAML') return 'data'
if (label === 'TXT') return 'text'
if (['PY', 'TS', 'TSX', 'JS', 'JSX', 'GO', 'RS', 'RB', 'JAVA', 'C', 'CPP', 'HTML', 'CSS', 'SH'].includes(label)) return 'code'
return 'generic'
}
function AttachmentProgressRing({
progress,
state,
error,
}: {
progress: number
state: AttachmentTransferState
error?: string
}) {
const radius = 11
const circumference = 2 * Math.PI * radius
const normalized = error ? 100 : state === 'ready' ? 100 : Math.max(0, Math.min(progress, 100))
const dashOffset = circumference * (1 - normalized / 100)
return (
<span
className={`attachment-progress-ring${error ? ' error' : state === 'ready' ? ' ready' : ''}`}
aria-label={error ? 'Attachment preparation failed' : state === 'ready' ? 'Attachment ready to send' : `Preparing attachment ${normalized}%`}
role="img"
>
<svg viewBox="0 0 28 28" aria-hidden="true">
<circle className="attachment-progress-track" cx="14" cy="14" r={radius} />
<circle
className="attachment-progress-value"
cx="14"
cy="14"
r={radius}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
<span className="attachment-progress-center">
{error ? '!' : state === 'ready' ? <IconCheck /> : normalized}
</span>
</span>
)
}
function ContextRing({
usedPct,
usedTokens,
windowTokens,
}: {
usedPct: number
usedTokens?: number
windowTokens?: number
}) {
const radius = 11
const circumference = 2 * Math.PI * radius
const clamped = Math.max(0, Math.min(usedPct, 100))
const dashOffset = circumference * (1 - clamped / 100)
const isLow = clamped >= 80
const isCritical = clamped >= 90
const usageLabel = typeof usedTokens === 'number' && typeof windowTokens === 'number'
? `${clamped}% used (${usedTokens.toLocaleString()}/${windowTokens.toLocaleString()})`
: `${clamped}% used`
return (
<span
className={`composer-context-ring${isLow ? ' low' : ''}${isCritical ? ' critical' : ''}`}
title={`Context window: ${usageLabel}`}
aria-label={`Context window ${usageLabel}`}
role="img"
>
<svg viewBox="0 0 28 28" aria-hidden="true">
<circle className="composer-context-ring-track" cx="14" cy="14" r={radius} />
<circle
className="composer-context-ring-value"
cx="14"
cy="14"
r={radius}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
<span className="composer-context-ring-label">{clamped}</span>
</span>
)
}
interface MessageComposerProps {
disabled?: boolean
placeholder?: string
channelId?: string
execMode?: string
companyProfile?: string
taskPreferredAgent?: TaskPreferredAgent
agentStatus?: string
currentTool?: string
displayTool?: string
activeAgentCount?: number
runtimeControlState?: 'running' | 'suspending' | 'suspended' | 'resuming' | 'idle'
canStop?: boolean
autoFocus?: boolean
contextTokens?: number
contextWindow?: number
contextRemainingPct?: number
savedOrgs?: SavedOrgSummary[] | null
activeSavedOrg?: string | null
selectedOrgId?: string | null
/**
* When true the mode/agent pickers freeze into a read-only chip: the chat
* has committed to its current execution identity (i.e. messages have been
* sent) and the identity is no longer changeable from this composer. The
* chip exposes a hover hint pointing users at "start a new chat" instead.
*/
lockedMode?: boolean
onSend: (content: string, attachments?: OutgoingAttachmentPayload[]) => void
onModeChange?: (mode: ComposerExecMode, profile?: ComposerCompanyProfile, orgId?: string) => void
onTaskAgentChange?: (preferredAgent: TaskPreferredAgent) => void
onSavedOrgsRefresh?: () => void
onSavedOrgLoad?: (name: string) => void
onStop?: () => void
/**
* Spawn a brand-new chat in the requested mode, preserving the user inside
* the same project. Wired from the locked-mode chip popover so users can
* "continue in a different mode" without having to find the global new-chat
* button. When omitted, the popover degrades gracefully to text-only.
*/
onContinueInNewChat?: (mode: ComposerExecMode, profile?: ComposerCompanyProfile, orgId?: string) => void
}
interface ModeAlternative {
key: string
mode: ComposerExecMode
profile?: ComposerCompanyProfile
orgId?: string
label: string
description: string
icon: ReactElement
}
function savedOrgLabel(org: SavedOrgSummary): string {
return org.organization_name?.trim() || org.name
}
export function MessageComposer({
disabled,
placeholder,
channelId,
execMode,
companyProfile,
taskPreferredAgent = 'native',
agentStatus,
currentTool,
displayTool,
activeAgentCount,
runtimeControlState,
canStop,
autoFocus = true,
contextTokens,
contextWindow,
contextRemainingPct,
savedOrgs,
activeSavedOrg,
selectedOrgId,
lockedMode = false,
onSend,
onModeChange,
onTaskAgentChange,
onSavedOrgsRefresh,
onSavedOrgLoad,
onStop,
onContinueInNewChat,
}: MessageComposerProps) {
const [text, setText] = useState('')
const [focused, setFocused] = useState(false)
const [pending, setPending] = useState<PendingAttachment[]>([])
const [lightbox, setLightbox] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const isStopping = runtimeControlState === 'suspending'
const isRuntimeActive = runtimeControlState === 'running'
|| runtimeControlState === 'suspending'
|| runtimeControlState === 'resuming'
const isSuspended = runtimeControlState === 'suspended'
const hasRuntimeControlState = runtimeControlState != null
const isWorking = isRuntimeActive || (!hasRuntimeControlState && agentStatus != null && agentStatus !== 'idle')
const stopEnabled = (canStop ?? true) && !isStopping && !isSuspended
const contextUsage = useMemo(
() => getContextUsageMetrics({ contextTokens, contextWindow, contextRemainingPct }),
[contextRemainingPct, contextTokens, contextWindow],
)
useEffect(() => {
const ta = textareaRef.current
if (!ta) return
ta.style.height = 'auto'
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`
}, [text])
useEffect(() => {
setText('')
setPending(prev => {
prev.forEach(attachment => {
if (attachment.preview_url) URL.revokeObjectURL(attachment.preview_url)
})
return []
})
if (!disabled && autoFocus) setTimeout(() => textareaRef.current?.focus(), 50)
}, [channelId, disabled, autoFocus])
const updateAttachment = useCallback((id: string, updater: (attachment: PendingAttachment) => PendingAttachment) => {
setPending(prev => prev.map(attachment => attachment.id === id ? updater(attachment) : attachment))
}, [])
const prepareAttachment = useCallback(async (attachmentId: string, file: File) => {
try {
const base64 = await readFileAsBase64(file, (progress) => {
updateAttachment(attachmentId, (attachment) => ({
...attachment,
progress_percent: progress,
transfer_state: 'reading',
}))
})
updateAttachment(attachmentId, (attachment) => ({
...attachment,
base64_data: base64,
progress_percent: 100,
transfer_state: 'ready',
}))
} catch {
updateAttachment(attachmentId, (attachment) => ({
...attachment,
error: 'Failed to prepare file',
progress_percent: 0,
transfer_state: 'error',
}))
}
}, [updateAttachment])
const addFiles = useCallback((files: FileList | File[]) => {
const arr = Array.from(files)
let runningTotal = pending.reduce((sum, attachment) => sum + attachment.size_bytes, 0)
const newPending: PendingAttachment[] = arr.map(file => {
let error: string | undefined
if (file.size > MAX_FILE_SIZE) error = `Too large (${formatSize(file.size)})`
else if (runningTotal + file.size > MAX_TOTAL_SIZE) error = 'Total size exceeds 20MB'
else runningTotal += file.size
return {
id: crypto.randomUUID(),
file,
filename: file.name || 'upload',
mime_type: file.type || 'application/octet-stream',
size_bytes: file.size,
preview_url: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
progress_percent: 0,
transfer_state: error ? 'error' : 'reading',
error,
}
})
setPending(prev => [...prev, ...newPending])
newPending
.filter(attachment => !attachment.error)
.forEach(attachment => { void prepareAttachment(attachment.id, attachment.file) })
}, [pending, prepareAttachment])
const removeAttachment = useCallback((id: string) => {
setPending(prev => {
const item = prev.find(attachment => attachment.id === id)
if (item?.preview_url) URL.revokeObjectURL(item.preview_url)
return prev.filter(attachment => attachment.id !== id)
})
}, [])
const handleSend = useCallback(() => {
if (disabled || isWorking) return
const content = text.trim()
const preparing = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'reading')
const ready = pending.filter(attachment => !attachment.error && !!attachment.base64_data)
if (preparing.length > 0) return
if (!content && ready.length === 0) return
const attachments = ready.map(attachment => ({
filename: attachment.filename,
data: attachment.base64_data!,
mime_type: attachment.mime_type,
}))
onSend(content || 'Sent with attachments', attachments.length ? attachments : undefined)
setText('')
pending.forEach(attachment => {
if (attachment.preview_url) URL.revokeObjectURL(attachment.preview_url)
})
setPending([])
}, [disabled, isWorking, text, pending, onSend])
const handlePaste = useCallback((event: React.ClipboardEvent) => {
const files = event.clipboardData?.files
if (files && files.length > 0) {
event.preventDefault()
addFiles(files)
}
}, [addFiles])
const normalizedCompanyProfile = String(companyProfile ?? '').trim().toLowerCase()
const normalizedExecMode: ComposerExecMode = execMode === 'company'
? 'company'
: execMode === 'org' || execMode === 'custom' || normalizedCompanyProfile === 'custom'
? 'org'
: 'task'
const savedOrgOptions = useMemo(
() => (savedOrgs ?? []).filter(org => !!org.name && org.name !== 'corporate'),
[savedOrgs],
)
const activeSavedOrgOption = activeSavedOrg
? savedOrgOptions.find(org => org.name === activeSavedOrg)
: undefined
const selectedOrgOption = selectedOrgId
? savedOrgOptions.find(org => org.name === selectedOrgId)
: undefined
const activeSavedOrgLabel = activeSavedOrgOption
? savedOrgLabel(activeSavedOrgOption)
: activeSavedOrg || ''
const selectedOrgLabel = selectedOrgOption
? savedOrgLabel(selectedOrgOption)
: selectedOrgId || activeSavedOrgLabel
const selectedOrgValue = selectedOrgOption?.name || selectedOrgId || activeSavedOrgOption?.name || ''
const selectedModeOption: ComposerModeOption = normalizedExecMode === 'task' ? 'task' : 'company'
const selectedCompanyArchitecture: CompanyArchitectureOption = normalizedExecMode === 'org'
? (selectedOrgValue ? `org:${selectedOrgValue}` : '')
: 'corporate'
const companyArchitectureLabel = normalizedExecMode === 'org'
? selectedOrgLabel
? `Company / ${selectedOrgLabel}`
: 'Company / Saved org'
: 'Company / Corporate'
const modeLabel = selectedModeOption === 'task' ? 'Task' : companyArchitectureLabel
const showModePicker = !!execMode && !!onModeChange
const showTaskAgentPicker = normalizedExecMode === 'task' && !!onTaskAgentChange
// Build the list of "Continue in a new chat" alternatives, excluding the
// mode the current chat is already locked to. We surface up to three options
// so the popover stays compact; the order is stable so users build muscle
// memory for it.
const continueAlternatives: ModeAlternative[] = useMemo(() => {
const currentKey = normalizedExecMode === 'task'
? 'task'
: normalizedExecMode === 'org'
? `org:${selectedOrgValue || 'selected'}`
: 'company:corporate'
const continueOrgName = selectedOrgValue || activeSavedOrgOption?.name || ''
const continueOrgLabel = selectedOrgLabel || activeSavedOrgLabel || continueOrgName
const all: ModeAlternative[] = [
{
key: 'task',
mode: 'task',
label: 'Task',
description: 'A single agent handles the request',
icon: <IconUserRound />,
},
{
key: 'company:corporate',
mode: 'company',
profile: 'corporate',
label: 'Company / Corporate',
description: 'A team of roles collaborates',
icon: <IconBuilding />,
},
]
if (continueOrgName) {
all.push({
key: `org:${continueOrgName}`,
mode: 'org',
profile: 'custom',
orgId: continueOrgName,
label: `Company / ${continueOrgLabel}`,
description: 'A saved company architecture collaborates',
icon: <IconSparkles />,
})
}
return all.filter(option => option.key !== currentKey)
}, [activeSavedOrgLabel, activeSavedOrgOption?.name, normalizedExecMode, selectedOrgLabel, selectedOrgValue])
const preparingAttachmentCount = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'reading').length
const readyAttachmentCount = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'ready').length
const visibleTool = displayTool || currentTool
const statusText = (() => {
if (!isWorking) return null
if (isStopping) return 'Stopping...'
if (activeAgentCount && activeAgentCount > 1) return `${activeAgentCount} agents working`
if (visibleTool) return `Running ${visibleTool}`
if (agentStatus === 'reflecting') return 'Thinking...'
return 'Working...'
})()
return (
<>
<div className={`msg-composer${focused ? ' focused' : ''}${isWorking ? ' working' : ''}`}>
{isWorking && statusText && (
<div className="composer-status">
<div className="composer-status-indicator" />
<span className="composer-status-text">{statusText}</span>
<button className="composer-stop-btn" onClick={onStop} title="Stop" disabled={!stopEnabled || !onStop}>
<IconStop />
<span>{isStopping ? 'Stopping...' : 'Stop'}</span>
</button>
</div>
)}
{pending.length > 0 && (
<div className="composer-attachments">
{pending.map(attachment => (
<div key={attachment.id} className={`attachment-chip${attachment.error ? ' error' : ''}`}>
{attachment.preview_url ? (
<img
className="attachment-thumb"
src={attachment.preview_url}
alt={attachment.filename}
onClick={() => setLightbox(attachment.preview_url)}
/>
) : (
<span className={`attachment-file-icon tone-${attachmentToneClass(attachment.mime_type, attachment.filename)}`}>
{attachmentBadgeLabel(attachment.mime_type, attachment.filename)}
</span>
)}
<span className="attachment-chip-info">
<span className="attachment-chip-name">{attachment.filename}</span>
<span className="attachment-chip-size">
{attachment.error
? attachment.error
: attachment.transfer_state === 'reading'
? `Preparing ${attachment.progress_percent}%`
: `${formatSize(attachment.size_bytes)} - Ready`}
</span>
</span>
<AttachmentProgressRing
progress={attachment.progress_percent}
state={attachment.transfer_state}
error={attachment.error}
/>
<button
className="attachment-chip-remove"
onClick={() => removeAttachment(attachment.id)}
title="Remove"
>
<IconClose />
</button>
</div>
))}
</div>
)}
<div className="composer-input-area">
<textarea
ref={textareaRef}
value={text}
onChange={event => setText(event.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onPaste={handlePaste}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}}
placeholder={placeholder ?? 'Message...'}
rows={1}
disabled={disabled}
/>
<div className="composer-bottom">
<button
className="composer-attach-btn"
onClick={() => fileInputRef.current?.click()}
title="Attach files"
disabled={disabled}
>
<IconPaperclip />
</button>
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_TYPES}
style={{ display: 'none' }}
onChange={event => {
if (event.target.files) addFiles(event.target.files)
event.target.value = ''
}}
/>
{(showModePicker || execMode) && (
<div className="composer-config-group" data-locked={lockedMode ? 'true' : undefined}>
{showModePicker && !lockedMode ? (
<label
className="composer-mode-inline"
data-kind="mode"
title="Execution mode for this chat and new work started from it"
>
<span className="composer-mode-inline-label">Mode</span>
<span className="composer-mode-select-wrap">
<select
className="composer-mode-select"
value={selectedModeOption}
onChange={(event) => {
const value = event.target.value as ComposerModeOption
if (value === 'task') {
onModeChange?.('task')
return
}
onModeChange?.('company', 'corporate')
}}
onFocus={() => onSavedOrgsRefresh?.()}
onPointerDown={() => onSavedOrgsRefresh?.()}
disabled={disabled}
aria-label="Execution mode"
>
<option value="task">Task</option>
<option value="company">Company</option>
</select>
</span>
</label>
) : showModePicker && lockedMode ? (
<div
className="composer-mode-chip"
data-kind="mode"
tabIndex={0}
role="group"
aria-label={`Mode locked to ${modeLabel}.${onContinueInNewChat ? ' Use the menu to start a new chat in a different mode.' : ' Start a new chat to use a different mode.'}`}
>
<span className="composer-mode-chip-icon" aria-hidden="true">
<IconLock />
</span>
<span className="composer-mode-chip-label">{modeLabel}</span>
<div className="composer-mode-chip-popover" role="dialog" aria-label="Mode info">
<div className="composer-mode-chip-popover-title">
Mode is fixed for this chat
</div>
<div className="composer-mode-chip-popover-body">
Once the first message is sent, this chat is committed to{' '}
<strong>{modeLabel}</strong>.
</div>
{onContinueInNewChat && continueAlternatives.length > 0 && (
<>
<div className="composer-mode-chip-popover-divider" aria-hidden="true" />
<div className="composer-mode-chip-popover-action-title">
Continue in a new chat
</div>
<div className="composer-mode-chip-popover-actions">
{continueAlternatives.map(alt => (
<button
key={alt.key}
type="button"
className="composer-mode-chip-popover-action"
onClick={() => onContinueInNewChat(alt.mode, alt.profile, alt.orgId)}
aria-label={`Start a new chat in ${alt.label}`}
>
<span className="composer-mode-chip-popover-action-icon" aria-hidden="true">
{alt.icon}
</span>
<span className="composer-mode-chip-popover-action-text">
<span className="composer-mode-chip-popover-action-label">
{alt.label}
</span>
<span className="composer-mode-chip-popover-action-desc">
{alt.description}
</span>
</span>
<span className="composer-mode-chip-popover-action-arrow" aria-hidden="true">
<IconArrowRight />
</span>
</button>
))}
</div>
</>
)}
</div>
</div>
) : (
<span className="composer-mode" data-kind="mode">{modeLabel}</span>
)}
{selectedModeOption === 'company' && showModePicker && !lockedMode && (
<>
<span className="composer-config-divider" aria-hidden="true" />
<label
className="composer-mode-inline"
data-kind="org"
title="Company architecture for this chat"
>
<span className="composer-mode-inline-label">Company</span>
<span className="composer-mode-select-wrap">
<select
className="composer-mode-select"
value={selectedCompanyArchitecture}
onChange={(event) => {
const value = event.target.value as CompanyArchitectureOption
if (value === 'corporate') {
onModeChange?.('company', 'corporate')
return
}
if (value.startsWith('org:')) {
const orgName = value.slice(4)
if (orgName) onModeChange?.('org', 'custom', orgName)
}
}}
onFocus={() => onSavedOrgsRefresh?.()}
onPointerDown={() => onSavedOrgsRefresh?.()}
disabled={disabled}
aria-label="Company architecture"
>
<option value="corporate">Corporate</option>
{!selectedCompanyArchitecture && (
<option value="" disabled>Select saved org</option>
)}
{selectedCompanyArchitecture
&& selectedCompanyArchitecture !== 'corporate'
&& !savedOrgOptions.some(org => `org:${org.name}` === selectedCompanyArchitecture) && (
<option value={selectedCompanyArchitecture}>{selectedOrgLabel || selectedOrgValue}</option>
)}
{savedOrgOptions.length === 0 ? (
<option value="" disabled>No saved orgs</option>
) : savedOrgOptions.map(org => (
<option key={org.name} value={`org:${org.name}`}>
{savedOrgLabel(org)}
</option>
))}
</select>
</span>
</label>
</>
)}
{normalizedExecMode === 'task' && (
<>
<span className="composer-config-divider" aria-hidden="true" />
{showTaskAgentPicker && !lockedMode ? (
<label
className="composer-mode-inline"
data-kind="agent"
title="Execution agent for this task-mode chat"
>
<span className="composer-mode-inline-label">Agent</span>
<span className="composer-mode-select-wrap">
<select
className="composer-mode-select"
value={taskPreferredAgent}
onChange={(event) => onTaskAgentChange?.(event.target.value as TaskPreferredAgent)}
disabled={disabled}
aria-label="Task mode agent"
>
{Object.entries(TASK_AGENT_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</span>
</label>
) : showTaskAgentPicker && lockedMode ? (
<span
className="composer-mode-chip"
data-kind="agent"
tabIndex={0}
role="status"
aria-label={`Agent locked to ${TASK_AGENT_LABELS[taskPreferredAgent]}. Start a new chat to use a different agent.`}
>
<span className="composer-mode-chip-icon" aria-hidden="true">
<IconLock />
</span>
<span className="composer-mode-chip-label">
{TASK_AGENT_LABELS[taskPreferredAgent]}
</span>
<span className="composer-mode-chip-popover" role="tooltip">
<span className="composer-mode-chip-popover-title">
Agent is fixed for this chat
</span>
<span className="composer-mode-chip-popover-body">
The execution agent is committed once the chat starts. Start a new
chat to switch agents.
</span>
</span>
</span>
) : (
<span className="composer-mode" data-kind="agent">{TASK_AGENT_LABELS[taskPreferredAgent]}</span>
)}
</>
)}
</div>
)}
<span className="composer-hint">
{preparingAttachmentCount > 0
? `Preparing ${preparingAttachmentCount} attachment${preparingAttachmentCount > 1 ? 's' : ''}`
: readyAttachmentCount > 0
? `${readyAttachmentCount} attachment${readyAttachmentCount > 1 ? 's' : ''} ready`
: 'Shift+Enter for new line'}
</span>
{typeof contextUsage.usedPct === 'number' && (
<ContextRing
usedPct={contextUsage.usedPct}
usedTokens={contextUsage.usedTokens}
windowTokens={contextUsage.windowTokens}
/>
)}
<button
className="composer-send-btn"
onClick={handleSend}
disabled={disabled || preparingAttachmentCount > 0 || (!text.trim() && readyAttachmentCount === 0) || isWorking}
>
<IconSend />
</button>
</div>
</div>
</div>
{lightbox && (
<div className="lightbox-overlay" onClick={() => setLightbox(null)}>
<img className="lightbox-img" src={lightbox} alt="Preview" />
</div>
)}
</>
)
}
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict'
import { buildNarrativeMessageItems, copyTextToClipboard, parseProjectUpdatePayload, shouldReleaseStickToBottomOnScroll } from './MessageList'
import type { ChatMessage } from '../types/chat'
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 1200,
nextScrollTop: 900,
atBottom: false,
userScrolling: false,
programmaticScroll: false,
}),
true,
'scrollbar drag upward should release stick-to-bottom even without wheel/pointer events',
)
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 1200,
nextScrollTop: 900,
atBottom: false,
userScrolling: false,
programmaticScroll: true,
}),
false,
'programmatic scrolls should not release stick-to-bottom',
)
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 900,
nextScrollTop: 900,
atBottom: false,
userScrolling: true,
programmaticScroll: false,
}),
true,
'explicit user scroll state should release stick-to-bottom while away from bottom',
)
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 900,
nextScrollTop: 1200,
atBottom: true,
userScrolling: true,
programmaticScroll: false,
}),
false,
'scrolling back to bottom should keep follow mode available',
)
const parsedUpdate = parseProjectUpdatePayload(JSON.stringify({
summary: 'Completed the final Chinese memo with source checks.',
deliverables: [
{ name: 'source_credibility.md', path: '/workspace/source_credibility.md', status: 'complete' },
],
acceptance_status: [
{ criterion: 'Chinese memo', met: true },
{ criterion: 'Citations', met: true },
],
risks: ['Refresh market data after close.'],
next_actions: ['Use the memo in CEO aggregation.'],
}))
assert.equal(parsedUpdate?.kind, 'report')
assert.equal(parsedUpdate?.deliverables[0]?.name, 'source_credibility.md')
assert.equal(parsedUpdate?.acceptanceSummary, '2/2 acceptance checks met')
assert.deepEqual(parsedUpdate?.risks, ['Refresh market data after close.'])
const prefixedPayload = JSON.stringify({
summary: 'Focused QA recheck completed.',
deliverables: [
{ name: 'qa_recheck.md', path: '/workspace/qa_recheck.md', status: 'complete' },
],
})
const parsedPrefixedUpdate = parseProjectUpdatePayload(`**Report #1: Recheck remediated screen**: ${prefixedPayload}`)
assert.equal(parsedPrefixedUpdate?.kind, 'report')
assert.equal(parsedPrefixedUpdate?.title, 'Report #1: Recheck remediated screen')
assert.equal(parsedPrefixedUpdate?.summary, 'Focused QA recheck completed.')
const baseMessage = (id: string, content: string, timestamp: number, sender = 'system'): ChatMessage => ({
id,
channelId: 'session:root',
sender,
senderName: sender === 'user' ? 'You' : 'OPC',
content,
timestamp,
mentions: [],
metadata: {},
})
const narrativeItems = buildNarrativeMessageItems([
baseMessage('m1', '[Company:cto::execute::abc] starting Research source reliability', 1000),
baseMessage('m2', '[Delegating to codex] task=Research source reliability | cmd=codex exec ...', 1100),
baseMessage('m2b', 'Status digest: Research source reliability', 1150, 'cto'),
baseMessage('m3', 'The user-visible result is ready.', 1200, 'cto'),
baseMessage('m4', '[External status] codex started pid=123', 1300),
], { isCompanyRuntime: true, detailMode: 'summary' })
assert.equal(narrativeItems.length, 3)
assert.equal(narrativeItems[0].kind, 'ops-bundle')
assert.equal(narrativeItems[0].kind === 'ops-bundle' ? narrativeItems[0].events.length : 0, 3)
assert.equal(narrativeItems[1].kind, 'message')
assert.equal(narrativeItems[2].kind, 'ops-bundle')
const dedupedProjectUpdates = buildNarrativeMessageItems([
baseMessage('u1', prefixedPayload, 2000, 'qa_analyst'),
baseMessage('u2', `**Report #1: Recheck remediated screen**: ${prefixedPayload}`, 2000, 'qa_analyst'),
], { isCompanyRuntime: true, detailMode: 'summary' })
assert.equal(dedupedProjectUpdates.length, 1)
assert.equal(dedupedProjectUpdates[0].kind, 'message')
assert.equal(dedupedProjectUpdates[0].kind === 'message' ? dedupedProjectUpdates[0].msg.id : '', 'u1')
const longResult = 'Completed the focused recheck and produced the QA artifact with caveats for downstream aggregation.'
const dedupedNarrativeMessages = buildNarrativeMessageItems([
baseMessage('n1', longResult, 3000, 'qa_analyst'),
baseMessage('n2', `Recheck remediated ten-bagger candidate screen: ${longResult}`, 3000, 'qa_analyst'),
], { isCompanyRuntime: true, detailMode: 'summary' })
assert.equal(dedupedNarrativeMessages.length, 1)
assert.equal(dedupedNarrativeMessages[0].kind === 'message' ? dedupedNarrativeMessages[0].msg.id : '', 'n1')
const duplicatedResultSurface = buildNarrativeMessageItems([
{
...baseMessage('r1', longResult, 4000, 'chao'),
metadata: { source: 'engine', transcript_kind: 'child_task_result' },
},
{
...baseMessage('r2', `Deliver final result to user: ${longResult}`, 4500, 'system'),
senderName: 'Company Member',
metadata: { source: 'runtime_event', kind: 'worker_notification', notification_kind: 'task_complete' },
},
], { isCompanyRuntime: true, detailMode: 'summary' })
assert.equal(duplicatedResultSurface.length, 1)
assert.equal(duplicatedResultSurface[0].kind === 'message' ? duplicatedResultSurface[0].msg.id : '', 'r1')
const fullItems = buildNarrativeMessageItems([
baseMessage('m1', '[Company:cto::execute::abc] starting Research source reliability', 1000),
], { isCompanyRuntime: true, detailMode: 'full' })
assert.equal(fullItems[0].kind, 'message')
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document')
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
clipboard: {
writeText: async () => {
throw new Error('clipboard denied')
},
},
},
})
let selectedValue = ''
let appendedNode: any = null
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
body: {
appendChild: (node: any) => {
appendedNode = node
},
removeChild: (node: any) => {
assert.equal(node, appendedNode)
appendedNode = null
},
},
createElement: () => ({
value: '',
style: {},
setAttribute: () => {},
focus: () => {},
select: function () {
selectedValue = this.value
},
setSelectionRange: () => {},
}),
execCommand: (command: string) => command === 'copy',
},
})
assert.equal(await copyTextToClipboard('fallback copy text'), true)
assert.equal(selectedValue, 'fallback copy text')
assert.equal(appendedNode, null)
if (originalNavigator) {
Object.defineProperty(globalThis, 'navigator', originalNavigator)
} else {
delete (globalThis as any).navigator
}
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument)
} else {
delete (globalThis as any).document
}
console.log('MessageList.test.tsx: OK (scroll + narrative timeline helpers)')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { RecruitmentPanel } from './RecruitmentPanel'
const markup = renderToStaticMarkup(
React.createElement(RecruitmentPanel, {
meta: {
checkpoint_type: 'company_recruitment_confirmation',
checkpoint_id: 'cp-recruit',
company_profile: 'corporate',
summary: 'Company mode has a pending staffing decision before execution.',
proposals: [
{
role_id: 'senior_engineer',
status: 'proposed_hire',
rationale: 'Selected the strongest backend option.',
role_labels: ['Senior Engineer'],
candidate: {
template_id: 'engineering-backend-architect',
template_name: 'Backend Architect',
category: 'engineering',
domains: ['backend', 'api'],
proposed_name: 'Backend Architect',
rationale: 'Strong API architecture fit.',
},
existing_employee_ids: [],
default_agent: 'codex',
selected_agent: 'codex',
},
],
recruitment_rationales: [
{
role_id: 'senior_engineer',
role_label: 'Senior Engineer',
status: 'proposed_hire',
selection_label: 'Backend Architect',
rationale: 'Strong API architecture fit.',
},
],
staffing_roles: [
{
role_id: 'senior_engineer',
role_label: 'Senior Engineer',
default_selection: { kind: 'template', id: 'engineering-backend-architect' },
default_agent: 'codex',
selected_agent: 'codex',
same_role_employee_ids: [],
},
],
staffing_pool: {
employees: [],
templates: [
{
template_id: 'engineering-backend-architect',
template_name: 'Backend Architect',
category: 'engineering',
domains: ['backend', 'api'],
},
],
},
staffing_selections: {
senior_engineer: { kind: 'template', id: 'engineering-backend-architect' },
},
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(markup, /Recruitment Review/)
assert.match(markup, /Strong API architecture fit/)
assert.match(markup, /ckpt-staffing-grid/)
assert.match(markup, /Backend Architect/)
assert.match(markup, /Approve/)
assert.match(markup, /Send Feedback/)
assert.doesNotMatch(markup, /Deny/)
const source = readFileSync(new URL('./RecruitmentPanel.tsx', import.meta.url), 'utf8')
assert.match(source, /buildReplyMetadata\('approve'\)/)
assert.match(source, /buildReplyMetadata\('feedback'\)/)
assert.match(source, /recruitment_agent: recruitmentAgent/)
assert.match(source, /hasSubmittedCheckpointMetadata/, 'responded recruitment cards must detect persisted reply metadata')
assert.match(source, /setRoleAgents\(buildRoleAgentsFromMeta\(meta, roles\)\)/, 'responded recruitment cards must sync displayed agent choices from reply metadata')
console.log('RecruitmentPanel.test.tsx: OK (recruitment review uses staffing-style UI)')
@@ -0,0 +1,399 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import type {
CheckpointReplyMetadata,
ChatMessageMeta,
RecruitmentProposalEntry,
StaffingEmployeeOption,
StaffingRoleEntry,
StaffingSelectionValue,
StaffingTemplateOption,
} from '../types/chat'
import type { TaskPreferredAgent } from '../types/kanban'
const TASK_AGENT_LABELS: Record<TaskPreferredAgent, string> = {
native: 'OpenOPC Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
const DEFAULT_ROLE_AGENT: TaskPreferredAgent = 'codex'
const DEFAULT_RECRUITMENT_AGENT: TaskPreferredAgent = 'native'
const TASK_AGENT_OPTIONS: TaskPreferredAgent[] = ['codex', 'native', 'claude_code', 'cursor', 'opencode']
const RECRUITMENT_AGENT_OPTIONS: TaskPreferredAgent[] = ['native', 'codex', 'claude_code', 'cursor', 'opencode']
type StaffingOption =
| { kind: 'employee'; id: string; name: string; subtitle: string; category: string; searchText: string }
| { kind: 'template'; id: string; name: string; subtitle: string; category: string; searchText: string }
| { kind: 'fallback'; id: ''; name: string; subtitle: string; category: string; searchText: string }
interface RecruitmentPanelProps {
meta: ChatMessageMeta
onReply: (text: string, metadata?: CheckpointReplyMetadata) => void
responded: boolean
}
function normalizeSelection(value: StaffingSelectionValue | undefined): StaffingSelectionValue {
if (!value) return { kind: 'fallback' }
if (value.kind === 'employee') {
const id = String(value.id ?? value.employee_id ?? '').trim()
return id ? { kind: 'employee', id } : { kind: 'fallback' }
}
if (value.kind === 'template') {
const id = String(value.id ?? value.template_id ?? '').trim()
return id ? { kind: 'template', id } : { kind: 'fallback' }
}
return { kind: 'fallback' }
}
function selectionKey(value: StaffingSelectionValue | undefined): string {
const normalized = normalizeSelection(value)
return normalized.kind === 'fallback' ? 'fallback:' : `${normalized.kind}:${normalized.id ?? ''}`
}
function buildOptions(
role: StaffingRoleEntry,
employees: StaffingEmployeeOption[],
templates: StaffingTemplateOption[],
): StaffingOption[] {
const roleId = String(role.role_id ?? '').trim()
const roleLabel = String(role.role_label ?? '').trim()
const roleText = `${roleId} ${roleLabel} ${role.role_responsibility ?? ''}`.toLowerCase()
const sameRoleIds = new Set((role.same_role_employee_ids ?? []).map(item => String(item ?? '').trim()).filter(Boolean))
const employeeOptions = employees.map((employee): StaffingOption & { rank: number } => {
const id = String(employee.employee_id ?? '').trim()
const name = String(employee.employee_name ?? id).trim() || id
const employeeRole = String(employee.role_id ?? '').trim()
const category = String(employee.category ?? '').trim()
const subtitle = [employeeRole, category].filter(Boolean).join(' · ') || id
return {
kind: 'employee',
id,
name,
subtitle,
category,
searchText: `${id} ${name} ${employeeRole} ${category} ${(employee.domains ?? []).join(' ')} ${(employee.tags ?? []).join(' ')}`.toLowerCase(),
rank: sameRoleIds.has(id) || employeeRole === roleId ? 0 : 2,
}
}).filter(option => option.id).sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
const templateOptions = templates.map((template): StaffingOption & { rank: number } => {
const id = String(template.template_id ?? '').trim()
const name = String(template.template_name ?? id).trim() || id
const category = String(template.category ?? '').trim()
const subtitle = [category, id].filter(Boolean).join(' · ') || id
const templateText = `${id} ${name} ${category} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase()
const rank = roleText.split(/[^a-z0-9]+/).filter(token => token.length >= 3).reduce(
(score, token) => score + (templateText.includes(token) ? 1 : 0),
0,
)
return {
kind: 'template',
id,
name,
subtitle,
category,
searchText: templateText,
rank,
}
}).filter(option => option.id).sort((a, b) => b.rank - a.rank || a.name.localeCompare(b.name))
return [
...employeeOptions,
...templateOptions,
{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' },
]
}
function optionForSelection(options: StaffingOption[], selection: StaffingSelectionValue | undefined): StaffingOption {
const key = selectionKey(selection)
return options.find(option => `${option.kind}:${option.id}` === key) ?? options[0]
}
function optionMatches(option: StaffingOption, query: string): boolean {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return true
return terms.every(term => option.searchText.includes(term))
}
function buildRolesFromProposals(proposals: RecruitmentProposalEntry[]): StaffingRoleEntry[] {
return proposals.map((proposal) => {
const roleId = String(proposal.role_id ?? '').trim()
const existingId = String(proposal.existing_employee?.employee_id ?? '').trim()
const templateId = String(proposal.candidate?.template_id ?? '').trim()
const defaultSelection: StaffingSelectionValue = existingId
? { kind: 'employee', id: existingId }
: templateId
? { kind: 'template', id: templateId }
: { kind: 'fallback' }
return {
role_id: roleId,
role_label: proposal.role_labels?.[0] ?? roleId,
role_responsibility: '',
default_selection: defaultSelection,
same_role_employee_ids: proposal.existing_employee_ids ?? [],
fallback_available: true,
default_agent: proposal.default_agent ?? DEFAULT_ROLE_AGENT,
selected_agent: proposal.selected_agent ?? proposal.default_agent ?? DEFAULT_ROLE_AGENT,
default_source: 'recruitment',
}
}).filter(role => role.role_id)
}
function selectedRecruitmentName(
proposal: RecruitmentProposalEntry | undefined,
selected: StaffingOption,
): string {
if (selected.kind === 'template' && proposal?.candidate?.template_id === selected.id) {
return proposal.candidate.proposed_name || proposal.candidate.template_name || selected.name
}
if (selected.kind === 'employee' && proposal?.existing_employee?.employee_id === selected.id) {
return proposal.existing_employee.employee_name || selected.name
}
return selected.name
}
function buildSelectionsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, StaffingSelectionValue> {
const initial: Record<string, StaffingSelectionValue> = {}
const persisted = meta.staffing_selections ?? {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) continue
initial[roleId] = normalizeSelection(persisted[roleId] ?? role.default_selection)
}
return initial
}
function buildRoleAgentsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, TaskPreferredAgent> {
const persisted = meta.recruitment_role_agents ?? {}
const initial: Record<string, TaskPreferredAgent> = {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) continue
initial[roleId] = persisted[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
}
return initial
}
function hasSubmittedCheckpointMetadata(meta: ChatMessageMeta): boolean {
return Boolean(
String(meta.checkpoint_response_message_id ?? '').trim()
|| String(meta.checkpoint_responded_at ?? '').trim()
|| String(meta.checkpoint_reply_kind ?? '').trim()
)
}
export const RecruitmentPanel = React.memo(function RecruitmentPanel({
meta, onReply, responded,
}: RecruitmentPanelProps) {
const proposals = meta.proposals ?? []
const proposalByRole = useMemo(() => {
const next: Record<string, RecruitmentProposalEntry> = {}
for (const proposal of proposals) {
const roleId = String(proposal.role_id ?? '').trim()
if (roleId) next[roleId] = proposal
}
return next
}, [proposals])
const roles = useMemo(
() => (meta.staffing_roles?.length ? meta.staffing_roles : buildRolesFromProposals(proposals)),
[meta.staffing_roles, proposals],
)
const employees = meta.staffing_pool?.employees ?? []
const templates = meta.staffing_pool?.templates ?? []
const rationales = meta.recruitment_rationales ?? []
const optionsByRole = useMemo(() => {
const next: Record<string, StaffingOption[]> = {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (roleId) next[roleId] = buildOptions(role, employees, templates)
}
return next
}, [employees, roles, templates])
const [queries, setQueries] = useState<Record<string, string>>({})
const [feedback, setFeedback] = useState('')
const [selections, setSelections] = useState<Record<string, StaffingSelectionValue>>(() => buildSelectionsFromMeta(meta, roles))
const [roleAgents, setRoleAgents] = useState<Record<string, TaskPreferredAgent>>(() => buildRoleAgentsFromMeta(meta, roles))
const [recruitmentAgent, setRecruitmentAgent] = useState<TaskPreferredAgent>(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
const isResponded = responded
useEffect(() => {
if (!isResponded || !hasSubmittedCheckpointMetadata(meta)) return
setSelections(buildSelectionsFromMeta(meta, roles))
setRoleAgents(buildRoleAgentsFromMeta(meta, roles))
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
}, [isResponded, meta, roles])
useEffect(() => {
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
}, [meta.recruitment_agent])
const buildReplyMetadata = useCallback((kind: NonNullable<CheckpointReplyMetadata['checkpoint_reply_kind']>): CheckpointReplyMetadata => {
const checkpointId = String(meta.checkpoint_id ?? '').trim()
if (!checkpointId) {
throw new Error('Recruitment checkpoint reply requires checkpoint_id metadata.')
}
const checkpointType = String(meta.checkpoint_type ?? '').trim()
return {
response_to_checkpoint_id: checkpointId,
response_to_checkpoint_type: checkpointType || 'company_recruitment_confirmation',
checkpoint_reply_kind: kind,
staffing_selections: selections,
recruitment_agent: recruitmentAgent,
recruitment_role_agents: roles.reduce<Record<string, TaskPreferredAgent>>((acc, role) => {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) return acc
acc[roleId] = roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
return acc
}, {}),
}
}, [meta.checkpoint_id, meta.checkpoint_type, recruitmentAgent, roleAgents, roles, selections])
const handleApprove = useCallback(() => {
if (isResponded) return
onReply('approve', buildReplyMetadata('approve'))
}, [buildReplyMetadata, isResponded, onReply])
const handleFeedback = useCallback(() => {
if (isResponded || !feedback.trim()) return
onReply(feedback.trim(), buildReplyMetadata('feedback'))
setFeedback('')
}, [buildReplyMetadata, isResponded, feedback, onReply])
return (
<div className="ckpt-panel ckpt-recruitment">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-recruit">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6" cy="5" r="3" />
<path d="M2 14c0-2.2 1.8-4 4-4s4 1.8 4 4" />
<path d="M12 5v4M10 7h4" />
</svg>
</div>
<div className="ckpt-title">Recruitment Review</div>
<span className="ckpt-badge ckpt-badge-profile">{meta.company_profile || 'corporate'}</span>
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
</div>
{meta.summary && <div className="ckpt-summary">{meta.summary}</div>}
<div className="ckpt-recruiter-agent">
<label className="ckpt-agent-label" htmlFor={`recruitment-recruiter-agent-${meta.checkpoint_id}`}>
Recruiter Agent
</label>
<select
id={`recruitment-recruiter-agent-${meta.checkpoint_id}`}
className="ckpt-agent-select"
value={recruitmentAgent}
onChange={event => setRecruitmentAgent(event.target.value as TaskPreferredAgent)}
disabled={isResponded}
>
{RECRUITMENT_AGENT_OPTIONS.map(agent => (
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
))}
</select>
</div>
{rationales.length > 0 && (
<div className="ckpt-recruitment-reasons">
{rationales.map(item => (
<div key={item.role_id} className="ckpt-recruitment-reason">
<div className="ckpt-proposal-header">
<span className="ckpt-role-name">{item.role_id}</span>
{item.role_label && item.role_label !== item.role_id && <span className="ckpt-field-tag">{item.role_label}</span>}
{item.selection_label && <span className="ckpt-badge ckpt-badge-template">{item.selection_label}</span>}
</div>
{item.rationale && <div className="ckpt-rationale">{item.rationale}</div>}
</div>
))}
</div>
)}
<div className="ckpt-staffing-grid">
{roles.map(role => {
const roleId = String(role.role_id ?? '').trim()
const options = optionsByRole[roleId] ?? [{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' }]
const selected = optionForSelection(options, selections[roleId])
const proposal = proposalByRole[roleId]
const query = queries[roleId] ?? ''
const visibleOptions = options.filter(option => optionMatches(option, query)).slice(0, 8)
return (
<div key={roleId} className="ckpt-staffing-card">
<div className="ckpt-proposal-header">
<span className="ckpt-role-name">{roleId}</span>
<span className={`ckpt-badge ckpt-badge-${selected.kind}`}>{selected.kind}</span>
</div>
{role.role_label && role.role_label !== roleId && (
<div className="ckpt-role-labels">
<span className="ckpt-field-tag">{role.role_label}</span>
</div>
)}
<div className="ckpt-staffing-selected">
<div className="ckpt-cand-name">{selectedRecruitmentName(proposal, selected)}</div>
<div className="ckpt-cand-meta">
<span className="ckpt-cand-category">{selected.category}</span>
<span className="ckpt-domain-tag">{selected.subtitle}</span>
</div>
</div>
<input
className="ckpt-staffing-search"
value={query}
onChange={event => setQueries(current => ({ ...current, [roleId]: event.target.value }))}
placeholder="Search employees or templates..."
disabled={isResponded}
/>
<div className="ckpt-staffing-options">
{visibleOptions.map(option => {
const active = `${option.kind}:${option.id}` === selectionKey(selections[roleId])
return (
<button
key={`${option.kind}:${option.id}`}
className={`ckpt-staffing-option${active ? ' active' : ''}`}
onClick={() => setSelections(current => ({ ...current, [roleId]: { kind: option.kind, id: option.id } }))}
disabled={isResponded}
title={option.subtitle}
>
<span className="ckpt-staffing-option-kind">{option.kind}</span>
<span className="ckpt-staffing-option-name">{option.name}</span>
</button>
)
})}
</div>
<div className="ckpt-agent-picker">
<label className="ckpt-agent-label" htmlFor={`recruit-agent-${meta.checkpoint_id}-${roleId}`}>
Execution Agent
</label>
<select
id={`recruit-agent-${meta.checkpoint_id}-${roleId}`}
className="ckpt-agent-select"
value={roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT}
onChange={event => setRoleAgents(current => ({ ...current, [roleId]: event.target.value as TaskPreferredAgent }))}
disabled={isResponded}
>
{TASK_AGENT_OPTIONS.map(agent => (
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
))}
</select>
</div>
</div>
)
})}
</div>
{!isResponded && (
<div className="ckpt-actions ckpt-actions-inline-feedback">
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove}>Approve</button>
<textarea
className="ckpt-feedback-input ckpt-feedback-inline-input"
placeholder="Feedback to refine recruitment..."
value={feedback}
onChange={e => setFeedback(e.target.value)}
rows={2}
/>
<button className="ckpt-btn ckpt-btn-feedback" onClick={handleFeedback} disabled={!feedback.trim()}>
Send Feedback
</button>
</div>
)}
</div>
)
})
@@ -0,0 +1,123 @@
import React, { useCallback } from 'react'
import type { ChatMessageMeta } from '../types/chat'
interface ReorgPanelProps {
meta: ChatMessageMeta
onReply: (text: string) => void
responded: boolean
}
const SCOPE_LABELS: Record<string, string> = {
task_adjustment: 'Task Adjustment',
runtime_replan: 'Runtime Replan',
org_mutation: 'Org Mutation',
}
const RISK_COLORS: Record<string, string> = {
low: 'var(--green)',
medium: 'var(--yellow)',
high: 'var(--red)',
}
export const ReorgPanel = React.memo(function ReorgPanel({
meta, onReply, responded,
}: ReorgPanelProps) {
const isResponded = responded
const handleApprove = useCallback(() => {
if (isResponded) return
onReply('approve')
}, [isResponded, onReply])
const handleDeny = useCallback(() => {
if (isResponded) return
onReply('deny')
}, [isResponded, onReply])
const roleChanges = meta.role_changes ?? []
const projectionChanges = meta.work_item_projection_changes ?? []
const scope = meta.scope || 'org_mutation'
const risk = meta.risk_level || 'medium'
const impact = meta.impact_summary || {}
return (
<div className="ckpt-panel ckpt-reorg">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-reorg">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="1" width="5" height="5" rx="1" />
<rect x="10" y="1" width="5" height="5" rx="1" />
<rect x="5.5" y="10" width="5" height="5" rx="1" />
<path d="M3.5 6v2.5a1 1 0 001 1h7a1 1 0 001-1V6" />
<path d="M8 9.5V10" />
</svg>
</div>
<div className="ckpt-title">{meta.title || 'Company Reorg'}</div>
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
</div>
<div className="ckpt-reorg-badges">
<span className="ckpt-badge ckpt-badge-scope">{SCOPE_LABELS[scope] || scope}</span>
<span className="ckpt-badge" style={{ color: RISK_COLORS[risk] || 'var(--text-secondary)', borderColor: RISK_COLORS[risk] || 'var(--border)' }}>
Risk: {risk.charAt(0).toUpperCase() + risk.slice(1)}
</span>
</div>
{meta.summary && <div className="ckpt-summary">{meta.summary}</div>}
{meta.rationale && <div className="ckpt-rationale">{meta.rationale}</div>}
{roleChanges.length > 0 && (
<div className="ckpt-changes-section">
<div className="ckpt-changes-title">Role Changes</div>
{roleChanges.map((rc, i) => (
<div key={i} className="ckpt-change-row">
<span className={`ckpt-change-action ckpt-action-${rc.action}`}>{rc.action}</span>
<span className="ckpt-change-id">{rc.role_id}</span>
{rc.replacement_role_id && (
<>
<span className="ckpt-change-arrow">&rarr;</span>
<span className="ckpt-change-id">{rc.replacement_role_id}</span>
</>
)}
{rc.reason && <span className="ckpt-change-reason">{rc.reason}</span>}
</div>
))}
</div>
)}
{projectionChanges.length > 0 && (
<div className="ckpt-changes-section">
<div className="ckpt-changes-title">Work Item Projection Changes</div>
{projectionChanges.map((change, i) => (
<div key={i} className="ckpt-change-row">
<span className={`ckpt-change-action ckpt-action-${change.action}`}>{change.action}</span>
<span className="ckpt-change-id">{change.work_item_projection_id}</span>
{change.replacement_work_item_projection_id && (
<>
<span className="ckpt-change-arrow">&rarr;</span>
<span className="ckpt-change-id">{change.replacement_work_item_projection_id}</span>
</>
)}
{change.reason && <span className="ckpt-change-reason">{change.reason}</span>}
</div>
))}
</div>
)}
{Object.keys(impact).length > 0 && (
<div className="ckpt-impact">
{impact.affected_tasks != null && <span>Tasks affected: {impact.affected_tasks}</span>}
{impact.affected_roles != null && <span>Roles affected: {impact.affected_roles}</span>}
{impact.migration_count != null && <span>Migrations: {impact.migration_count}</span>}
</div>
)}
{!isResponded && (
<div className="ckpt-actions">
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove}>Approve Reorg</button>
<button className="ckpt-btn ckpt-btn-deny" onClick={handleDeny}>Deny</button>
</div>
)}
</div>
)
})
@@ -0,0 +1,443 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { Session } from '../types/kanban'
import { IconPlus, IconSearch, IconActivity, IconShield, IconTrash, IconWorkItem } from './SvgIcons'
import { getSessionRuntimeStatus } from '../lib/sessionRuntime'
import { deriveCompanyRuntimeDisplayStatus } from '../lib/workItemSessions'
interface SessionSidebarProps {
sessions: Session[]
activeSessionId: string | null
activeChannel?: string | null
secretaryChannelId?: string
unreadCounts?: Record<string, number>
onSelect: (taskId: string | null) => void
onCreateSession: () => void
onDeleteSession: (taskId: string) => void
onSelectSecretary?: () => void
}
const STATUS_DOT: Record<string, string> = {
idle: 'status-idle',
done: 'status-done',
pending: 'status-pending',
failed: 'status-failed',
cancelled: 'status-cancelled',
blocked: 'status-blocked',
}
function sessionDotClass(session: Session): string {
const displayStatus = deriveCompanyRuntimeDisplayStatus(session) ?? session.status
const runtimeStatus = getSessionRuntimeStatus({ ...session, status: displayStatus })
if (runtimeStatus === 'tool_active') return 'status-tool-active'
if (runtimeStatus === 'reflecting') return 'status-reflecting'
if (displayStatus === 'running') return 'status-running-idle'
return STATUS_DOT[displayStatus] ?? 'status-pending'
}
function relativeTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60_000) return 'just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
return `${Math.floor(diff / 86_400_000)}d`
}
function dateGroup(ts: number): string {
const now = new Date()
const d = new Date(ts)
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today.getTime() - 86_400_000)
if (d >= today) return 'Today'
if (d >= yesterday) return 'Yesterday'
return 'Earlier'
}
interface SessionTree {
session: Session
children: Session[]
}
type SidebarRow =
| { kind: 'group'; group: 'Today' | 'Yesterday' | 'Earlier' }
| { kind: 'primary'; node: SessionTree }
| { kind: 'child'; parentTaskId: string; child: Session; childIndex: number; childCount: number }
| { kind: 'child-count'; node: SessionTree }
function buildSessionTree(sessions: Session[]): SessionTree[] {
const childMap = new Map<string, Session[]>()
const primarySessions: Session[] = []
for (const s of sessions) {
if (s.mode === 'child' && s.parentSessionId) {
const siblings = childMap.get(s.parentSessionId) ?? []
siblings.push(s)
childMap.set(s.parentSessionId, siblings)
} else {
primarySessions.push(s)
}
}
return primarySessions.map(p => ({
session: p,
children: Array.from(new Map(
[...(p.sessionId ? (childMap.get(p.sessionId) ?? []) : []), ...(childMap.get(p.taskId) ?? [])]
.map(child => [child.taskId, child]),
).values()),
}))
}
function SessionItem({
session,
isActive,
isChild,
isLast,
unreadCount,
onSelect,
onDelete,
}: {
session: Session
isActive: boolean
isChild?: boolean
isLast?: boolean
unreadCount?: number
onSelect: () => void
onDelete: () => void
}) {
const [showDelete, setShowDelete] = useState(false)
const [confirming, setConfirming] = useState(false)
const agentLabel = session.assigneeIds.length > 0
? session.assigneeIds[0].replace(/^agent-/, '')
: null
const displayStatus = deriveCompanyRuntimeDisplayStatus(session) ?? session.status
useEffect(() => {
if (!confirming) return
const timer = setTimeout(() => setConfirming(false), 3000)
return () => clearTimeout(timer)
}, [confirming])
return (
<button
className={`session-item${isActive ? ' active' : ''}${isChild ? ' session-item-child' : ''}`}
onClick={onSelect}
onMouseEnter={() => setShowDelete(true)}
onMouseLeave={() => setShowDelete(false)}
>
{isChild && (
<span className={`session-tree-line${isLast ? ' last' : ''}`} />
)}
<span className={`session-dot ${sessionDotClass(session)}`} />
<div className="session-item-content">
<span className="session-item-title">
{isChild && agentLabel && (
<span className="session-agent-tag">{agentLabel}</span>
)}
{!isChild && session.isCompanyRuntime && (
<span className="session-runtime-badge" title="Company runtime"><IconWorkItem /></span>
)}
{session.title}
</span>
<span className="session-item-meta">
{displayStatus} · {relativeTime(session.updatedAt)}
</span>
</div>
{!!unreadCount && unreadCount > 0 && (
<span className="session-unread-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
{showDelete && !isChild && !confirming && (
<button
className="session-delete-btn"
onClick={e => { e.stopPropagation(); setConfirming(true) }}
title="Delete"
>
<IconTrash />
</button>
)}
{confirming && (
<span className="session-confirm-delete" onClick={e => e.stopPropagation()}>
<button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>Delete</button>
<button className="session-confirm-no" onClick={() => setConfirming(false)}>Cancel</button>
</span>
)}
</button>
)
}
export function SessionSidebar({ sessions, activeSessionId, activeChannel, secretaryChannelId, unreadCounts, onSelect, onCreateSession, onDeleteSession, onSelectSecretary }: SessionSidebarProps) {
const [search, setSearch] = useState('')
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const hasRuntimeSessions = sessions.some(session =>
session.isCompanyRuntime
|| session.mode === 'child'
|| session.execMode === 'company'
|| session.execMode === 'org'
|| session.execMode === 'custom'
)
const toggleCollapse = useCallback((taskId: string) => {
setCollapsed(prev => {
const next = new Set(prev)
if (next.has(taskId)) next.delete(taskId)
else next.add(taskId)
return next
})
}, [])
const filtered = useMemo(() => {
if (!search.trim()) return sessions
const q = search.toLowerCase()
return sessions.filter(s => s.title.toLowerCase().includes(q))
}, [sessions, search])
const tree = useMemo(() => buildSessionTree(filtered), [filtered])
const grouped = useMemo(() => {
const groups: Record<string, SessionTree[]> = { Today: [], Yesterday: [], Earlier: [] }
for (const node of tree) {
const g = dateGroup(node.session.createdAt)
groups[g]?.push(node)
}
return groups
}, [tree])
const rows = useMemo<SidebarRow[]>(() => {
const nextRows: SidebarRow[] = []
for (const group of ['Today', 'Yesterday', 'Earlier'] as const) {
const items = grouped[group]
if (!items || items.length === 0) continue
nextRows.push({ kind: 'group', group })
for (const node of items) {
nextRows.push({ kind: 'primary', node })
const hasChildren = node.children.length > 0
const isCollapsed = collapsed.has(node.session.taskId)
if (hasChildren && isCollapsed) {
nextRows.push({ kind: 'child-count', node })
} else if (hasChildren) {
node.children.forEach((child, idx) => {
nextRows.push({
kind: 'child',
parentTaskId: node.session.taskId,
child,
childIndex: idx,
childCount: node.children.length,
})
})
}
}
}
return nextRows
}, [collapsed, grouped])
const useVirtualRows = rows.length > 120
const listRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => listRef.current,
estimateSize: (index) => {
const row = rows[index]
if (row?.kind === 'group') return 26
if (row?.kind === 'child-count') return 30
return 54
},
overscan: 8,
})
const renderPrimaryRow = useCallback((node: SessionTree) => {
const hasChildren = node.children.length > 0
const isCollapsed = collapsed.has(node.session.taskId)
return (
<div className="session-tree-node">
<div className="session-tree-primary">
{hasChildren && (
<button
className={`session-expand-btn${isCollapsed ? ' collapsed' : ''}`}
onClick={e => {
e.stopPropagation()
toggleCollapse(node.session.taskId)
}}
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
/>
)}
<SessionItem
session={node.session}
isActive={node.session.taskId === activeSessionId}
unreadCount={unreadCounts?.[node.session.channelId]}
onSelect={() => onSelect(node.session.taskId)}
onDelete={() => onDeleteSession(node.session.taskId)}
/>
</div>
</div>
)
}, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts])
const renderVirtualRow = useCallback((row: SidebarRow) => {
if (row.kind === 'group') {
return (
<div className="session-group-label">
{hasRuntimeSessions ? `${row.group} Runtime Sessions` : row.group}
</div>
)
}
if (row.kind === 'primary') {
return renderPrimaryRow(row.node)
}
if (row.kind === 'child-count') {
return (
<button
className="session-child-count"
onClick={() => toggleCollapse(row.node.session.taskId)}
>
{row.node.children.length} sub-task{row.node.children.length > 1 ? 's' : ''}
</button>
)
}
return (
<div className="session-children">
<SessionItem
session={row.child}
isActive={row.child.taskId === activeSessionId}
isChild
isLast={row.childIndex === row.childCount - 1}
unreadCount={unreadCounts?.[row.child.channelId]}
onSelect={() => onSelect(row.child.taskId)}
onDelete={() => onDeleteSession(row.child.taskId)}
/>
</div>
)
}, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts])
return (
<div className="session-sidebar">
<button className="session-new-btn" onClick={onCreateSession}>
<IconPlus />
<span>New Chat</span>
</button>
{onSelectSecretary && (
<button
className={`session-nav-btn${activeChannel === secretaryChannelId ? ' active' : ''}`}
onClick={onSelectSecretary}
>
<IconShield />
<span>Secretary</span>
{!!(secretaryChannelId && unreadCounts?.[secretaryChannelId]) && unreadCounts![secretaryChannelId!] > 0 && (
<span className="session-unread-badge">{unreadCounts![secretaryChannelId!] > 99 ? '99+' : unreadCounts![secretaryChannelId!]}</span>
)}
</button>
)}
<div className="session-search-wrap">
<IconSearch />
<input
className="session-search"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div ref={listRef} className={`session-list${useVirtualRows ? ' session-list-virtualized' : ''}`}>
{useVirtualRows ? (
<div
style={{
height: rowVirtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderVirtualRow(rows[virtualRow.index])}
</div>
))}
</div>
) : (['Today', 'Yesterday', 'Earlier'] as const).map(group => {
const items = grouped[group]
if (!items || items.length === 0) return null
return (
<div key={group} className="session-group">
<div className="session-group-label">{hasRuntimeSessions ? `${group} Runtime Sessions` : group}</div>
{items.map(node => {
const hasChildren = node.children.length > 0
const isCollapsed = collapsed.has(node.session.taskId)
return (
<div key={node.session.taskId} className="session-tree-node">
<div className="session-tree-primary">
{hasChildren && (
<button
className={`session-expand-btn${isCollapsed ? ' collapsed' : ''}`}
onClick={e => {
e.stopPropagation()
toggleCollapse(node.session.taskId)
}}
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
/>
)}
<SessionItem
session={node.session}
isActive={node.session.taskId === activeSessionId}
unreadCount={unreadCounts?.[node.session.channelId]}
onSelect={() => onSelect(node.session.taskId)}
onDelete={() => onDeleteSession(node.session.taskId)}
/>
</div>
{hasChildren && !isCollapsed && (
<div className="session-children">
{node.children.map((child, idx) => (
<SessionItem
key={child.taskId}
session={child}
isActive={child.taskId === activeSessionId}
isChild
isLast={idx === node.children.length - 1}
unreadCount={unreadCounts?.[child.channelId]}
onSelect={() => onSelect(child.taskId)}
onDelete={() => onDeleteSession(child.taskId)}
/>
))}
</div>
)}
{hasChildren && isCollapsed && (
<button
className="session-child-count"
onClick={() => toggleCollapse(node.session.taskId)}
>
{node.children.length} sub-task{node.children.length > 1 ? 's' : ''}
</button>
)}
</div>
)
})}
</div>
)
})}
{filtered.length === 0 && (
<div className="session-empty">No sessions yet</div>
)}
</div>
<button
className={`session-nav-btn session-activity-btn${activeSessionId === null && activeChannel !== secretaryChannelId ? ' active' : ''}`}
onClick={() => onSelect(null)}
>
<IconActivity />
<span>Activity</span>
</button>
</div>
)
}
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { StaffingSelectionPanel } from './StaffingSelectionPanel'
const markup = renderToStaticMarkup(
React.createElement(StaffingSelectionPanel, {
meta: {
checkpoint_type: 'company_staffing_selection',
checkpoint_id: 'cp-staffing',
company_profile: 'corporate',
summary: 'Select staff manually, or run automatic recruitment.',
staffing_roles: [
{
role_id: 'senior_engineer',
role_label: 'Senior Engineer',
default_selection: { kind: 'employee', id: 'senior-existing' },
default_agent: 'codex',
selected_agent: 'codex',
},
],
staffing_pool: {
employees: [
{
employee_id: 'senior-existing',
employee_name: 'Existing Engineer',
role_id: 'senior_engineer',
category: 'engineering',
},
],
templates: [
{
template_id: 'engineering-frontend-developer',
template_name: 'Frontend Developer',
category: 'engineering',
},
],
},
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(markup, /Manual Staffing/)
assert.match(markup, /Existing Engineer/)
assert.match(markup, /Frontend Developer/)
assert.match(markup, /Approve/)
assert.match(markup, /Auto Recruit/)
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'StaffingSelectionPanel.tsx'), 'utf8')
assert.match(src, /staffing_action: action/, 'panel replies must send structured staffing_action metadata')
assert.match(src, /staffing_selections: selections/, 'panel replies must send structured staffing selections')
assert.match(src, /recruitment_agent: recruitmentAgent/, 'panel replies must send the selected recruiter agent')
assert.match(src, /hasSubmittedCheckpointMetadata/, 'responded staffing cards must detect persisted reply metadata')
assert.match(src, /setRoleAgents\(buildRoleAgentsFromMeta\(meta, roles\)\)/, 'responded staffing cards must sync displayed agent choices from reply metadata')
console.log('StaffingSelectionPanel.test.tsx: OK (manual staffing panel renders structured choices)')
@@ -0,0 +1,336 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import type {
CheckpointReplyMetadata,
ChatMessageMeta,
StaffingEmployeeOption,
StaffingRoleEntry,
StaffingSelectionValue,
StaffingTemplateOption,
} from '../types/chat'
import type { TaskPreferredAgent } from '../types/kanban'
const TASK_AGENT_LABELS: Record<TaskPreferredAgent, string> = {
native: 'OpenOPC Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
const DEFAULT_ROLE_AGENT: TaskPreferredAgent = 'codex'
const DEFAULT_RECRUITMENT_AGENT: TaskPreferredAgent = 'native'
const TASK_AGENT_OPTIONS: TaskPreferredAgent[] = ['codex', 'native', 'claude_code', 'cursor', 'opencode']
const RECRUITMENT_AGENT_OPTIONS: TaskPreferredAgent[] = ['native', 'codex', 'claude_code', 'cursor', 'opencode']
type StaffingOption =
| { kind: 'employee'; id: string; name: string; subtitle: string; category: string; searchText: string }
| { kind: 'template'; id: string; name: string; subtitle: string; category: string; searchText: string }
| { kind: 'fallback'; id: ''; name: string; subtitle: string; category: string; searchText: string }
interface StaffingSelectionPanelProps {
meta: ChatMessageMeta
onReply: (text: string, metadata?: CheckpointReplyMetadata) => void
responded: boolean
}
function normalizeSelection(value: StaffingSelectionValue | undefined): StaffingSelectionValue {
if (!value) return { kind: 'fallback' }
if (value.kind === 'employee') {
const id = String(value.id ?? value.employee_id ?? '').trim()
return id ? { kind: 'employee', id } : { kind: 'fallback' }
}
if (value.kind === 'template') {
const id = String(value.id ?? value.template_id ?? '').trim()
return id ? { kind: 'template', id } : { kind: 'fallback' }
}
return { kind: 'fallback' }
}
function selectionKey(value: StaffingSelectionValue | undefined): string {
const normalized = normalizeSelection(value)
return normalized.kind === 'fallback' ? 'fallback:' : `${normalized.kind}:${normalized.id ?? ''}`
}
function buildOptions(
role: StaffingRoleEntry,
employees: StaffingEmployeeOption[],
templates: StaffingTemplateOption[],
): StaffingOption[] {
const roleId = String(role.role_id ?? '').trim()
const roleLabel = String(role.role_label ?? '').trim()
const roleText = `${roleId} ${roleLabel} ${role.role_responsibility ?? ''}`.toLowerCase()
const sameRoleIds = new Set((role.same_role_employee_ids ?? []).map(item => String(item ?? '').trim()).filter(Boolean))
const templateScore = (template: StaffingTemplateOption): number => {
const templateText = `${template.template_id ?? ''} ${template.template_name ?? ''} ${template.category ?? ''} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase()
const categoryTerm = String(template.category ?? '').toLowerCase()
let score = 0
for (const token of roleText.split(/[^a-z0-9]+/).filter(token => token.length >= 3)) {
if (templateText.includes(token) || (categoryTerm && token.includes(categoryTerm))) score += 1
}
if (templateText.includes(roleId.replace(/_/g, '-')) || templateText.includes(roleId.replace(/_/g, ' '))) score += 2
return score
}
const employeeOptions = employees.map((employee): StaffingOption & { rank: number } => {
const id = String(employee.employee_id ?? '').trim()
const name = String(employee.employee_name ?? id).trim() || id
const employeeRole = String(employee.role_id ?? '').trim()
const category = String(employee.category ?? '').trim()
const subtitle = [employeeRole, category].filter(Boolean).join(' · ') || id
return {
kind: 'employee',
id,
name,
subtitle,
category,
searchText: `${id} ${name} ${employeeRole} ${category} ${(employee.domains ?? []).join(' ')} ${(employee.tags ?? []).join(' ')}`.toLowerCase(),
rank: sameRoleIds.has(id) || employeeRole === roleId ? 0 : 2,
}
}).filter(option => option.id).sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
const templateOptions = templates.map((template): StaffingOption & { rank: number } => {
const id = String(template.template_id ?? '').trim()
const name = String(template.template_name ?? id).trim() || id
const category = String(template.category ?? '').trim()
const subtitle = [category, id].filter(Boolean).join(' · ') || id
return {
kind: 'template',
id,
name,
subtitle,
category,
searchText: `${id} ${name} ${category} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase(),
rank: templateScore(template),
}
}).filter(option => option.id).sort((a, b) => b.rank - a.rank || a.name.localeCompare(b.name))
return [
...employeeOptions,
...templateOptions,
{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' },
]
}
function optionForSelection(options: StaffingOption[], selection: StaffingSelectionValue | undefined): StaffingOption {
const key = selectionKey(selection)
return options.find(option => `${option.kind}:${option.id}` === key) ?? options[0]
}
function optionMatches(option: StaffingOption, query: string): boolean {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return true
return terms.every(term => option.searchText.includes(term))
}
function buildSelectionsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, StaffingSelectionValue> {
const initial: Record<string, StaffingSelectionValue> = {}
const persisted = meta.staffing_selections ?? {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) continue
initial[roleId] = normalizeSelection(persisted[roleId] ?? role.default_selection)
}
return initial
}
function buildRoleAgentsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, TaskPreferredAgent> {
const persisted = meta.recruitment_role_agents ?? {}
const initial: Record<string, TaskPreferredAgent> = {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) continue
initial[roleId] = persisted[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
}
return initial
}
function hasSubmittedCheckpointMetadata(meta: ChatMessageMeta): boolean {
return Boolean(
String(meta.checkpoint_response_message_id ?? '').trim()
|| String(meta.checkpoint_responded_at ?? '').trim()
|| String(meta.staffing_action ?? '').trim()
)
}
export const StaffingSelectionPanel = React.memo(function StaffingSelectionPanel({
meta, onReply, responded,
}: StaffingSelectionPanelProps) {
const roles = useMemo(() => meta.staffing_roles ?? [], [meta.staffing_roles])
const employees = meta.staffing_pool?.employees ?? []
const templates = meta.staffing_pool?.templates ?? []
const optionsByRole = useMemo(() => {
const next: Record<string, StaffingOption[]> = {}
for (const role of roles) {
const roleId = String(role.role_id ?? '').trim()
if (roleId) next[roleId] = buildOptions(role, employees, templates)
}
return next
}, [employees, roles, templates])
const [queries, setQueries] = useState<Record<string, string>>({})
const [selections, setSelections] = useState<Record<string, StaffingSelectionValue>>(() => buildSelectionsFromMeta(meta, roles))
const [roleAgents, setRoleAgents] = useState<Record<string, TaskPreferredAgent>>(() => buildRoleAgentsFromMeta(meta, roles))
const [recruitmentAgent, setRecruitmentAgent] = useState<TaskPreferredAgent>(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
const isResponded = responded
const recommendAutoRecruit = meta.recommended_action === 'auto_recruit' && templates.length > 0
useEffect(() => {
if (!isResponded || !hasSubmittedCheckpointMetadata(meta)) return
setSelections(buildSelectionsFromMeta(meta, roles))
setRoleAgents(buildRoleAgentsFromMeta(meta, roles))
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
}, [isResponded, meta, roles])
useEffect(() => {
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
}, [meta.recruitment_agent])
const buildReplyMetadata = useCallback((action: 'manual_approve' | 'auto_recruit'): CheckpointReplyMetadata => {
const checkpointId = String(meta.checkpoint_id ?? '').trim()
if (!checkpointId) {
throw new Error('Staffing checkpoint reply requires checkpoint_id metadata.')
}
return {
response_to_checkpoint_id: checkpointId,
response_to_checkpoint_type: 'company_staffing_selection',
staffing_action: action,
staffing_selections: selections,
recruitment_agent: recruitmentAgent,
recruitment_role_agents: roles.reduce<Record<string, TaskPreferredAgent>>((acc, role) => {
const roleId = String(role.role_id ?? '').trim()
if (!roleId) return acc
acc[roleId] = roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
return acc
}, {}),
}
}, [meta.checkpoint_id, recruitmentAgent, roleAgents, roles, selections])
const handleApprove = useCallback(() => {
if (isResponded) return
onReply('approve', buildReplyMetadata('manual_approve'))
}, [buildReplyMetadata, isResponded, onReply])
const handleAutoRecruit = useCallback(() => {
if (isResponded) return
onReply('auto recruit', buildReplyMetadata('auto_recruit'))
}, [buildReplyMetadata, isResponded, onReply])
return (
<div className="ckpt-panel ckpt-staffing">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-staffing">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<path d="M1.5 14c.6-2.5 2.2-4 4.5-4s3.9 1.5 4.5 4" />
<path d="M12.5 3.5v5M10 6h5" />
</svg>
</div>
<div className="ckpt-title">Manual Staffing</div>
<span className="ckpt-badge ckpt-badge-profile">{meta.company_profile || 'corporate'}</span>
{recommendAutoRecruit && !isResponded && <span className="ckpt-badge ckpt-badge-scope">Recruit recommended</span>}
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
</div>
{meta.summary && <div className="ckpt-summary">{meta.summary}</div>}
<div className="ckpt-recruiter-agent">
<label className="ckpt-agent-label" htmlFor={`staffing-recruiter-agent-${meta.checkpoint_id}`}>
Recruiter Agent
</label>
<select
id={`staffing-recruiter-agent-${meta.checkpoint_id}`}
className="ckpt-agent-select"
value={recruitmentAgent}
onChange={event => setRecruitmentAgent(event.target.value as TaskPreferredAgent)}
disabled={isResponded}
>
{RECRUITMENT_AGENT_OPTIONS.map(agent => (
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
))}
</select>
</div>
<div className="ckpt-staffing-grid">
{roles.map(role => {
const roleId = String(role.role_id ?? '').trim()
const options = optionsByRole[roleId] ?? [{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' }]
const selected = optionForSelection(options, selections[roleId])
const query = queries[roleId] ?? ''
const visibleOptions = options.filter(option => optionMatches(option, query)).slice(0, 8)
return (
<div key={roleId} className="ckpt-staffing-card">
<div className="ckpt-proposal-header">
<span className="ckpt-role-name">{roleId}</span>
<span className={`ckpt-badge ckpt-badge-${selected.kind}`}>{selected.kind}</span>
</div>
{role.role_label && role.role_label !== roleId && (
<div className="ckpt-role-labels">
<span className="ckpt-field-tag">{role.role_label}</span>
</div>
)}
<div className="ckpt-staffing-selected">
<div className="ckpt-cand-name">{selected.name}</div>
<div className="ckpt-cand-meta">
<span className="ckpt-cand-category">{selected.category}</span>
<span className="ckpt-domain-tag">{selected.subtitle}</span>
</div>
</div>
<input
className="ckpt-staffing-search"
value={query}
onChange={event => setQueries(current => ({ ...current, [roleId]: event.target.value }))}
placeholder="Search employees or templates..."
disabled={isResponded}
/>
<div className="ckpt-staffing-options">
{visibleOptions.map(option => {
const active = `${option.kind}:${option.id}` === selectionKey(selections[roleId])
return (
<button
key={`${option.kind}:${option.id}`}
className={`ckpt-staffing-option${active ? ' active' : ''}`}
onClick={() => setSelections(current => ({ ...current, [roleId]: { kind: option.kind, id: option.id } }))}
disabled={isResponded}
title={option.subtitle}
>
<span className="ckpt-staffing-option-kind">{option.kind}</span>
<span className="ckpt-staffing-option-name">{option.name}</span>
</button>
)
})}
</div>
<div className="ckpt-agent-picker">
<label className="ckpt-agent-label" htmlFor={`staffing-agent-${meta.checkpoint_id}-${roleId}`}>
Execution Agent
</label>
<select
id={`staffing-agent-${meta.checkpoint_id}-${roleId}`}
className="ckpt-agent-select"
value={roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT}
onChange={event => setRoleAgents(current => ({ ...current, [roleId]: event.target.value as TaskPreferredAgent }))}
disabled={isResponded}
>
{TASK_AGENT_OPTIONS.map(agent => (
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
))}
</select>
</div>
</div>
)
})}
</div>
{!isResponded && (
<div className="ckpt-actions">
{recommendAutoRecruit ? (
<>
<button className="ckpt-btn ckpt-btn-approve" onClick={handleAutoRecruit}>Auto Recruit</button>
<button className="ckpt-btn ckpt-btn-feedback" onClick={handleApprove}>Approve Selections</button>
</>
) : (
<>
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove}>Approve Selections</button>
{templates.length > 0 && <button className="ckpt-btn ckpt-btn-feedback" onClick={handleAutoRecruit}>Auto Recruit</button>}
</>
)}
</div>
)}
</div>
)
})
@@ -0,0 +1,390 @@
/**
* SVG icon set — geometry derived from Lucide (https://lucide.dev), MIT.
*
* We inline the paths instead of pulling in `lucide-react` to keep the
* production bundle tight. The icons follow Lucide conventions:
* - 24×24 design grid
* - 2px stroke (1.5 for very small chrome icons)
* - round caps & joins
* - currentColor everywhere so CSS controls tone
*
* When adding a new icon, copy the geometry from the official Lucide
* source (https://github.com/lucide-icons/lucide/tree/main/icons) so the
* whole set stays visually consistent.
*/
import React from 'react'
export function IconBrain() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C9.5 2 7.5 3.5 7 5.5C5 5.5 3 7.5 3 10C3 12 4.5 13.5 6 14V20C6 21.1 6.9 22 8 22H16C17.1 22 18 21.1 18 20V14C19.5 13.5 21 12 21 10C21 7.5 19 5.5 17 5.5C16.5 3.5 14.5 2 12 2Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 2V8M8 6H16M12 14V18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconTool() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94L6.73 20.15a2.12 2.12 0 0 1-3-3l6.72-6.72a6 6 0 0 1 7.94-7.94L14.7 6.3z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconStop() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<rect x="4" y="4" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.5" />
<rect x="8" y="8" width="8" height="8" rx="1.5" fill="currentColor" />
</svg>
)
}
export function IconSend() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M5 12L3 3L21 12L3 21L5 12ZM5 12H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconCopy() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<rect x="9" y="9" width="13" height="13" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconCheck() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
/**
* Welcome-state chat icon. Lucide `message-square-text`: a squared bubble
* with two transcript lines, which reads as "a real conversation" much
* better than a generic empty bubble at large sizes.
*/
export function IconChat() {
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M13 8H7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M17 12H7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
export function IconPaperclip() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.49" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconChevron({ down }: { down?: boolean }) {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ transition: 'transform 200ms', transform: down ? 'rotate(90deg)' : 'none' }}>
<path d="M9 18L15 12L9 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconPlus() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 5V19M5 12H19" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconSearch() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="1.5" />
<path d="M21 21L16.65 16.65" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconTrash() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 6H5H21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6L18.13 20.11A2 2 0 0 1 16.14 22H7.86a2 2 0 0 1-2-1.89L5 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconBoard() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="3" y="16" width="7" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
</svg>
)
}
export function IconActivity() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M22 12H18L15 21L9 3L6 12H2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconShield() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
/**
* Agent-avatar sparkle. Lucide `sparkles`: one large 4-point burst plus
* two small accent sparks — reads as "AI/magic" without the generic
* single-star look.
*/
export function IconSparkle() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M20 3v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M22 5h-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M4 17v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M5 18H3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
/** Lucide `arrow-right` — two crisp strokes, no diagonal artifacts. */
export function IconArrowRight() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M5 12h14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
<path d="m12 5 7 7-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconGate() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M9 11L12 14L22 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 12V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconZap() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
// ── Work Item / Runtime Panel icons ──────────────────────────────────────
export function IconWorkItem() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M4 18H10V14H4V18Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 14H14V10H8V14Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 10H18V6H12V10Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18 8L21 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconGatePass() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9 12L11 14L15 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconGateReject() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15 9L9 15M9 9L15 15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconClock() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" />
<path d="M12 6V12L16 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconHandoff() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M7 17L2 12L7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M17 7L22 12L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 12H22" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconTimeline() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2V22" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="12" cy="6" r="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="12" cy="12" r="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="12" cy="18" r="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M14 6H20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M14 12H18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M14 18H20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function IconClose() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
// ── Mode / lock icons (shared by the composer mode chip) ─────────────────
/**
* Lucide `lock` — the classic shackle + body. Size defaults to 12px so it
* tucks nicely inside a chip; pass a size prop to scale.
*/
export function IconLock({ size = 12 }: { size?: number } = {}) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 11V7a5 5 0 0 1 10 0v4"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** Lucide `user-round` — proportional head + shoulders, perfect for Task mode. */
export function IconUserRound({ size = 14 }: { size?: number } = {}) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle
cx="12"
cy="8"
r="5"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M20 21a8 8 0 0 0-16 0"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** Lucide `building-2` — multi-story office building, reads as "company". */
export function IconBuilding({ size = 14 }: { size?: number } = {}) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path
d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M10 6h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M10 10h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M10 14h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M10 18h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
/**
* Compact 3-spark "Sparkles" for the Org mode tile. We reuse the full
* `sparkles` geometry but at a smaller default size — same family, less
* visual weight than the agent avatar usage.
*/
export function IconSparkles({ size = 14 }: { size?: number } = {}) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M20 3v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M22 5h-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M4 17v2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M5 18H3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { TaskHeaderBar } from './TaskHeaderBar'
import type { Session } from '../types/kanban'
import type { AgentInfo } from '../types/visual'
const now = Date.now()
const nexusAgent: AgentInfo = {
agent_id: 'nexus',
name: 'NEXUS Executive Brief',
description: 'Executive briefing agent',
specialties: [],
status: 'idle',
appearance: { palette: 0, hue_shift: 0, seat_zone: 'north' },
}
function makeSession(overrides: Partial<Session>): Session {
return {
projectId: 'project-a',
taskId: 'task-a',
channelId: 'channel-a',
execMode: 'task',
title: 'NEXUS Executive Brief',
status: 'running',
columnId: 'in-progress',
assigneeIds: ['nexus'],
priority: null,
tags: [],
progressLog: [],
createdAt: now - 60_000,
updatedAt: now,
messageCount: 1,
...overrides,
}
}
const companyMarkup = renderToStaticMarkup(
React.createElement(TaskHeaderBar, {
session: makeSession({
execMode: 'org',
isCompanyRuntime: true,
workItemRoleName: 'Chief Analyst',
employeeAssignment: { name: 'NEXUS Executive Brief', employeeId: 'employee-nexus' },
selectedExecutionAgent: 'codex',
displayTool: 'opc-collab delegate_work',
currentTool: undefined,
}),
agents: [nexusAgent],
onTitleChange: () => undefined,
}),
)
assert.match(companyMarkup, /Chief Analyst/, 'company header should keep the role pill')
assert.match(companyMarkup, /NEXUS Executive Brief/, 'company header should keep the employee label')
assert.match(companyMarkup, /Codex/, 'company header should keep the execution agent label')
assert.match(companyMarkup, /opc-collab delegate_work/, 'company header should show stable displayTool while still running')
assert.doesNotMatch(
companyMarkup,
/task-header-avatar/,
'company header must not render a duplicate assignee initial avatar',
)
const taskMarkup = renderToStaticMarkup(
React.createElement(TaskHeaderBar, {
session: makeSession({ execMode: 'task' }),
agents: [nexusAgent],
onTitleChange: () => undefined,
}),
)
assert.match(
taskMarkup,
/task-header-avatar/,
'plain task headers should keep assignee initial avatars',
)
console.log('TaskHeaderBar.test.tsx: OK (company header hides duplicate avatar and keeps stable tool label)')
@@ -0,0 +1,265 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import type { Session } from '../types/kanban'
import type { AgentInfo } from '../types/visual'
import { IconStop, IconBoard, IconTool, IconCheck } from './SvgIcons'
import { getSessionRuntimeStatus, isSessionWorking } from '../lib/sessionRuntime'
import { getWorkItemRoleLabel } from '../lib/workItemIdentity'
interface TaskHeaderBarProps {
session: Session
agents: AgentInfo[]
onTitleChange: (taskId: string, title: string) => void
onViewOnBoard?: () => void
onStop?: () => void
onComplete?: () => void
onResume?: () => void
}
function relativeTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60_000) return 'just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
return `${Math.floor(diff / 86_400_000)}d ago`
}
const STATUS_META: Record<string, { color: string; label: string }> = {
running: { color: 'var(--green)', label: 'Running' },
idle: { color: 'var(--accent)', label: 'Idle' },
done: { color: 'var(--green)', label: 'Done' },
pending: { color: 'var(--text-secondary)', label: 'Pending' },
failed: { color: 'var(--red)', label: 'Failed' },
cancelled: { color: 'var(--text-secondary)', label: 'Cancelled' },
blocked: { color: 'var(--yellow)', label: 'Blocked' },
awaiting_human: { color: 'var(--yellow)', label: 'Waiting for review' },
awaiting_manager_review: { color: 'var(--yellow)', label: 'Manager review' },
awaiting_review: { color: 'var(--yellow)', label: 'Waiting for review' },
awaiting_peer: { color: 'var(--yellow)', label: 'Waiting for peer' },
}
const HUMAN_REVIEW_STATUSES = new Set([
'awaiting_human',
'awaiting_manager_review',
'awaiting_review',
'awaiting_peer',
])
const EXECUTION_AGENT_LABELS: Record<string, string> = {
native: 'Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
export function TaskHeaderBar({ session, agents, onTitleChange, onViewOnBoard, onStop, onComplete, onResume }: TaskHeaderBarProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(session.title)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!editing) setDraft(session.title)
}, [session.title, editing])
const commitTitle = useCallback(() => {
const trimmed = draft.trim()
if (trimmed && trimmed !== session.title) {
onTitleChange(session.taskId, trimmed)
} else {
setDraft(session.title)
}
setEditing(false)
}, [draft, session.title, session.taskId, onTitleChange])
const startEditing = useCallback(() => {
setDraft(session.title)
setEditing(true)
setTimeout(() => inputRef.current?.select(), 0)
}, [session.title])
const assignees = session.assigneeIds
.map(id => agents.find(a => a.agent_id === id))
.filter(Boolean) as AgentInfo[]
const execMode = String(session.execMode ?? '').trim().toLowerCase()
const isCompanyHeaderSession = execMode === 'company'
|| execMode === 'org'
|| execMode === 'custom'
|| !!session.isCompanyRuntime
|| !!session.workItemProjectionId
|| !!session.workItemRoleId
|| !!session.workItemRoleName
const showAssigneeAvatars = assignees.length > 0 && !isCompanyHeaderSession
const runtimeControlState = session.runtimeControlState ?? (session.status === 'running' ? 'running' : 'idle')
const isSuspending = runtimeControlState === 'suspending'
const isResuming = runtimeControlState === 'resuming'
const isSuspended = runtimeControlState === 'suspended'
const isRunning = session.status === 'running' && !isSuspending && !isSuspended && !isResuming
const isAwaitingReview = HUMAN_REVIEW_STATUSES.has(session.status)
const canStop = (session.canStop ?? session.status === 'running') && !isSuspending && !isSuspended && !isResuming
const canResume = (
session.canResume
?? (isSuspended || (!isAwaitingReview && !isRunning && session.status !== 'done' && session.status !== 'pending'))
) && !isSuspending && !isResuming
const meta = STATUS_META[session.status] ?? STATUS_META.pending
const statusLabel = isSuspending ? 'Stopping' : isSuspended ? 'Suspended' : isResuming ? 'Resuming' : meta.label
const roleLabel = getWorkItemRoleLabel(session)
const runtimeStatus = getSessionRuntimeStatus(session)
const isWorking = isSessionWorking(session)
const liveTool = session.displayTool || session.currentTool
// Sticky tool label tied to the RUN lifecycle (not the transient agentStatus).
// The native runtime reports an 'idle'/'reflecting' state with no current_tool
// between consecutive tool calls; reacting to that blanks the pill for a frame
// and makes the command flicker once per call. Instead, keep showing the last
// non-empty command for as long as the session is running, and drop it only
// when the run stops — so the pill holds steady and just swaps to the next tool.
const [stickyTool, setStickyTool] = useState<string | undefined>(liveTool || undefined)
useEffect(() => {
if (!isRunning) { setStickyTool(undefined); return }
if (liveTool) setStickyTool(liveTool)
}, [isRunning, liveTool])
const hasApprovalMetrics = typeof session.pendingPermissionCount === 'number' && session.pendingPermissionCount > 0
const showRuntimeMetrics = hasApprovalMetrics
const statusDotColor = isSuspending
? 'var(--yellow)'
: isSuspended
? 'var(--text-secondary)'
: runtimeStatus === 'tool_active'
? 'var(--green)'
: runtimeStatus === 'reflecting'
? 'var(--yellow)'
: meta.color
return (
<div className="task-header-shell">
<div className="task-header-bar">
<div className="task-header-left">
{editing ? (
<input
ref={inputRef}
className="task-title-input"
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={e => {
if (e.key === 'Enter') commitTitle()
if (e.key === 'Escape') { setDraft(session.title); setEditing(false) }
}}
/>
) : (
<span className="task-title" onClick={startEditing} title="Click to edit">
{session.title}
</span>
)}
<span className="task-status-pill" data-status={session.status} data-working={isWorking ? 'true' : 'false'}>
<span className="task-status-dot" style={{ background: statusDotColor }} />
{statusLabel}
</span>
{isRunning && stickyTool && (
<span className="task-tool-pill">
<IconTool />
<code>{stickyTool}</code>
{session.currentTool && typeof session.toolElapsedMs === 'number' && session.toolElapsedMs > 0 && (
<span>{session.toolElapsedMs}ms</span>
)}
</span>
)}
{session.lastToolSummary && (
<span className="task-projection-pill" title={session.lastToolSummary}>
{session.lastToolSummary.slice(0, 48)}
</span>
)}
{/* Projection id (e.g. "attention::seat::team::cto::cto::review::f42cf81f")
is an internal debug identifier — useful in the Info tab but
adds noise to the header bar. Surface it via title-tooltip on
the role pill rather than as a wide chip. */}
{roleLabel && (
<span className="task-role-pill" title={`Role: ${roleLabel}`}>
{roleLabel}
</span>
)}
{session.employeeAssignment?.name && (
<span className="task-employee-pill" title={`Employee: ${session.employeeAssignment.name}${session.employeeAssignment.category ? ` (${session.employeeAssignment.category})` : ''}`}>
<span className="task-employee-icon">&#x1F464;</span>
{session.employeeAssignment.name}
</span>
)}
{session.selectedExecutionAgent && (
<span
className="task-agent-pill"
title={`Execution Agent: ${EXECUTION_AGENT_LABELS[session.selectedExecutionAgent] ?? session.selectedExecutionAgent}`}
>
<span className="task-agent-icon">&#x2699;</span>
{EXECUTION_AGENT_LABELS[session.selectedExecutionAgent] ?? session.selectedExecutionAgent}
</span>
)}
</div>
<div className="task-header-right">
{showAssigneeAvatars && (
<div className="task-header-avatars">
{assignees.slice(0, 3).map(a => (
<span key={a.agent_id} className="task-header-avatar" title={a.name}>
{a.name.charAt(0).toUpperCase()}
</span>
))}
</div>
)}
<span className="task-header-time" title={new Date(session.createdAt).toLocaleString()}>
{relativeTime(session.createdAt)}
</span>
{(canStop || isSuspending) && onStop && (
<button className="task-stop-btn" onClick={onStop} title="Stop task" disabled={!canStop}>
<IconStop />
<span>{isSuspending ? 'Stopping...' : 'Stop'}</span>
</button>
)}
{canResume && onResume && session.status !== 'done' && (
<button
className="task-resume-btn"
onClick={onResume}
title="Resume prior runtime (re-awaken original team, no new plan)"
>
<span>Continue</span>
</button>
)}
{onComplete && (
<button className="task-done-btn" onClick={onComplete} title="Mark task as done">
<IconCheck />
<span>Done</span>
</button>
)}
{onViewOnBoard && (
<button className="task-board-btn" onClick={onViewOnBoard} title="View on Board">
<IconBoard />
</button>
)}
</div>
</div>
{showRuntimeMetrics && (
<div className="task-header-metrics">
{hasApprovalMetrics && (
<div className="task-runtime-metric task-runtime-metric-approval" title="Pending approvals">
<span className="task-runtime-metric-label">Approvals</span>
<span className="task-runtime-metric-value">{session.pendingPermissionCount}</span>
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { TaskUserInputPanel } from './TaskUserInputPanel'
const markup = renderToStaticMarkup(
React.createElement(TaskUserInputPanel, {
meta: {
checkpoint_type: 'task_user_input',
checkpoint_id: 'cp-input',
task_id: 'task-1',
work_item_projection_title: 'Engineer Input',
summary: 'Need one missing decision.',
prompt: 'Please answer:\n\n- Which provider?\n- Which tier?\n\n```txt\nstripe\n```',
questions: ['Which provider should be used?'],
required_fields: ['provider'],
context_note: 'Known context:\n\n- User wants checkout',
requesting_role_id: 'engineer',
requesting_task_id: 'task-1',
requesting_work_item_id: 'work-item-1',
seat_id: 'seat::team::engineering::engineer',
active_subagents: [{ id: 'sub-1' }],
permission_requests: [{ id: 'perm-1' }],
worktree_path: '/tmp/work',
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(markup, /Engineer Input/)
assert.match(markup, /<li>Which provider\?<\/li>/)
assert.match(markup, /<code class="language-txt">/)
assert.match(markup, /<summary>Runtime State<\/summary>/)
assert.match(markup, /Requester: <code>engineer<\/code>/)
assert.match(markup, /Work item: <code>work-item-1<\/code>/)
assert.match(markup, /Active subagents: 1/)
const choiceMarkup = renderToStaticMarkup(
React.createElement(TaskUserInputPanel, {
meta: {
checkpoint_type: 'task_user_input',
checkpoint_id: 'cp-choice',
task_id: 'task-2',
work_item_projection_title: 'Deployment Input',
summary: 'Need a deployment decision.',
prompt: 'Choose a region before continuing.',
questions: ['Which deployment region should be used?'],
input_questions: [
{
id: 'deployment_region',
header: 'Deployment region',
question: 'Which deployment region should I target?\n\n- Pick one if there is a clear preference.',
options: [
{ id: 'a', label: 'US East', description: 'Use us-east-1' },
{ id: 'b', label: 'EU West', description: 'Use eu-west-1' },
{ id: 'c', label: 'Asia', description: 'Use ap-east-1' },
],
allow_freeform: true,
required: true,
},
],
required_fields: ['deployment_region'],
},
onReply: () => undefined,
responded: false,
}),
)
assert.match(choiceMarkup, /Deployment region/)
assert.match(choiceMarkup, /ckpt-choice-option/)
assert.match(choiceMarkup, /US East/)
assert.match(choiceMarkup, /EU West/)
assert.match(choiceMarkup, /Asia/)
assert.match(choiceMarkup, /Other/)
assert.match(choiceMarkup, /disabled=""/)
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'TaskUserInputPanel.tsx'), 'utf8')
assert.doesNotMatch(src, /localResponded|setLocalResponded/, 'panel must wait for server checkpoint metadata before showing responded state')
assert.match(src, /user_input_answers/, 'structured answers must be forwarded to the backend')
const messageListSrc = readFileSync(join(here, 'MessageList.tsx'), 'utf8')
const progressIndex = messageListSrc.indexOf("items.push({ kind: 'progress-block' })")
const pendingIndex = messageListSrc.indexOf("items.push({ kind: 'pending-section' })")
const endIndex = messageListSrc.indexOf("items.push({ kind: 'end-anchor' })")
assert.ok(progressIndex !== -1 && pendingIndex !== -1 && endIndex !== -1)
assert.ok(progressIndex < pendingIndex, 'pending checkpoint cards should render after the progress block')
assert.ok(pendingIndex < endIndex, 'pending checkpoint cards should render before the end anchor')
console.log('TaskUserInputPanel.test.tsx: OK (markdown and choice checkpoint panel)')
@@ -0,0 +1,308 @@
import React, { useCallback, useMemo, useState } from 'react'
import type {
ChatMessageMeta,
CheckpointReplyMetadata,
TaskUserInputAnswer,
TaskUserInputQuestion,
} from '../types/chat'
import { MarkdownBody } from './MarkdownBody'
interface TaskUserInputPanelProps {
meta: ChatMessageMeta
onReply: (text: string, metadata?: Partial<CheckpointReplyMetadata>) => void
responded: boolean
}
interface QuestionState {
selectedOptionId?: string
freeformText: string
}
const OTHER_OPTION_ID = '__other__'
const OPTION_LETTERS = ['A', 'B', 'C']
function cleanText(value: unknown): string {
return String(value ?? '').trim()
}
function normalizeQuestion(raw: TaskUserInputQuestion, index: number): TaskUserInputQuestion | null {
const question = cleanText(raw.question)
const header = cleanText(raw.header)
if (!question && !header) return null
const options = (raw.options ?? [])
.slice(0, 3)
.map((option, optionIndex) => ({
id: cleanText(option.id) || String.fromCharCode(97 + optionIndex),
label: cleanText(option.label),
description: cleanText(option.description),
}))
.filter((option) => option.label)
return {
id: cleanText(raw.id) || `question_${index + 1}`,
header,
question: question || header,
options,
allow_freeform: raw.allow_freeform !== false,
required: raw.required !== false,
}
}
export const TaskUserInputPanel = React.memo(function TaskUserInputPanel({
meta, onReply, responded,
}: TaskUserInputPanelProps) {
const [reply, setReply] = useState('')
const [answers, setAnswers] = useState<Record<string, QuestionState>>({})
const isResponded = responded
const title = String(meta.work_item_projection_title ?? meta.work_item_projection_id ?? 'Input Needed').trim() || 'Input Needed'
const summary = String(meta.summary ?? '').trim()
const prompt = String(meta.prompt ?? '').trim()
const contextNote = String(meta.context_note ?? '').trim()
const resumeHint = String(meta.resume_hint ?? '').trim()
const questions = useMemo(
() => (meta.questions ?? []).map((item) => String(item).trim()).filter(Boolean),
[meta.questions],
)
const inputQuestions = useMemo(
() => (meta.input_questions ?? [])
.map((item, index) => normalizeQuestion(item, index))
.filter((item): item is TaskUserInputQuestion => item !== null),
[meta.input_questions],
)
const usesChoiceMode = inputQuestions.some((question) => (question.options ?? []).length > 0)
const requiredFields = useMemo(
() => (meta.required_fields ?? []).map((item) => String(item).trim()).filter(Boolean),
[meta.required_fields],
)
const activeSubagents = useMemo(
() => (meta.active_subagents ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.active_subagents],
)
const permissionRequests = useMemo(
() => (meta.permission_requests ?? []).filter((item) => !!item && typeof item === 'object'),
[meta.permission_requests],
)
const worktreePath = String(meta.worktree_path ?? '').trim()
const requestingRoleId = String(meta.requesting_role_id ?? '').trim()
const requestingTaskId = String(meta.requesting_task_id ?? '').trim()
const requestingWorkItemId = String(meta.requesting_work_item_id ?? '').trim()
const seatId = String(meta.seat_id ?? '').trim()
const hasRequesterState = !!requestingRoleId || !!requestingTaskId || !!requestingWorkItemId || !!seatId
const hasRuntimeState = hasRequesterState || activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath
const setSelectedOption = useCallback((questionId: string, optionId: string) => {
setAnswers((current) => ({
...current,
[questionId]: {
freeformText: current[questionId]?.freeformText ?? '',
selectedOptionId: optionId,
},
}))
}, [])
const setFreeformAnswer = useCallback((questionId: string, value: string) => {
setAnswers((current) => ({
...current,
[questionId]: {
selectedOptionId: current[questionId]?.selectedOptionId,
freeformText: value,
},
}))
}, [])
const questionComplete = useCallback((question: TaskUserInputQuestion) => {
if (question.required === false) return true
const state = answers[question.id]
const selected = state?.selectedOptionId
if (selected && selected !== OTHER_OPTION_ID) return true
if (question.allow_freeform !== false && cleanText(state?.freeformText)) return true
return false
}, [answers])
const canSubmitStructured = usesChoiceMode && inputQuestions.every(questionComplete)
const handleSubmitLegacy = useCallback(() => {
const text = reply.trim()
if (isResponded || !text) return
onReply(text)
}, [isResponded, onReply, reply])
const handleSubmitStructured = useCallback(() => {
if (isResponded || !canSubmitStructured) return
const answerMetadata: Record<string, TaskUserInputAnswer> = {}
const lines: string[] = []
inputQuestions.forEach((question) => {
const state = answers[question.id] ?? { freeformText: '' }
const selectedOption = (question.options ?? []).find((option) => option.id === state.selectedOptionId)
const freeformText = cleanText(state.freeformText)
const label = selectedOption?.label ?? ''
const answerText = [label, freeformText].filter(Boolean).join('; ')
answerMetadata[question.id] = {
question_id: question.id,
question: question.question,
...(selectedOption ? {
selected_option_id: selectedOption.id,
selected_label: selectedOption.label,
} : {}),
...(freeformText ? { freeform_text: freeformText } : {}),
answer_text: answerText,
}
const displayQuestion = cleanText(question.header) || cleanText(question.question) || question.id
lines.push(`- ${displayQuestion}: ${answerText || '(no answer)'}`)
})
onReply(lines.join('\n'), { user_input_answers: answerMetadata })
}, [answers, canSubmitStructured, inputQuestions, isResponded, onReply])
return (
<div className="ckpt-panel ckpt-user-input">
<div className="ckpt-header">
<div className="ckpt-icon ckpt-icon-user-input">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3.5h10v6H6.5L3 13V3.5Z" />
<path d="M5.5 6h5" />
<path d="M5.5 8h3.5" />
</svg>
</div>
<div className="ckpt-title">{title}</div>
<span className="ckpt-badge ckpt-badge-scope">awaiting input</span>
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
</div>
{summary && (
<div className="ckpt-section">
<div className="ckpt-section-title">Summary</div>
<MarkdownBody content={summary} className="ckpt-markdown" />
</div>
)}
{prompt && (
<div className="ckpt-section">
<div className="ckpt-section-title">Request</div>
<MarkdownBody content={prompt} className="ckpt-markdown" />
</div>
)}
{usesChoiceMode ? (
<div className="ckpt-section">
<div className="ckpt-section-title">Questions</div>
<div className="ckpt-choice-list">
{inputQuestions.map((question) => {
const state = answers[question.id] ?? { freeformText: '' }
const options = question.options ?? []
const showOther = question.allow_freeform !== false
const showOtherInput = showOther && (options.length === 0 || state.selectedOptionId === OTHER_OPTION_ID)
return (
<div className="ckpt-choice-question" key={question.id}>
{question.header && <div className="ckpt-question-header">{question.header}</div>}
<MarkdownBody content={question.question} className="ckpt-markdown" />
{options.length > 0 && (
<div className="ckpt-choice-grid">
{options.map((option, optionIndex) => {
const selected = state.selectedOptionId === option.id
return (
<button
key={option.id}
className={`ckpt-choice-option${selected ? ' is-selected' : ''}`}
onClick={() => setSelectedOption(question.id, option.id)}
>
<span className="ckpt-choice-letter">{OPTION_LETTERS[optionIndex] ?? '?'}</span>
<span className="ckpt-choice-copy">
<span className="ckpt-choice-label">{option.label}</span>
{option.description && <span className="ckpt-choice-desc">{option.description}</span>}
</span>
</button>
)
})}
{showOther && (
<button
className={`ckpt-choice-option${state.selectedOptionId === OTHER_OPTION_ID ? ' is-selected' : ''}`}
onClick={() => setSelectedOption(question.id, OTHER_OPTION_ID)}
>
<span className="ckpt-choice-letter">D</span>
<span className="ckpt-choice-copy">
<span className="ckpt-choice-label">Other</span>
<span className="ckpt-choice-desc">Enter a custom answer</span>
</span>
</button>
)}
</div>
)}
{showOtherInput && (
<textarea
className="ckpt-feedback-input ckpt-other-field"
placeholder="Type your answer..."
value={state.freeformText}
onChange={(event) => setFreeformAnswer(question.id, event.target.value)}
rows={3}
/>
)}
</div>
)
})}
</div>
</div>
) : questions.length > 0 && (
<div className="ckpt-section">
<div className="ckpt-section-title">Questions</div>
<ul className="ckpt-task-list">
{questions.map((question) => <li key={question}>{question}</li>)}
</ul>
</div>
)}
{requiredFields.length > 0 && (
<div className="ckpt-section">
<div className="ckpt-section-title">Required Fields</div>
<div className="ckpt-task-tags">
{requiredFields.map((field) => <span key={field} className="ckpt-field-tag">{field}</span>)}
</div>
</div>
)}
{contextNote && (
<div className="ckpt-section">
<div className="ckpt-section-title">Context</div>
<MarkdownBody content={contextNote} className="ckpt-markdown ckpt-markdown-muted" />
</div>
)}
{resumeHint && <div className="ckpt-escalation-hint">{resumeHint}</div>}
{hasRuntimeState && (
<details className="ckpt-runtime-details">
<summary>Runtime State</summary>
<div className="ckpt-runtime-body">
{requestingRoleId && <div>Requester: <code>{requestingRoleId}</code></div>}
{requestingWorkItemId && <div>Work item: <code>{requestingWorkItemId}</code></div>}
{requestingTaskId && <div>Task: <code>{requestingTaskId}</code></div>}
{seatId && <div>Seat: <code>{seatId}</code></div>}
{worktreePath && <div>Worktree: <code>{worktreePath}</code></div>}
{activeSubagents.length > 0 && <div>Active subagents: {activeSubagents.length}</div>}
{permissionRequests.length > 0 && <div>Pending permission records: {permissionRequests.length}</div>}
</div>
</details>
)}
{!isResponded && (
<div className="ckpt-feedback-area">
{usesChoiceMode ? null : (
<textarea
className="ckpt-feedback-input"
placeholder="Reply with the missing input to continue..."
value={reply}
onChange={(e) => setReply(e.target.value)}
rows={3}
/>
)}
<div className="ckpt-feedback-btns">
<button
className="ckpt-btn ckpt-btn-approve"
onClick={usesChoiceMode ? handleSubmitStructured : handleSubmitLegacy}
disabled={usesChoiceMode ? !canSubmitStructured : !reply.trim()}
>
Send Reply
</button>
</div>
</div>
)}
</div>
)
})
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { WorkItemProgressCard } from './WorkItemProgressCard'
import type { RoleWorkItemSummary } from '../types/kanban'
const currentOwnerRoleWorkItems: Record<string, RoleWorkItemSummary> = {
cto: {
roleKey: 'cto',
roleId: 'cto',
roleName: 'CTO',
runtimeStatus: 'idle',
aggregatedStatus: 'waiting',
workItems: [
{
workItemId: 'wi-review',
phase: 'awaiting_manager_review',
kanbanColumn: 'in-review',
title: 'Implement summary',
kind: 'execute',
isReviewTarget: true,
executorRoleId: 'engineer',
reviewerRoleId: 'cto',
createdAt: 10,
updatedAt: 20,
executionTurnId: 'runtime-task-1',
progressLog: [],
},
],
},
}
const executorRoleWorkItems: Record<string, RoleWorkItemSummary> = {
engineer: {
roleKey: 'engineer',
roleId: 'engineer',
roleName: 'Engineer',
runtimeStatus: 'idle',
aggregatedStatus: 'waiting',
workItems: [
{
workItemId: 'wi-review',
phase: 'awaiting_manager_review',
kanbanColumn: 'in-review',
title: 'Implement summary',
kind: 'execute',
isReviewTarget: true,
executorRoleId: 'engineer',
reviewerRoleId: 'cto',
createdAt: 10,
updatedAt: 20,
executionTurnId: 'runtime-task-1',
progressLog: [],
},
],
},
}
const executorMarkup = renderToStaticMarkup(
React.createElement(WorkItemProgressCard, {
workItemLog: [],
roleWorkItems: currentOwnerRoleWorkItems,
executorRoleWorkItems,
isCompanyRuntime: true,
}),
)
assert.match(executorMarkup, /Execution Progress/)
assert.match(executorMarkup, /Engineer/)
assert.doesNotMatch(executorMarkup, /CTO/)
const fallbackMarkup = renderToStaticMarkup(
React.createElement(WorkItemProgressCard, {
workItemLog: [],
roleWorkItems: currentOwnerRoleWorkItems,
isCompanyRuntime: true,
}),
)
assert.match(fallbackMarkup, /CTO/)
assert.doesNotMatch(fallbackMarkup, /Engineer/)
console.log('WorkItemProgressCard.test.tsx: OK (executor rollup preferred with current-owner fallback)')
@@ -0,0 +1,576 @@
import React, { useMemo } from 'react'
import type { ReactNode } from 'react'
import type {
AgentAnimStatus,
ProgressEntry,
RoleAggregatedStatus,
RoleWorkItemActivitySection,
RoleWorkItemRow,
RoleWorkItemSummary,
Session,
WorkItemProgressEntry,
} from '../types/kanban'
import { IconWorkItem } from './SvgIcons'
import { getWorkItemAssignmentLabel, humanizeWorkItemRoleId } from '../lib/workItemIdentity'
import { getExecutionTurnId } from '../lib/workItemRuntimeIds'
interface WorkItemProgressCardProps {
workItemLog: WorkItemProgressEntry[]
/**
* Per-role DelegationWorkItem rollup. When present, drives the panel
* (1 row = 1 work item; review work items appear under reviewer role).
* Falls back to ``childSessions`` derivation only when undefined / empty
* (legacy non-company-mode runs).
* Source: ``snapshot_builder._build_role_work_items_for_session``.
*/
roleWorkItems?: Record<string, RoleWorkItemSummary>
/** Display-only executor-role rollup. Company/org Execution Progress
* prefers this so worker chips remain visible while awaiting review. */
executorRoleWorkItems?: Record<string, RoleWorkItemSummary>
/** Legacy session-based source. Kept for non-company-mode and for the
* case where a primary session has no ``roleWorkItems`` payload yet. */
childSessions?: Session[]
/** Company/org mode uses work-item rollups as the only role source. */
isCompanyRuntime?: boolean
onWorkItemClick?: (executionTurnId: string) => void
}
type WorkItemStatus = 'active' | 'done' | 'failed' | 'waiting' | 'pending'
interface WorkItemInfo {
projectionId: string
title: string
roleName?: string
status: WorkItemStatus
executionTurnId?: string
}
interface RoleTurnInfo {
executionTurnId: string
title: string
status: WorkItemStatus
statusLabel: string // kanban column label (To do / In progress / In review / Done)
columnId: string // todo | in_progress | in_review | done
updatedAt: number
/** Set when the row was derived from a DelegationWorkItem rather than
* a runtime Session. Used as the React key and for inline activity
* expansion. */
workItemId?: string
/** Per-row activity entries (already filtered by work-item projection
* on the backend). Empty for session-derived rows. */
progressLog?: ProgressEntry[]
activitySections?: RoleWorkItemActivitySection[]
/** True when this row sits under the reviewer because the work item is
* in an ``in_review`` phase. */
isReviewTarget?: boolean
}
interface RoleSummaryInfo {
roleKey: string
executionTurnId: string // default click target = latest turn
title: string // role display name
status: WorkItemStatus // aggregated across all turns
statusLabel: string // kanban label for aggregated status
executionAgent?: string
roleName?: string
updatedAt: number // most recent turn's updatedAt
turns: RoleTurnInfo[] // chronological ASC (oldest first, newest last)
/** Live tracker state (only set for work-item-driven summaries). When
* ``reflecting`` / ``tool_active`` the chip shows the orange pulse;
* otherwise the chip's colour is governed by ``status`` alone. */
runtimeStatus?: AgentAnimStatus
}
/** Kanban-column labels used both on the role's aggregate chip and on
* each per-turn row. Matches the column headers shown on the kanban
* board so the vocabulary stays consistent across views. */
const COLUMN_LABELS: Record<string, string> = {
todo: 'To do',
in_progress: 'In progress',
in_review: 'In review',
done: 'Done',
}
/** Map the WorkItemStatus derived from a Session back to the kanban column
* it sits in. Keeps the label logic in one place rather than diverging
* between the role chip and the per-turn row. */
function workItemStatusToColumnId(status: WorkItemStatus, fallbackColumnId?: string): string {
if (fallbackColumnId && fallbackColumnId in COLUMN_LABELS) return fallbackColumnId
switch (status) {
case 'active': return 'in_progress'
case 'waiting': return 'in_review'
case 'done': return 'done'
case 'failed': return 'done'
case 'pending': return 'todo'
default: return 'todo'
}
}
function labelForColumnId(columnId: string): string {
return COLUMN_LABELS[columnId] ?? COLUMN_LABELS.todo
}
/** Priority used to aggregate status across a role's turns so the main
* chip reflects "any turn still in flight" rather than whatever was
* updated last. */
const STATUS_AGGREGATE_PRIORITY: Record<WorkItemStatus, number> = {
active: 0,
waiting: 1,
failed: 2,
done: 3,
pending: 4,
}
function aggregateStatus(statuses: WorkItemStatus[]): WorkItemStatus {
if (statuses.length === 0) return 'pending'
let best: WorkItemStatus = statuses[0]
let bestRank = STATUS_AGGREGATE_PRIORITY[best] ?? 99
for (const s of statuses) {
const rank = STATUS_AGGREGATE_PRIORITY[s] ?? 99
if (rank < bestRank) {
best = s
bestRank = rank
}
}
return best
}
const EXECUTION_AGENT_LABELS: Record<string, string> = {
native: 'Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
/** Check if a string looks like a UUID or long hex id */
function isUuidLike(s: string): boolean {
return s.length > 12 && /^[0-9a-f-]+$/i.test(s.replace(/_/g, ''))
}
function trimString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function firstNonEmpty(...values: unknown[]): string {
for (const value of values) {
const text = trimString(value)
if (text) return text
}
return ''
}
function formatExecutionAgent(value?: string): string {
const normalized = trimString(value)
if (!normalized) return ''
return EXECUTION_AGENT_LABELS[normalized] ?? humanizeWorkItemRoleId(normalized)
}
function renderProjectionIcon(status: WorkItemStatus): ReactNode {
if (status === 'done') return <span className="wi-projection-icon">&#10003;</span>
if (status === 'active') {
// Always pulse when the role is "active". Active aggregates two cases
// — (a) tracker is reflecting/tool_active right now, or (b) at least
// one work item is in an in-progress phase. Both read as "this role
// is working" to the user, so both should breathe. The earlier
// refactor that gated pulse on runtime tracker only made finished-but-
// running phases look static, which the user reported as a regression.
return <span className="wi-projection-icon wi-projection-pulse">&#9679;</span>
}
if (status === 'failed') return <span className="wi-projection-icon">&#10007;</span>
if (status === 'waiting') return <span className="wi-projection-icon">&#9679;</span>
return null
}
const AGGREGATED_TO_WORK_ITEM_STATUS: Record<RoleAggregatedStatus, WorkItemStatus> = {
active: 'active',
waiting: 'waiting',
pending: 'pending',
done: 'done',
failed: 'failed',
}
/** Backend ``kanban_column`` returns hyphenated ids (``in-progress`` /
* ``in-review``); this card's CSS classes use underscored forms. The
* conversion lives here so the rest of the file keeps speaking one
* vocabulary. */
function normalizeColumnId(columnId: string): string {
if (!columnId) return 'todo'
if (columnId === 'in-progress') return 'in_progress'
if (columnId === 'in-review') return 'in_review'
return columnId
}
/** Per-row status used for icon + chip colour. Mirrors the backend
* phase → column mapping (``opc/layer2_organization/phase.py``) but
* reduced to the 5 UI states the chip renders. */
function phaseAggregateForRow(phase: string): WorkItemStatus {
switch (phase) {
case 'running':
case 'waiting_for_peer':
case 'waiting_for_children':
case 'paused':
case 'needs_attention':
return 'active'
case 'awaiting_manager_review':
case 'awaiting_human':
case 'queued':
case 'ready':
case 'ready_for_rework':
case 'waiting_dependencies':
return 'waiting'
case 'approved':
return 'done'
case 'failed':
case 'cancelled':
return 'failed'
default:
return 'pending'
}
}
function statusFromSession(session: Session): WorkItemStatus {
const status = trimString(session.status).toLowerCase()
if (status === 'done' || status === 'delivered') return 'done'
if (status === 'failed' || status === 'cancelled') return 'failed'
if (
status === 'awaiting_peer'
|| status === 'awaiting_manager_review'
|| status === 'awaiting_human'
|| status === 'awaiting_review'
|| status === 'blocked'
|| status === 'paused'
|| status === 'awaiting_owner'
) {
return 'waiting'
}
if (status === 'running' || status === 'deliverable' || status === 'active' || status === 'ready') return 'active'
return 'pending'
}
export function WorkItemProgressCard({
workItemLog,
roleWorkItems,
executorRoleWorkItems,
childSessions,
isCompanyRuntime = false,
onWorkItemClick,
}: WorkItemProgressCardProps) {
// Build child session lookup for enriching display names
const sessionByTaskId = useMemo(() => {
const map = new Map<string, Session>()
for (const s of childSessions ?? []) {
map.set(s.taskId, s)
const executionTurnId = getExecutionTurnId(s)
if (executionTurnId) map.set(executionTurnId, s)
}
return map
}, [childSessions])
// Build ordered work-item list from log entries — used
// only as a fallback when no child sessions exist yet, because the
// work-item log emits one projection id per runtime turn which means a single
// role can appear multiple times (execute + each review + attention).
// For kanban-push runs we'd rather derive the pipeline from
// roleSummaries below, which already groups by workItemRoleId.
const workItemLogWorkItems = useMemo(() => {
const map = new Map<string, WorkItemInfo>()
const order: string[] = []
for (const entry of workItemLog) {
const entryExecutionTurnId = getExecutionTurnId(entry)
const projectionId = entry.workItemProjectionId ?? entryExecutionTurnId ?? ''
if (!projectionId) continue
if (!map.has(projectionId)) {
order.push(projectionId)
// Determine display title — prefer roleName, avoid UUID fallback
const rawTitle = entry.workItemProjectionTitle ?? projectionId
const title = entry.roleName || (!isUuidLike(rawTitle) ? rawTitle : 'Agent')
map.set(projectionId, {
projectionId,
title,
roleName: entry.roleName,
status: 'pending',
executionTurnId: entryExecutionTurnId,
})
}
const info = map.get(projectionId)!
// Update with latest role/title info
if (entry.roleName) {
info.roleName = entry.roleName
info.title = entry.roleName
} else if (entry.workItemProjectionTitle && !isUuidLike(entry.workItemProjectionTitle)) {
info.title = entry.workItemProjectionTitle
}
if (entryExecutionTurnId) info.executionTurnId = entryExecutionTurnId
// Update status based on event type
switch (entry.type) {
case 'work_item_started': info.status = 'active'; break
case 'gate_approved': info.status = 'done'; break
case 'gate_rejected': info.status = 'active'; break
case 'awaiting_manager_review':
case 'awaiting_human':
case 'awaiting_review':
case 'awaiting_peer': info.status = 'waiting'; break
case 'work_item_failed':
case 'deadlock': info.status = 'failed'; break
}
}
// Enrich from child sessions
for (const info of map.values()) {
if (!info.executionTurnId) continue
const session = sessionByTaskId.get(info.executionTurnId)
if (!session) continue
const label = getWorkItemAssignmentLabel(session)
if (label) info.title = label
if (session.workItemRoleName) info.roleName = session.workItemRoleName
if (session.status === 'done') info.status = 'done'
else if (session.status === 'failed') info.status = 'failed'
}
return order.map(id => map.get(id)!)
}, [workItemLog, sessionByTaskId])
const taskOrder = useMemo(() => {
const map = new Map<string, number>()
for (const entry of workItemLog) {
const taskId = getExecutionTurnId(entry)
if (taskId && !map.has(taskId)) map.set(taskId, map.size)
}
return map
}, [workItemLog])
// Primary path: drive rows directly from the per-role DelegationWorkItem
// rollup the backend ships in ``session.role_work_items``. This is the
// fix for "1 row should = 1 work item" — the legacy session-driven
// derivation below treats every runtime Task as its own row, which
// double-counts rework turns and silently drops queued / review-target
// work items. See ``plan/bug-breezy-dragonfly.md`` for the full root-cause
// breakdown.
const displayRoleWorkItems = executorRoleWorkItems ?? roleWorkItems
const roleSummariesFromWorkItems = useMemo<RoleSummaryInfo[]>(() => {
if (!displayRoleWorkItems) return []
const summaries: RoleSummaryInfo[] = []
for (const summary of Object.values(displayRoleWorkItems)) {
if (!summary || !Array.isArray(summary.workItems) || summary.workItems.length === 0) continue
// Backend already sorts ASC by createdAt; defensive copy + re-sort
// here means a prop-mutation upstream can't reorder the rows.
const ordered = [...summary.workItems].sort((a, b) => a.createdAt - b.createdAt)
const turns: RoleTurnInfo[] = ordered.map((row: RoleWorkItemRow) => {
const columnId = normalizeColumnId(row.kanbanColumn)
const phaseToStatus = phaseAggregateForRow(row.phase)
return {
executionTurnId: row.executionTurnId ?? '',
title: trimString(row.title) || trimString(row.executorRoleName) || trimString(row.executorRoleId) || 'Work item',
status: phaseToStatus,
statusLabel: labelForColumnId(columnId),
columnId,
updatedAt: row.updatedAt,
workItemId: row.workItemId,
progressLog: row.progressLog,
activitySections: row.activitySections ?? [],
isReviewTarget: row.isReviewTarget,
}
})
const aggregatedStatus = AGGREGATED_TO_WORK_ITEM_STATUS[summary.aggregatedStatus] ?? 'pending'
const aggregatedColumnId = workItemStatusToColumnId(aggregatedStatus)
const latest = turns[turns.length - 1]
// Chip click target: prefer the latest turn's runtime task. When the
// last work item has no execution turn yet (queued / never dispatched),
// walk back to find the most recent dispatched turn.
const fallbackExecutionTurnId = [...turns].reverse().find(t => !!t.executionTurnId)?.executionTurnId ?? ''
summaries.push({
roleKey: summary.roleKey,
executionTurnId: latest.executionTurnId || fallbackExecutionTurnId,
title: summary.roleName || summary.roleId,
status: aggregatedStatus,
statusLabel: labelForColumnId(aggregatedColumnId),
roleName: summary.roleName,
updatedAt: turns.reduce((max, t) => Math.max(max, t.updatedAt), 0),
turns,
runtimeStatus: summary.runtimeStatus,
})
}
// Stable order: roles first appearing in time go left.
summaries.sort((a, b) => {
const aFirst = a.turns[0]?.updatedAt ?? Number.POSITIVE_INFINITY
const bFirst = b.turns[0]?.updatedAt ?? Number.POSITIVE_INFINITY
return aFirst - bFirst
})
return summaries
}, [displayRoleWorkItems])
const roleSummariesFromSessions = useMemo<RoleSummaryInfo[]>(() => {
const sessions = [...(childSessions ?? [])]
if (sessions.length === 0) return []
// Group by workItemRoleId (fall back to the assignee when missing
// so legacy non-company-mode sessions still get a stable key).
const groups = new Map<string, Session[]>()
const groupOrder: string[] = []
for (const session of sessions) {
const roleKey = firstNonEmpty(
session.workItemRoleId,
session.assigneeIds[0],
session.taskId, // last-resort singleton
)
if (!groups.has(roleKey)) {
groups.set(roleKey, [])
groupOrder.push(roleKey)
}
groups.get(roleKey)!.push(session)
}
// Sort role groups by the earliest work-item-log/updatedAt position
// of any turn in the group so the bar reads left-to-right in the
// order roles first appeared.
groupOrder.sort((leftKey, rightKey) => {
const leftSessions = groups.get(leftKey) ?? []
const rightSessions = groups.get(rightKey) ?? []
const leftOrder = Math.min(
...leftSessions.map(s => taskOrder.get(getExecutionTurnId(s) || s.taskId) ?? Number.POSITIVE_INFINITY),
)
const rightOrder = Math.min(
...rightSessions.map(s => taskOrder.get(getExecutionTurnId(s) || s.taskId) ?? Number.POSITIVE_INFINITY),
)
if (leftOrder !== rightOrder) return leftOrder - rightOrder
const leftUpdated = Math.min(...leftSessions.map(s => s.updatedAt))
const rightUpdated = Math.min(...rightSessions.map(s => s.updatedAt))
return leftUpdated - rightUpdated
})
const summaries: RoleSummaryInfo[] = []
for (const roleKey of groupOrder) {
const roleSessions = groups.get(roleKey) ?? []
if (roleSessions.length === 0) continue
// Build one turn entry per session, sorted chronologically (ASC).
// Per design: "a 最前面, c 最后面" — first-happened goes first.
// Each turn shows only its kanban column (To do / In progress /
// In review / Done). No synthetic "kind" classification.
const turns: RoleTurnInfo[] = roleSessions
.slice()
.sort((a, b) => a.updatedAt - b.updatedAt)
.map((session) => {
const status = statusFromSession(session)
const executionTurnId = getExecutionTurnId(session)
const columnId = workItemStatusToColumnId(
status,
String((session as Session & { columnId?: string }).columnId ?? '').trim() || undefined,
)
return {
executionTurnId: executionTurnId || session.taskId,
title: firstNonEmpty(
getWorkItemAssignmentLabel(session),
session.title,
session.workItemRoleName,
session.workItemRoleId,
'Turn',
),
status,
statusLabel: labelForColumnId(columnId),
columnId,
updatedAt: session.updatedAt,
}
})
// The newest turn is the one the user probably wants to open when
// they click the main chip — "what is this role doing right now"
// beats "what did this role do first".
const latest = turns[turns.length - 1]
const aggregatedStatus = aggregateStatus(turns.map(t => t.status))
const aggregatedColumnId = workItemStatusToColumnId(aggregatedStatus)
// Role name beats task title on the main chip so the chip always
// reads as "this role" regardless of which of its turns is loaded
// as latest.
const representative = roleSessions[0]
const title = firstNonEmpty(
representative.workItemRoleName,
representative.workItemRoleId,
representative.assigneeIds[0],
getWorkItemAssignmentLabel(representative),
representative.title,
'Agent',
)
summaries.push({
roleKey,
executionTurnId: latest.executionTurnId,
title,
status: aggregatedStatus,
statusLabel: labelForColumnId(aggregatedColumnId),
executionAgent: formatExecutionAgent(
representative.selectedExecutionAgent ?? representative.preferredAgent,
),
roleName: representative.workItemRoleName,
updatedAt: Math.max(...roleSessions.map(s => s.updatedAt)),
turns,
})
}
return summaries
}, [childSessions, taskOrder])
// Pick the work-item-driven summaries when present. In company/org
// mode, runtime sessions are only audit targets; they must not synthesize
// role rows because that recreates the role/session mix-up seen in new37.
const roleSummaries = useMemo<RoleSummaryInfo[]>(() => (
roleSummariesFromWorkItems.length > 0
? roleSummariesFromWorkItems
: (isCompanyRuntime ? [] : roleSummariesFromSessions)
), [isCompanyRuntime, roleSummariesFromWorkItems, roleSummariesFromSessions])
// Top-level pipeline: one chip per role, derived from roleSummaries
// when available (kanban-push runs). Fall back to work-item-log work items
// when child sessions haven't been serialized yet.
const workItems = useMemo<WorkItemInfo[]>(() => {
if (roleSummaries.length > 0) {
return roleSummaries.map(role => ({
projectionId: role.roleKey,
title: role.title,
roleName: role.roleName,
status: role.status,
executionTurnId: role.executionTurnId,
}))
}
return isCompanyRuntime ? [] : workItemLogWorkItems
}, [isCompanyRuntime, roleSummaries, workItemLogWorkItems])
if (isCompanyRuntime && roleSummaries.length === 0) return null
if (workItemLog.length === 0 && workItems.length === 0 && roleSummaries.length === 0) return null
return (
<div className="wi-progress-card">
<div className="wi-progress-header">
<IconWorkItem />
<span>Execution Progress</span>
</div>
{workItems.length > 0 && (
<div className="wi-progress-pipeline">
{workItems.map((workItem, i) => (
<div key={workItem.projectionId} className="wi-projection-group">
<button
type="button"
className={`wi-projection-chip wi-projection-${workItem.status}`}
onClick={() => onWorkItemClick?.(workItem.executionTurnId || '')}
title={`Open Runtime Session${workItem.roleName ? `: ${workItem.title} (${workItem.roleName})` : `: ${workItem.title}`}`}
>
<span className="wi-projection-label">{workItem.title}</span>
{renderProjectionIcon(workItem.status)}
</button>
{i < workItems.length - 1 && <span className="wi-projection-connector">&rarr;</span>}
</div>
))}
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict'
import type { ChatMessage } from '../types/chat'
import {
analyzeCheckpointMessages,
checkpointReplyMetadataForComposer,
isCheckpointCardMetadata,
isCheckpointType,
toCheckpointReplyMetadata,
} from './checkpointUtils'
const checkpoint: ChatMessage = {
id: 'msg-checkpoint',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'OPC',
content: 'Please review the delivery.',
timestamp: 1,
mentions: [],
metadata: {
checkpoint_type: 'company_delivery_feedback',
checkpoint_id: 'cp-delivery',
work_item_projection_title: 'CEO Delivery',
feedback_scope: 'final',
},
}
assert.equal(isCheckpointType('company_delivery_feedback'), true)
assert.equal(isCheckpointType('company_staffing_selection'), true)
assert.equal(isCheckpointCardMetadata(checkpoint.metadata), true)
assert.deepEqual(toCheckpointReplyMetadata(checkpoint.metadata), {
response_to_checkpoint_id: 'cp-delivery',
response_to_checkpoint_type: 'company_delivery_feedback',
response_to_escalation_id: undefined,
})
const pending = analyzeCheckpointMessages([checkpoint])
assert.deepEqual([...pending.pendingMessageIds], ['msg-checkpoint'])
assert.deepEqual(pending.latestPendingReplyMetadata, {
response_to_checkpoint_id: 'cp-delivery',
response_to_checkpoint_type: 'company_delivery_feedback',
response_to_escalation_id: undefined,
})
assert.equal(checkpointReplyMetadataForComposer(pending.latestPendingReplyMetadata), undefined)
const legacySelfEvolutionResult: ChatMessage = {
...checkpoint,
id: 'msg-self-evolution-result',
content: 'Self-evolution finished without writing updates because the agents did not return valid evolution patches.',
metadata: {
checkpoint_type: 'company_delivery_feedback',
checkpoint_id: 'cp-delivery',
kind: 'company_self_evolution_result',
self_evolution_completed: true,
},
}
assert.equal(isCheckpointCardMetadata(legacySelfEvolutionResult.metadata), false)
const ignoredSelfEvolutionResult = analyzeCheckpointMessages([legacySelfEvolutionResult])
assert.deepEqual([...ignoredSelfEvolutionResult.pendingMessageIds], [])
assert.deepEqual([...ignoredSelfEvolutionResult.respondedMessageIds], [])
assert.equal(checkpointReplyMetadataForComposer({
response_to_checkpoint_id: 'esc-approval',
response_to_checkpoint_type: 'human_escalation',
response_to_escalation_id: 'esc-approval',
}), undefined)
assert.deepEqual(checkpointReplyMetadataForComposer({
response_to_checkpoint_id: 'cp-staffing',
response_to_checkpoint_type: 'company_staffing_selection',
}), {
response_to_checkpoint_id: 'cp-staffing',
response_to_checkpoint_type: 'company_staffing_selection',
})
const duplicatePending = analyzeCheckpointMessages([
checkpoint,
{
...checkpoint,
id: 'msg-checkpoint-duplicate',
channelId: 'session:task-2',
timestamp: 2,
},
])
assert.deepEqual([...duplicatePending.pendingMessageIds], ['msg-checkpoint'])
assert.deepEqual([...duplicatePending.respondedMessageIds], [])
assert.deepEqual([...duplicatePending.duplicateMessageIds], ['msg-checkpoint-duplicate'])
const duplicateResponded = analyzeCheckpointMessages([
{
...checkpoint,
metadata: {
...checkpoint.metadata,
checkpoint_status: 'responded',
},
},
{
...checkpoint,
id: 'msg-checkpoint-duplicate',
channelId: 'session:task-2',
timestamp: 2,
metadata: {
...checkpoint.metadata,
checkpoint_status: 'responded',
},
},
])
assert.deepEqual([...duplicateResponded.respondedMessageIds], ['msg-checkpoint'])
assert.deepEqual([...duplicateResponded.duplicateMessageIds], ['msg-checkpoint-duplicate'])
const replyBeforeEngineResolution = analyzeCheckpointMessages([
checkpoint,
{
id: 'msg-user',
channelId: 'session:task-1',
sender: 'user',
senderName: 'You',
content: 'Please make one more change.',
timestamp: 2,
mentions: [],
metadata: {
response_to_checkpoint_id: 'cp-delivery',
response_to_checkpoint_type: 'company_delivery_feedback',
},
},
])
assert.deepEqual([...replyBeforeEngineResolution.respondedMessageIds], ['msg-checkpoint'])
assert.deepEqual([...replyBeforeEngineResolution.pendingMessageIds], [])
assert.equal(replyBeforeEngineResolution.latestPendingReplyMetadata, undefined)
const responded = analyzeCheckpointMessages([
{
...checkpoint,
metadata: {
...checkpoint.metadata,
checkpoint_status: 'responded',
checkpoint_response_message_id: 'msg-user',
},
},
])
assert.deepEqual([...responded.respondedMessageIds], ['msg-checkpoint'])
const expiredApproval: ChatMessage = {
id: 'msg-expired-approval',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'OPC',
content: 'Approve external_agent?',
timestamp: 3,
mentions: [],
metadata: {
checkpoint_type: 'human_escalation',
checkpoint_id: 'esc-expired',
escalation_id: 'esc-expired',
escalation_type: 'decision_needed',
prompt: 'Approve external_agent?',
options: [{ id: 'approve_once', label: 'Approve once' }],
checkpoint_status: 'timeout',
},
}
const staleApproval: ChatMessage = {
...expiredApproval,
id: 'msg-stale-approval',
metadata: {
...expiredApproval.metadata,
checkpoint_id: 'esc-stale',
escalation_id: 'esc-stale',
checkpoint_status: 'stale',
},
}
const supersededRecruitment: ChatMessage = {
...checkpoint,
id: 'msg-superseded-recruitment',
metadata: {
...checkpoint.metadata,
checkpoint_type: 'company_recruitment_confirmation',
checkpoint_id: 'cp-recruit-old',
checkpoint_status: 'superseded',
},
}
const ignoredDelivery: ChatMessage = {
...checkpoint,
id: 'msg-ignored-delivery',
metadata: {
...checkpoint.metadata,
checkpoint_status: 'ignored',
},
}
const terminal = analyzeCheckpointMessages([expiredApproval, staleApproval, supersededRecruitment, ignoredDelivery])
assert.deepEqual([...terminal.pendingMessageIds], [])
assert.deepEqual([...terminal.respondedMessageIds], ['msg-expired-approval', 'msg-stale-approval', 'msg-superseded-recruitment', 'msg-ignored-delivery'])
console.log('checkpointUtils.test.ts: OK (checkpoint pending/reply/terminal status handling)')
@@ -0,0 +1,161 @@
import type { ChatMessage, ChatMessageMeta, CheckpointReplyMetadata } from '../types/chat'
const CHECKPOINT_TYPES = new Set([
'company_work_item_gate',
'company_delivery_feedback',
'company_staffing_selection',
'company_recruitment_confirmation',
'company_reorg_pending',
'human_escalation',
'task_user_input',
])
const TERMINAL_CHECKPOINT_STATUSES = new Set([
'responded',
'resolved',
'timeout',
'timed_out',
'expired',
'stale',
'superseded',
'ignored',
'cancelled',
'canceled',
'invalid',
])
export function isCheckpointType(value: string | undefined): boolean {
return CHECKPOINT_TYPES.has(String(value ?? '').trim())
}
export function isCheckpointCardMetadata(meta: ChatMessageMeta | undefined): boolean {
if (!isCheckpointType(meta?.checkpoint_type)) {
return false
}
if (meta?.self_evolution_completed) {
return false
}
if (String(meta?.kind ?? '').trim() === 'company_self_evolution_result') {
return false
}
return true
}
export function isCheckpointResolved(meta: ChatMessageMeta | undefined): boolean {
const status = String(meta?.checkpoint_status ?? '').trim().toLowerCase()
if (TERMINAL_CHECKPOINT_STATUSES.has(status)) {
return true
}
return !!String(meta?.checkpoint_response_message_id ?? '').trim()
}
export function toCheckpointReplyMetadata(meta: ChatMessageMeta | undefined): CheckpointReplyMetadata | undefined {
const checkpointId = String(meta?.checkpoint_id ?? '').trim()
if (!checkpointId) {
return undefined
}
const checkpointType = String(meta?.checkpoint_type ?? '').trim()
const escalationId = String(meta?.escalation_id ?? '').trim()
return {
response_to_checkpoint_id: checkpointId,
response_to_checkpoint_type: checkpointType || undefined,
response_to_escalation_id: escalationId || undefined,
}
}
export function checkpointReplyMetadataForComposer(
meta: CheckpointReplyMetadata | undefined,
): CheckpointReplyMetadata | undefined {
const checkpointType = String(meta?.response_to_checkpoint_type ?? '').trim()
if (checkpointType === 'company_delivery_feedback' || checkpointType === 'human_escalation') {
return undefined
}
return meta
}
export function isResponseForCheckpoint(message: ChatMessage, checkpointMeta: ChatMessageMeta | undefined): boolean {
if (message.sender !== 'user') {
return false
}
const checkpointId = String(checkpointMeta?.checkpoint_id ?? '').trim()
if (!checkpointId) {
return false
}
const replyMeta = message.metadata
if (String(replyMeta?.response_to_checkpoint_id ?? '').trim() === checkpointId) {
return true
}
const checkpointType = String(checkpointMeta?.checkpoint_type ?? '').trim()
const escalationId = String(checkpointMeta?.escalation_id ?? '').trim()
return checkpointType === 'human_escalation'
&& !!escalationId
&& String(replyMeta?.response_to_escalation_id ?? '').trim() === escalationId
}
export function analyzeCheckpointMessages(messages: ChatMessage[]): {
pendingMessageIds: Set<string>
respondedMessageIds: Set<string>
duplicateMessageIds: Set<string>
latestPendingReplyMetadata?: CheckpointReplyMetadata
} {
const pendingMessageIds = new Set<string>()
const respondedMessageIds = new Set<string>()
const duplicateMessageIds = new Set<string>()
let latestPendingReplyMetadata: CheckpointReplyMetadata | undefined
const latestCheckpointReplyIndex = new Map<string, number>()
const latestEscalationReplyIndex = new Map<string, number>()
const seenCheckpointIds = new Set<string>()
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.sender !== 'user') continue
const replyMeta = message.metadata
const checkpointId = String(replyMeta?.response_to_checkpoint_id ?? '').trim()
if (checkpointId) {
latestCheckpointReplyIndex.set(checkpointId, i)
}
const escalationId = String(replyMeta?.response_to_escalation_id ?? '').trim()
if (escalationId) {
latestEscalationReplyIndex.set(escalationId, i)
}
}
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const checkpointMeta = message.metadata
if (!isCheckpointCardMetadata(checkpointMeta)) {
continue
}
const checkpointId = String(checkpointMeta?.checkpoint_id ?? '').trim()
const checkpointType = String(checkpointMeta?.checkpoint_type ?? '').trim()
const escalationId = String(checkpointMeta?.escalation_id ?? '').trim()
if (checkpointId && seenCheckpointIds.has(checkpointId)) {
duplicateMessageIds.add(message.id)
continue
}
if (checkpointId) {
seenCheckpointIds.add(checkpointId)
}
const hasLaterCheckpointReply = !!checkpointId && (latestCheckpointReplyIndex.get(checkpointId) ?? -1) > i
const hasLaterEscalationReply = checkpointType === 'human_escalation'
&& !!escalationId
&& (latestEscalationReplyIndex.get(escalationId) ?? -1) > i
if (isCheckpointResolved(checkpointMeta) || hasLaterCheckpointReply || hasLaterEscalationReply) {
respondedMessageIds.add(message.id)
continue
}
pendingMessageIds.add(message.id)
latestPendingReplyMetadata = toCheckpointReplyMetadata(checkpointMeta) ?? latestPendingReplyMetadata
}
return {
pendingMessageIds,
respondedMessageIds,
duplicateMessageIds,
latestPendingReplyMetadata,
}
}
@@ -0,0 +1,276 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import type { GameBridge } from '../game/GameBridge'
import {
DEFAULT_MAP_STR,
DEFAULT_SEATS,
parseMapStr,
gridToMapStr,
} from '../game/map/OfficeMapBuilder'
import { getOffices, type OfficeConfig } from '../game/map/OfficeStore'
import { OFFICE_COLS, OFFICE_ROWS, TILE_SIZE } from '../game/config'
interface Props {
bridge: GameBridge
}
type EditorMode = 'wall' | 'floor' | 'seat'
const BG_ASSET_URL = 'assets/office-bg.png'
export function CollisionEditor({ bridge }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const bgImgRef = useRef<HTMLImageElement | null>(null)
const isPainting = useRef(false)
const [offices, setOffices] = useState<OfficeConfig[]>(() => getOffices())
const [selectedOfficeId, setSelectedOfficeId] = useState<string>(() => getOffices()[0]?.id ?? 'office-0')
const selectedOffice = offices.find(o => o.id === selectedOfficeId) ?? offices[0]
const [grid, setGrid] = useState<number[][]>(() => parseMapStr(selectedOffice?.mapStr ?? DEFAULT_MAP_STR, OFFICE_COLS, OFFICE_ROWS))
const [seats, setSeats] = useState<[number, number][]>(() => [...(selectedOffice?.seats ?? DEFAULT_SEATS)])
const [mode, setMode] = useState<EditorMode>('wall')
const [bgLoaded, setBgLoaded] = useState(false)
const [showExport, setShowExport] = useState(false)
const [applied, setApplied] = useState(false)
const [zoom, setZoom] = useState(1)
const [showGrid, setShowGrid] = useState(true)
const cols = OFFICE_COLS
const rows = OFFICE_ROWS
const switchOffice = (officeId: string) => {
const refreshed = getOffices()
setOffices(refreshed)
setSelectedOfficeId(officeId)
const office = refreshed.find(o => o.id === officeId)
if (office) {
setGrid(parseMapStr(office.mapStr, OFFICE_COLS, OFFICE_ROWS))
setSeats([...office.seats])
}
setApplied(false)
}
useEffect(() => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => { bgImgRef.current = img; setBgLoaded(true) }
img.onerror = () => { bgImgRef.current = null; setBgLoaded(true) }
img.src = BG_ASSET_URL
}, [])
const render = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = cols * TILE_SIZE
const h = rows * TILE_SIZE
canvas.width = w
canvas.height = h
ctx.clearRect(0, 0, w, h)
if (bgImgRef.current) {
ctx.drawImage(bgImgRef.current, 0, 0, w, h)
} else {
ctx.fillStyle = '#3d3d3d'
ctx.fillRect(0, 0, w, h)
}
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x0 = c * TILE_SIZE
const y0 = r * TILE_SIZE
const blocked = grid[r]?.[c] === 1
ctx.fillStyle = blocked ? 'rgba(255, 40, 40, 0.35)' : 'rgba(40, 255, 40, 0.2)'
ctx.fillRect(x0, y0, TILE_SIZE, TILE_SIZE)
}
}
for (const [c, r] of seats) {
const cx = c * TILE_SIZE + TILE_SIZE / 2
const cy = r * TILE_SIZE + TILE_SIZE / 2
ctx.beginPath()
ctx.arc(cx, cy, 9, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 220, 0, 0.85)'
ctx.fill()
ctx.strokeStyle = '#000'
ctx.lineWidth = 1.5
ctx.stroke()
}
if (showGrid) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'
ctx.lineWidth = 0.5
for (let c = 0; c <= cols; c++) {
ctx.beginPath(); ctx.moveTo(c * TILE_SIZE, 0); ctx.lineTo(c * TILE_SIZE, h); ctx.stroke()
}
for (let r = 0; r <= rows; r++) {
ctx.beginPath(); ctx.moveTo(0, r * TILE_SIZE); ctx.lineTo(w, r * TILE_SIZE); ctx.stroke()
}
ctx.font = '10px monospace'
ctx.textBaseline = 'top'
for (let c = 0; c < cols; c++) { ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillText(String(c), c * TILE_SIZE + 2, 2) }
for (let r = 0; r < rows; r++) { ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillText(String(r), 2, r * TILE_SIZE + 2) }
}
}, [grid, seats, cols, rows, showGrid, bgLoaded])
useEffect(() => { render() }, [render])
const getCellFromEvent = (e: React.MouseEvent<HTMLCanvasElement>): { c: number; r: number } | null => {
const canvas = canvasRef.current
if (!canvas) return null
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
const c = Math.floor((e.clientX - rect.left) * scaleX / TILE_SIZE)
const r = Math.floor((e.clientY - rect.top) * scaleY / TILE_SIZE)
if (c < 0 || c >= cols || r < 0 || r >= rows) return null
return { c, r }
}
const paint = useCallback((c: number, r: number) => {
setApplied(false)
if (mode === 'seat') {
setSeats(prev => {
const idx = prev.findIndex(([sc, sr]) => sc === c && sr === r)
if (idx >= 0) return prev.filter((_, i) => i !== idx)
return [...prev, [c, r]]
})
setGrid(prev => {
if (prev[r]?.[c] === 1) { const next = prev.map(row => [...row]); next[r][c] = 0; return next }
return prev
})
} else {
const value = mode === 'wall' ? 1 : 0
setGrid(prev => {
if (prev[r]?.[c] === value) return prev
const next = prev.map(row => [...row]); next[r][c] = value; return next
})
if (mode === 'wall') setSeats(prev => prev.filter(([sc, sr]) => !(sc === c && sr === r)))
}
}, [mode])
const onMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (e.button !== 0) return
isPainting.current = true
const cell = getCellFromEvent(e)
if (cell) paint(cell.c, cell.r)
}
const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isPainting.current) return
if (mode === 'seat') return
const cell = getCellFromEvent(e)
if (cell) paint(cell.c, cell.r)
}
const onMouseUp = () => { isPainting.current = false }
const handleApply = () => {
const mapStr = gridToMapStr(grid)
bridge.rebuildOfficeCollision(selectedOfficeId, mapStr, seats)
setApplied(true)
setOffices(getOffices())
setTimeout(() => setApplied(false), 2000)
}
const handleExport = () => setShowExport(v => !v)
const handleReset = () => {
setGrid(parseMapStr(DEFAULT_MAP_STR, OFFICE_COLS, OFFICE_ROWS))
setSeats([...DEFAULT_SEATS])
setApplied(false)
}
const exportText = (() => {
const mapStr = gridToMapStr(grid)
const mapLines = mapStr.map((line, i) => ` '${line}', // ${i}`).join('\n')
const seatLines = seats.map(([c, r]) => ` [${c}, ${r}],`).join('\n')
return `const MAP_STR: string[] = [\n${mapLines}\n]\n\nconst SEATS: [number, number][] = [\n${seatLines}\n]`
})()
const zoomIn = () => setZoom(z => Math.min(z + 0.25, 3))
const zoomOut = () => setZoom(z => Math.max(z - 0.25, 0.5))
const wallCount = grid.flat().filter(v => v === 1).length
const floorCount = grid.flat().filter(v => v === 0).length
return (
<div className="collision-editor">
<div className="ce-toolbar">
<div className="ce-toolbar-group">
<span className="ce-title">Map Editor</span>
<select
className="ce-office-select"
value={selectedOfficeId}
onChange={e => switchOffice(e.target.value)}
>
{offices.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
<div className="ce-toolbar-group">
<button className={`ce-mode-btn ${mode === 'wall' ? 'active wall' : ''}`} onClick={() => setMode('wall')}>
<span className="ce-mode-dot wall" /> Wall
</button>
<button className={`ce-mode-btn ${mode === 'floor' ? 'active floor' : ''}`} onClick={() => setMode('floor')}>
<span className="ce-mode-dot floor" /> Floor
</button>
<button className={`ce-mode-btn ${mode === 'seat' ? 'active seat' : ''}`} onClick={() => setMode('seat')}>
<span className="ce-mode-dot seat" /> Seat
</button>
</div>
<div className="ce-toolbar-group">
<button className="ce-btn" onClick={zoomOut}>-</button>
<span className="ce-zoom-label">{Math.round(zoom * 100)}%</span>
<button className="ce-btn" onClick={zoomIn}>+</button>
<button className={`ce-btn${showGrid ? ' active' : ''}`} onClick={() => setShowGrid(v => !v)}>Grid</button>
</div>
<div className="ce-toolbar-group">
<button className={`ce-btn apply${applied ? ' success' : ''}`} onClick={handleApply}>
{applied ? 'Applied' : 'Apply'}
</button>
<button className={`ce-btn${showExport ? ' active' : ''}`} onClick={handleExport}>Export</button>
<button className="ce-btn danger" onClick={handleReset}>Reset</button>
</div>
<div className="ce-toolbar-group ce-stats">
<span>Walls: {wallCount}</span>
<span>Floor: {floorCount}</span>
<span>Seats: {seats.length}</span>
</div>
</div>
<div className="ce-body">
<div className="ce-canvas-wrap" style={{ overflow: 'auto' }}>
<canvas
ref={canvasRef}
style={{
width: cols * TILE_SIZE * zoom,
height: rows * TILE_SIZE * zoom,
imageRendering: 'pixelated',
cursor: mode === 'seat' ? 'crosshair' : 'cell',
}}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
/>
</div>
{showExport && (
<div className="ce-export-panel">
<div className="ce-export-header">
<span>Export copy to source code</span>
<button className="ce-btn" onClick={() => navigator.clipboard.writeText(exportText)}>Copy</button>
</div>
<textarea className="ce-export-textarea" value={exportText} readOnly rows={20} />
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,117 @@
import { useState, useCallback } from 'react'
import type { Project } from '../types/kanban'
interface ProjectSelectorProps {
projects: Project[]
activeId: string
onSelect: (id: string) => void
onCreate: (id: string) => void
onDelete?: (id: string) => void
}
export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDelete }: ProjectSelectorProps) {
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const handleCreate = useCallback(() => {
const id = newName.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '')
if (!id) return
onCreate(id)
setNewName('')
setCreating(false)
}, [newName, onCreate])
const handleDelete = useCallback(() => {
if (!confirmDelete || !onDelete) return
onDelete(confirmDelete)
setConfirmDelete(null)
}, [confirmDelete, onDelete])
return (
<div className="project-selector" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<select
className="theme-select"
value={activeId}
onChange={e => onSelect(e.target.value)}
title="Switch project"
>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{creating ? (
<form
onSubmit={e => { e.preventDefault(); handleCreate() }}
style={{ display: 'flex', gap: 4 }}
>
<input
autoFocus
className="theme-select"
value={newName}
placeholder="project-name"
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') setCreating(false) }}
style={{ width: 120 }}
/>
<button type="submit" className="pill-btn" style={{ fontSize: 11, padding: '2px 8px' }}>+</button>
</form>
) : (
<button
className="pill-btn"
onClick={() => setCreating(true)}
title="New project"
style={{ fontSize: 11, padding: '2px 8px' }}
>
+
</button>
)}
{onDelete && activeId !== 'default' && !confirmDelete && (
<button
className="pill-btn"
onClick={() => setConfirmDelete(activeId)}
title="Delete project"
style={{ fontSize: 11, padding: '2px 8px', color: '#ef4444' }}
>
Del
</button>
)}
{confirmDelete && (
<div className="project-delete-confirm" style={{
position: 'fixed', inset: 0, zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.5)',
}}>
<div style={{
background: 'var(--bg-surface, #1e1e2e)', borderRadius: 12, padding: '24px 32px',
maxWidth: 400, boxShadow: '0 8px 32px rgba(0,0,0,0.4)', textAlign: 'center',
}}>
<p style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 15 }}>
Delete project "{confirmDelete}"?
</p>
<p style={{ margin: '0 0 20px', fontSize: 13, opacity: 0.7 }}>
All sessions, messages, tasks, and agent data in this project will be permanently deleted. This action cannot be undone.
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button
className="pill-btn"
onClick={() => setConfirmDelete(null)}
style={{ padding: '6px 18px', fontSize: 13 }}
>
Cancel
</button>
<button
className="pill-btn"
onClick={handleDelete}
style={{ padding: '6px 18px', fontSize: 13, background: '#ef4444', color: '#fff' }}
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,234 @@
import Phaser from 'phaser'
import type { OfficeScene } from './scenes/OfficeScene'
import type { VisualEvent, VisualSnapshot } from '../types/visual'
import { getOffices, type OfficeConfig } from './map/OfficeStore'
import { AgentState } from './types'
export class GameBridge extends Phaser.Events.EventEmitter {
private scene: OfficeScene | null = null
private eventQueue: VisualEvent[] = []
private snapshotQueue: VisualSnapshot[] = []
constructor() {
super()
}
setScene(scene: OfficeScene) {
this.scene = scene
for (const snap of this.snapshotQueue) {
this.applySnapshot(snap)
}
this.snapshotQueue = []
for (const evt of this.eventQueue) {
this.applyEvent(evt)
}
this.eventQueue = []
}
getScene(): OfficeScene | null {
return this.scene
}
// ── Called from React side ────────────────────────────
pushEvent(evt: VisualEvent) {
if (!this.scene) {
this.eventQueue.push(evt)
return
}
this.applyEvent(evt)
}
pushSnapshot(snapshot: VisualSnapshot) {
const agentCount = Object.keys(snapshot.agents ?? {}).length
if (!this.scene) {
console.log(`[GameBridge] pushSnapshot queued (scene not ready) — ${agentCount} agents`)
this.snapshotQueue.push(snapshot)
return
}
console.log(`[GameBridge] pushSnapshot applying now — ${agentCount} agents`)
this.applySnapshot(snapshot)
}
sendToSeat(agentId: string) {
if (!this.scene) return
this.scene.behavior.sendToSeat(
this.scene.ensureAgent(agentId),
)
}
setAgentActive(agentId: string, active: boolean) {
if (!this.scene) return
const agent = this.scene.getAgent(agentId)
if (agent) agent.isActive = active
}
setAgentBubble(agentId: string, text: string | null) {
if (!this.scene) return
const agent = this.scene.getAgent(agentId)
if (!agent) return
if (text) agent.showBubble(text)
else agent.clearBubble()
}
ensureAgent(agentId: string, displayName?: string, officeId?: string, palette?: number, deskId?: string) {
if (!this.scene) return
this.scene.ensureAgent(agentId, displayName, false, null, officeId, palette, deskId)
}
getCharacterCards() {
if (!this.scene) return []
return this.scene.getCharacterCards()
}
// ── Office management API ─────────────────────────────
getOffices(): OfficeConfig[] {
return getOffices()
}
renameOffice(officeId: string, newName: string) {
if (!this.scene) return
this.scene.renameOffice(officeId, newName)
this.emit('officeChanged')
}
assignAgentToOffice(agentId: string, officeId: string) {
if (!this.scene) return
this.scene.reassignAgent(agentId, officeId)
this.emit('officeChanged')
}
panToOffice(officeId: string) {
if (!this.scene) return
this.scene.panToOffice(officeId)
}
resetCamera() {
if (!this.scene) return
this.scene.resetCameraView()
}
/** Re-read `isLocalDaytime()` (URL + localStorage) and refresh skyline / grass if it changed. */
syncOutdoorLighting() {
if (!this.scene) return
this.scene.syncOutdoorLighting()
}
rebuildOfficeCollision(officeId: string, mapStr: string[], seats: [number, number][]) {
if (!this.scene) return
this.scene.rebuildOfficeCollision(officeId, mapStr, seats)
}
getSeatsForOffice(officeId: string): Array<{ id: string; assigned: boolean; assignedTo: string | null }> {
if (!this.scene) return []
return this.scene.seats
.filter(s => s.id.startsWith(`${officeId}-desk-`) || s.id.startsWith(`${officeId}-leader-`))
.map(s => ({ id: s.id, assigned: s.assigned, assignedTo: s.assignedTo }))
}
changeAgentSeat(agentId: string, seatId: string) {
if (!this.scene) return
this.scene.changeAgentSeat(agentId, seatId)
this.emit('officeChanged')
}
// ── Chat + Kanban game integration ──────────────────────
notifyChannelMessage(agentIds: string[], text: string) {
if (!this.scene) return
for (const id of agentIds) {
const agent = this.scene.getAgent(id)
if (agent) {
agent.showBubble(text.slice(0, 30))
setTimeout(() => agent.clearBubble(), 4000)
}
}
}
triggerCrossOfficeMeeting(agentIds: string[]) {
if (!this.scene) return
for (const id of agentIds) {
const agent = this.scene.getAgent(id)
if (agent) {
this.scene.behavior.moveToZone(agent, 'meetingRoom')
}
}
}
triggerCelebration(agentId: string) {
if (!this.scene) return
const agent = this.scene.getAgent(agentId)
if (agent) {
agent.showBubble('🎉 Done!')
setTimeout(() => agent.clearBubble(), 3000)
}
}
// ── Internal ──────────────────────────────────────────
private applyEvent(evt: VisualEvent) {
if (!this.scene) return
this.scene.behavior.applyEvent(evt)
this.emit('eventApplied', evt)
}
private applySnapshot(snapshot: VisualSnapshot) {
if (!this.scene) {
console.warn('[GameBridge] applySnapshot called but scene is null')
return
}
// Snapshot old agents to array first (avoid mutating Map during iteration)
const oldIds = Array.from(this.scene.agents.keys())
console.log('[GameBridge] applySnapshot — clearing', oldIds.length, 'old agents:', oldIds)
for (const id of oldIds) {
this.scene.removeAgent(id)
}
const timeline = snapshot.timeline ?? []
for (const evt of timeline) {
this.scene.behavior.applyEvent(evt)
}
const agentEntries = Object.entries(snapshot.agents ?? {})
console.log('[GameBridge] applySnapshot — adding', agentEntries.length, 'agents:', agentEntries.map(([id]) => id))
for (const [id, info] of agentEntries) {
const agentData = info as {
name?: string; role_name?: string; office_id?: string
status?: string; runtime_status?: string; current_tool?: string | null
appearance?: { palette?: number; hue_shift?: number; seat_zone?: string; desk_id?: string }
}
const name = agentData.name || agentData.role_name || id
const officeId = agentData.office_id
const palette = agentData.appearance?.palette
const deskId = agentData.appearance?.desk_id
try {
const agent = this.scene.ensureAgent(id, name, false, null, officeId, palette, deskId)
const runtimeStatus = agentData.runtime_status || agentData.status
if (runtimeStatus === 'tool_active') {
agent.currentTool = agentData.current_tool ?? null
agent.isActive = true
agent.setAgentState(AgentState.TYPE)
} else if (runtimeStatus === 'reflecting') {
agent.currentTool = agentData.current_tool ?? 'Reflect'
agent.isActive = false
agent.setAgentState(AgentState.REFLECT)
}
console.log(`[GameBridge] ✓ ensured ${id} in ${officeId} palette=${palette}`)
} catch (err) {
console.error(`[GameBridge] ✗ ensureAgent failed for ${id}:`, err)
}
}
if (timeline.length === 0 && agentEntries.length === 0) {
console.warn('[GameBridge] applySnapshot — empty snapshot, creating fallback agent')
this.scene.ensureAgent('openopc-main', 'OpenOPC')
}
console.log('[GameBridge] applySnapshot done — total agents:', this.scene.agents.size)
this.emit('snapshotApplied', snapshot)
}
}
@@ -0,0 +1,70 @@
import { useEffect, useRef } from 'react'
import Phaser from 'phaser'
import { createGameConfig } from './config'
import type { GameBridge } from './GameBridge'
import { BootScene } from './scenes/BootScene'
import { OfficeScene } from './scenes/OfficeScene'
interface Props {
bridge: GameBridge
}
export function PhaserGame({ bridge }: Props) {
const wrapperRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const gameRef = useRef<Phaser.Game | null>(null)
useEffect(() => {
if (!wrapperRef.current || !containerRef.current || gameRef.current) return
// Measure the wrapper (which has definite CSS dimensions from the grid layout).
// The inner container div is initially empty so has 0 dimensions.
const wrapper = wrapperRef.current
const container = containerRef.current
// Force container to fill wrapper so clientWidth/Height are non-zero
container.style.width = `${wrapper.clientWidth}px`
container.style.height = `${wrapper.clientHeight}px`
// Safety: never create a 0×0 game
const w = container.clientWidth || window.innerWidth - 400
const h = container.clientHeight || window.innerHeight - 48
if (w < 50 || h < 50) {
console.warn('[PhaserGame] Container too small:', w, h, '— using fallback size')
container.style.width = `${window.innerWidth - 400}px`
container.style.height = `${window.innerHeight - 48}px`
}
console.log('[PhaserGame] Creating Phaser game', container.clientWidth, '×', container.clientHeight)
const config = createGameConfig(container)
config.scene = [BootScene, OfficeScene]
const game = new Phaser.Game(config)
game.registry.set('bridge', bridge)
gameRef.current = game
// Keep canvas sized to wrapper on window resize
const onResize = () => {
if (!wrapper || !game) return
container.style.width = `${wrapper.clientWidth}px`
container.style.height = `${wrapper.clientHeight}px`
game.scale.resize(wrapper.clientWidth, wrapper.clientHeight)
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
game.destroy(true)
gameRef.current = null
}
}, [bridge]) // bridge is a stable ref, effect runs once
return (
// Wrapper fills the CSS grid cell
<div ref={wrapperRef} style={{ width: '100%', height: '100%' }}>
{/* Phaser mounts its canvas inside this div */}
<div ref={containerRef} />
</div>
)
}
@@ -0,0 +1,130 @@
import Phaser from 'phaser'
export const TILE_SIZE = 32
export const OFFICE_COLS = 20
export const OFFICE_ROWS = 25
export const GAP_COLS = 2
export const OFFICE_COUNT = 3
export const WORLD_COLS = OFFICE_COLS * OFFICE_COUNT + GAP_COLS * (OFFICE_COUNT - 1) // 64
export const WORLD_ROWS = OFFICE_ROWS // 25
export const MAP_COLS = WORLD_COLS
export const MAP_ROWS = WORLD_ROWS
export const OUTDOOR_MARGIN_X = TILE_SIZE * 8
export const OUTDOOR_MARGIN_TOP = TILE_SIZE * 4
export const OUTDOOR_MARGIN_BOTTOM = TILE_SIZE * 20
export const CHAR_SCALE = 1.8
export const CHAR_SCALE_X = CHAR_SCALE
export const CHAR_SCALE_Y = CHAR_SCALE
export const CHAR_SHADOW_WIDTH = 30
export const CHAR_SHADOW_HEIGHT = 8
export const CHAR_SHADOW_Y = -2
export const CHAR_SHADOW_ALPHA = 0.22
/** Daytime if local hour is in [DAYTIME_START_HOUR, DAYTIME_END_HOUR] inclusive. */
export const DAYTIME_START_HOUR = 5
/** 23 → day through 23:59; 0:004:59 is night when mode is Auto (no URL/storage override). */
export const DAYTIME_END_HOUR = 23
/** Parse `?day=1` / `#?day=1` / `#/path?day=1` for outdoor preview. */
function readOutdoorOverrideFromUrl(): 'day' | 'night' | null {
if (typeof window === 'undefined') return null
const parse = (raw: string): 'day' | 'night' | null => {
const q = new URLSearchParams(raw)
if (q.get('day') === '1' || q.get('daytime') === '1') return 'day'
if (q.get('night') === '1') return 'night'
return null
}
let o = parse(window.location.search || '')
if (o) return o
const hash = window.location.hash
if (!hash) return null
const qm = hash.indexOf('?')
if (qm >= 0) {
o = parse(hash.slice(qm + 1))
if (o) return o
}
const h = hash.replace(/^#/, '')
if (h.includes('=')) {
o = parse(h)
if (o) return o
}
return null
}
/**
* Day vs night for the outdoor skyline. Clock: local 5:0023:59 = day, 0:004:59 = night (Auto mode).
* Override (browser): URL `?day=1` / `?daytime=1` (also after `#…?`), or `localStorage opc_outdoor_override` = `day`|`night`.
* Legacy: `opc_outdoor_day` / `opc_outdoor_night` = `1`.
*/
export function isLocalDaytime(now = new Date()): boolean {
if (typeof window !== 'undefined') {
try {
const url = readOutdoorOverrideFromUrl()
if (url === 'day') return true
if (url === 'night') return false
const om = window.localStorage?.getItem('opc_outdoor_override')
if (om === 'day') return true
if (om === 'night') return false
if (window.localStorage?.getItem('opc_outdoor_day') === '1') return true
if (window.localStorage?.getItem('opc_outdoor_night') === '1') return false
} catch {
/* private mode / SSR */
}
}
const h = now.getHours()
return h >= DAYTIME_START_HOUR && h <= DAYTIME_END_HOUR
}
/** Phaser camera clear color to match sky / lawn edge. */
export const SCENE_CLEAR_DAY = 0xa8d4ec
export const SCENE_CLEAR_NIGHT = 0x31453a
export const WALK_SPEED_URGENT = 200
export const WALK_SPEED_NORMAL = 100
export const WALK_SPEED_RELAXED = 60
export const WANDER_PAUSE_MIN = 2.0
export const WANDER_PAUSE_MAX = 20.0
export const WANDER_MOVES_BEFORE_REST_MIN = 3
export const WANDER_MOVES_BEFORE_REST_MAX = 6
export const SEAT_REST_MIN = 120.0
export const SEAT_REST_MAX = 240.0
export const CELEBRATE_DURATION = 2.5
export const COFFEE_DURATION_MIN = 8.0
export const COFFEE_DURATION_MAX = 15.0
export const CHAT_DURATION_MIN = 5.0
export const CHAT_DURATION_MAX = 10.0
export const STATUS_BUBBLE_DURATION = 5.0
export const INACTIVE_SEAT_TIMER_MIN = 3.0
export const INACTIVE_SEAT_TIMER_RANGE = 2.0
export function createGameConfig(parent: HTMLElement): Phaser.Types.Core.GameConfig {
const w = parent.clientWidth || window.innerWidth - 380
const h = parent.clientHeight || window.innerHeight - 48
const skyHex = isLocalDaytime() ? '#a8d4ec' : '#31453a'
return {
type: Phaser.CANVAS,
parent,
width: w,
height: h,
pixelArt: true,
backgroundColor: skyHex,
physics: {
default: 'arcade',
arcade: {
gravity: { x: 0, y: 0 },
debug: false,
},
},
scale: {
mode: Phaser.Scale.RESIZE,
autoCenter: Phaser.Scale.NONE,
parent,
},
render: {
antialias: false,
pixelArt: true,
},
}
}
@@ -0,0 +1,360 @@
import Phaser from 'phaser'
import {
TILE_SIZE,
CHAR_SCALE_X, CHAR_SCALE_Y,
CHAR_SHADOW_ALPHA, CHAR_SHADOW_HEIGHT, CHAR_SHADOW_WIDTH, CHAR_SHADOW_Y,
WALK_SPEED_URGENT, WALK_SPEED_NORMAL, WALK_SPEED_RELAXED,
CELEBRATE_DURATION, STATUS_BUBBLE_DURATION,
WANDER_PAUSE_MIN, WANDER_PAUSE_MAX,
WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX,
} from '../config'
import { AgentState, Direction } from '../types'
import type { PathfindingManager } from '../systems/PathfindingManager'
function randomRange(min: number, max: number) {
return min + Math.random() * (max - min)
}
function randomInt(min: number, max: number) {
return Math.floor(randomRange(min, max + 1))
}
export class Agent extends Phaser.GameObjects.Container {
declare body: Phaser.Physics.Arcade.Body
agentId: string
displayName: string
officeId = 'office-0'
agentState: AgentState = AgentState.IDLE
dir: Direction = Direction.DOWN
palette: number
isActive = false
currentTool: string | null = null
seatId: string | null = null
urgency: 'urgent' | 'normal' | 'relaxed' = 'relaxed'
isSubagent = false
parentAgentId: string | null = null
taskSummary?: string
lastEventAt = 0
stateTimer = 0
seatTimer = 0
wanderTimer: number
wanderCount = 0
wanderLimit: number
hueShift = 0
bubbleText: string | null = null
bubbleTimer = 0
myceliumEffect: string | null = null
myceliumEffectTimer = 0
myceliumSession: string | null = null
private sprite: Phaser.GameObjects.Sprite
private shadow: Phaser.GameObjects.Ellipse
private bubbleObj: Phaser.GameObjects.Container | null = null
private currentPath: { x: number; y: number }[] = []
private pathIndex = 0
private pathfinder: PathfindingManager | null = null
private arrivalCallback: (() => void) | null = null
constructor(
scene: Phaser.Scene,
agentId: string,
displayName: string,
palette: number,
tileX: number,
tileY: number,
) {
const px = tileX * TILE_SIZE + TILE_SIZE / 2
const py = tileY * TILE_SIZE + TILE_SIZE / 2
super(scene, px, py)
this.agentId = agentId
this.displayName = displayName
this.palette = palette % 6
this.wanderTimer = randomRange(0.5, 2.5)
this.wanderLimit = randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX)
const spriteKey = `char_${this.palette}`
this.shadow = scene.add.ellipse(
0,
CHAR_SHADOW_Y,
CHAR_SHADOW_WIDTH,
CHAR_SHADOW_HEIGHT,
0x151820,
CHAR_SHADOW_ALPHA,
)
this.shadow.setOrigin(0.5, 0.5)
this.add(this.shadow)
this.sprite = scene.add.sprite(0, 0, spriteKey)
this.sprite.setScale(CHAR_SCALE_X, CHAR_SCALE_Y)
this.sprite.setOrigin(0.5, 1)
this.add(this.sprite)
scene.add.existing(this)
scene.physics.world.enable(this)
const bodyW = Math.min(12, TILE_SIZE - 4)
const bodyH = Math.min(6, TILE_SIZE / 2)
this.body.setSize(bodyW, bodyH)
this.body.setOffset(-bodyW / 2, -bodyH)
this.body.setCollideWorldBounds(true)
this.setDepth(py)
this.playAnimForState()
}
setPathfinder(pf: PathfindingManager) {
this.pathfinder = pf
}
getState(): AgentState { return this.agentState }
getTilePos(): { x: number; y: number } {
return {
x: Math.floor(this.x / TILE_SIZE),
y: Math.floor(this.y / TILE_SIZE),
}
}
// ── State management ──────────────────────────────────────
setAgentState(newState: AgentState) {
if (this.agentState === newState) return
this.agentState = newState
this.playAnimForState()
}
setDirection(dir: Direction) {
if (this.dir === dir) return
this.dir = dir
this.playAnimForState()
}
private playAnimForState() {
if (!this.sprite?.anims) return
const key = `char_${this.palette}`
const dir = this.dir
const isLeft = dir === Direction.LEFT
this.sprite.setFlipX(isLeft)
const animDir = isLeft ? 'right' : dir
switch (this.agentState) {
case AgentState.WALK:
this.sprite.play(`${key}_walk_${animDir}`, true)
break
case AgentState.CELEBRATE:
this.sprite.play(`${key}_celebrate_${animDir}`, true)
break
case AgentState.TYPE:
case AgentState.PRESENT:
case AgentState.PRACTICE:
this.sprite.play(`${key}_type_${animDir}`, true)
break
case AgentState.THINK:
case AgentState.REFLECT:
this.sprite.play(`${key}_read_${animDir}`, true)
break
case AgentState.COFFEE:
case AgentState.CHAT:
this.sprite.play(`${key}_coffee_${animDir}`, true)
break
case AgentState.SLEEP:
case AgentState.IDLE:
default:
this.sprite.play(`${key}_idle_${animDir}`, true)
break
}
}
// ── Movement ──────────────────────────────────────────────
async walkTo(tileX: number, tileY: number, onArrival?: () => void): Promise<boolean> {
if (!this.pathfinder || !this.sprite?.anims) return false
const from = this.getTilePos()
const path = await this.pathfinder.findPath(from, { x: tileX, y: tileY })
if (path.length === 0) return false
this.currentPath = path
this.pathIndex = 0
this.arrivalCallback = onArrival ?? null
this.setAgentState(AgentState.WALK)
return true
}
stopMovement() {
this.currentPath = []
this.pathIndex = 0
this.arrivalCallback = null
this.body?.setVelocity(0, 0)
}
get isMoving(): boolean {
return this.agentState === AgentState.WALK && this.currentPath.length > 0
}
private getWalkSpeed(): number {
switch (this.urgency) {
case 'urgent': return WALK_SPEED_URGENT
case 'normal': return WALK_SPEED_NORMAL
default: return WALK_SPEED_RELAXED
}
}
// ── Bubble ────────────────────────────────────────────────
showBubble(text: string, duration = STATUS_BUBBLE_DURATION) {
this.bubbleText = text
this.bubbleTimer = duration
this.updateBubbleDisplay()
}
clearBubble() {
this.bubbleText = null
this.bubbleTimer = 0
if (this.bubbleObj) {
this.bubbleObj.destroy()
this.bubbleObj = null
}
}
private updateBubbleDisplay() {
if (this.bubbleObj) {
this.bubbleObj.destroy()
this.bubbleObj = null
}
if (!this.bubbleText) return
const container = this.scene.add.container(this.x, this.y - 70)
const textObj = this.scene.add.text(0, 0, this.bubbleText, {
fontSize: '10px',
fontFamily: 'monospace',
color: '#1a1a2e',
backgroundColor: '#ffffff',
padding: { x: 4, y: 2 },
resolution: 2,
})
textObj.setOrigin(0.5, 1)
const bg = this.scene.add.graphics()
const w = textObj.width + 8
const h = textObj.height + 4
bg.fillStyle(0xffffff, 0.95)
bg.fillRoundedRect(-w / 2, -h, w, h, 4)
bg.lineStyle(1, 0x666666, 0.5)
bg.strokeRoundedRect(-w / 2, -h, w, h, 4)
container.add([bg, textObj])
container.setDepth(100000)
this.bubbleObj = container
}
// ── Per-frame update ──────────────────────────────────────
update(dt: number) {
// Depth sorting
this.setDepth(this.y)
// Bubble position tracking + timer
if (this.bubbleObj) {
this.bubbleObj.setPosition(this.x, this.y - 70)
if (this.bubbleTimer > 0) {
this.bubbleTimer -= dt
if (this.bubbleTimer <= 0) {
this.clearBubble()
}
}
}
// State timer
if (this.stateTimer > 0) {
this.stateTimer -= dt
if (this.stateTimer <= 0) {
this.stateTimer = 0
if (this.agentState === AgentState.CELEBRATE) {
this.setAgentState(AgentState.IDLE)
}
if (this.agentState === AgentState.COFFEE) {
this.setAgentState(AgentState.IDLE)
this.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
}
if (this.agentState === AgentState.CHAT) {
this.setAgentState(AgentState.IDLE)
}
}
}
// Path following
if (this.agentState === AgentState.WALK && this.currentPath.length > 0) {
this.followPath(dt)
}
}
private followPath(dt: number) {
this.body.setVelocity(0, 0)
if (this.pathIndex >= this.currentPath.length) {
this.arriveAtDestination()
return
}
const target = this.currentPath[this.pathIndex]
const targetPx = target.x * TILE_SIZE + TILE_SIZE / 2
const targetPy = target.y * TILE_SIZE + TILE_SIZE / 2
const dx = targetPx - this.x
const dy = targetPy - this.y
const dist = Math.sqrt(dx * dx + dy * dy)
const speed = this.getWalkSpeed()
const step = speed * dt
if (dist <= step + 0.5) {
this.x = targetPx
this.y = targetPy
this.pathIndex++
if (this.pathIndex >= this.currentPath.length) {
this.arriveAtDestination()
}
return
}
this.x += (dx / dist) * step
this.y += (dy / dist) * step
if (Math.abs(dx) > Math.abs(dy)) {
this.setDirection(dx > 0 ? Direction.RIGHT : Direction.LEFT)
} else {
this.setDirection(dy > 0 ? Direction.DOWN : Direction.UP)
}
}
private arriveAtDestination() {
this.currentPath = []
this.pathIndex = 0
this.body.setVelocity(0, 0)
const cb = this.arrivalCallback
this.arrivalCallback = null
if (cb) {
cb()
} else if (this.agentState === AgentState.WALK) {
this.setAgentState(AgentState.IDLE)
}
}
destroy(fromScene?: boolean) {
this.clearBubble()
super.destroy(fromScene)
}
}
@@ -0,0 +1,215 @@
import type { Direction, SeatDef, InteractableDef } from '../types'
import { OFFICE_COLS, OFFICE_ROWS } from '../config'
import { getOffices, parseOfficeMapStr, type OfficeConfig } from './OfficeStore'
export interface ZoneDef {
name: string
bounds: { x: number; y: number; w: number; h: number }
seats: SeatDef[]
interactables: InteractableDef[]
doorways: { id: string; tileX: number; tileY: number }[]
}
function seat(id: string, tileX: number, tileY: number, facing: Direction): SeatDef {
return { id, tileX, tileY, facing, assigned: false, assignedTo: null }
}
function interactable(id: string, tileX: number, tileY: number, type: string): InteractableDef {
return { id, tileX, tileY, type }
}
interface ZoneTemplate {
name: string
localBounds: { x: number; y: number; w: number; h: number }
interactables: { id: string; localX: number; localY: number; type: string }[]
doorways: { id: string; localX: number; localY: number }[]
}
const ZONE_TEMPLATES: Record<string, ZoneTemplate> = {
meetingRoom: {
name: 'Meeting Room',
localBounds: { x: 7, y: 3, w: 6, h: 5 },
interactables: [{ id: 'whiteboard', localX: 9, localY: 1, type: 'whiteboard' }],
doorways: [{ id: 'door-meeting', localX: 9, localY: 9 }],
},
workspace: {
name: 'Workspace',
localBounds: { x: 1, y: 10, w: 11, h: 7 },
interactables: [{ id: 'printer', localX: 10, localY: 10, type: 'printer' }],
doorways: [{ id: 'door-ws', localX: 9, localY: 10 }],
},
breakRoom: {
name: 'Break Room',
localBounds: { x: 13, y: 10, w: 6, h: 8 },
interactables: [
{ id: 'coffee-machine', localX: 18, localY: 10, type: 'coffee_machine' },
{ id: 'fridge', localX: 18, localY: 11, type: 'fridge' },
],
doorways: [{ id: 'door-break', localX: 13, localY: 10 }],
},
leaderOffice: {
name: 'Leader Office',
localBounds: { x: 13, y: 18, w: 6, h: 6 },
interactables: [],
doorways: [{ id: 'door-leader', localX: 16, localY: 17 }],
},
lobby: {
name: 'Lobby',
localBounds: { x: 1, y: 19, w: 12, h: 5 },
interactables: [],
doorways: [{ id: 'entrance', localX: 10, localY: 18 }],
},
}
function inferFacing(col: number, row: number, zoneName: string): Direction {
switch (zoneName) {
case 'workspace':
return 'up'
case 'meetingRoom': {
const tableCenterX = 9.5
return col < tableCenterX ? 'right' : 'left'
}
case 'breakRoom': {
const tableCenterX = 15.5
return col < tableCenterX ? 'right' : 'left'
}
case 'leaderOffice':
return 'up'
default:
return 'down'
}
}
function classifyLocalSeat(col: number, row: number): string {
for (const [name, z] of Object.entries(ZONE_TEMPLATES)) {
const { x, y, w, h } = z.localBounds
if (col >= x && col < x + w && row >= y && row < y + h) return name
}
return 'lobby'
}
export function buildZonesForOffice(office: OfficeConfig): Record<string, ZoneDef> {
const off = office.offsetCol
const zones: Record<string, ZoneDef> = {}
for (const [zoneKey, tmpl] of Object.entries(ZONE_TEMPLATES)) {
const globalKey = `${office.id}-${zoneKey}`
zones[globalKey] = {
name: `${tmpl.name} (${office.name})`,
bounds: {
x: tmpl.localBounds.x + off,
y: tmpl.localBounds.y,
w: tmpl.localBounds.w,
h: tmpl.localBounds.h,
},
seats: [],
interactables: tmpl.interactables.map(i =>
interactable(`${office.id}-${i.id}`, i.localX + off, i.localY, i.type),
),
doorways: tmpl.doorways.map(d => ({
id: `${office.id}-${d.id}`,
tileX: d.localX + off,
tileY: d.localY,
})),
}
}
const counters: Record<string, number> = {}
for (const [col, row] of office.seats) {
const localZone = classifyLocalSeat(col, row)
const globalKey = `${office.id}-${localZone}`
if (!zones[globalKey]) continue
counters[globalKey] = (counters[globalKey] ?? 0) + 1
const idx = counters[globalKey]
const prefix = localZone === 'workspace' ? 'desk' : localZone === 'meetingRoom' ? 'meeting' : localZone === 'breakRoom' ? 'break' : localZone === 'leaderOffice' ? 'leader' : 'lobby'
const facing = inferFacing(col, row, localZone)
zones[globalKey].seats.push(seat(`${office.id}-${prefix}-${idx}`, col + off, row, facing))
}
return zones
}
export function buildAllZones(offices?: OfficeConfig[]): Record<string, ZoneDef> {
const all = offices ?? getOffices()
const zones: Record<string, ZoneDef> = {}
for (const office of all) {
Object.assign(zones, buildZonesForOffice(office))
}
return zones
}
export let ZONES: Record<string, ZoneDef> = buildAllZones()
export function reloadZones(offices?: OfficeConfig[]) {
ZONES = buildAllZones(offices)
}
export function getOfficeZoneKey(officeId: string, zoneName: string): string {
return `${officeId}-${zoneName}`
}
export function getOfficeDeskSeats(officeId: string): SeatDef[] {
const desks = ZONES[`${officeId}-workspace`]?.seats ?? []
const leaders = ZONES[`${officeId}-leaderOffice`]?.seats ?? []
return [...desks, ...leaders]
}
export function getOfficeAllSeats(officeId: string): SeatDef[] {
return Object.entries(ZONES)
.filter(([k]) => k.startsWith(`${officeId}-`))
.flatMap(([, z]) => z.seats)
}
export function getAllDeskSeats(): SeatDef[] {
return Object.entries(ZONES)
.filter(([k]) => k.endsWith('-workspace'))
.flatMap(([, z]) => z.seats)
}
export function getMeetingSeats(officeId?: string): SeatDef[] {
if (officeId) return ZONES[`${officeId}-meetingRoom`]?.seats ?? []
return Object.entries(ZONES)
.filter(([k]) => k.endsWith('-meetingRoom'))
.flatMap(([, z]) => z.seats)
}
export function getAllSeats(): SeatDef[] {
return Object.values(ZONES).flatMap(z => z.seats)
}
export function randomTileInZone(zoneKey: string): { x: number; y: number } | null {
const zone = ZONES[zoneKey]
if (!zone) return null
const { x, y, w, h } = zone.bounds
const officeId = zoneKey.split('-').slice(0, 2).join('-')
const offices = getOffices()
const office = offices.find(o => o.id === officeId)
if (!office) return { x: x + 1, y: y + 1 }
const grid = parseOfficeMapStr(office.mapStr)
const off = office.offsetCol
const walkable: { x: number; y: number }[] = []
for (let row = y; row < y + h; row++) {
for (let col = x; col < x + w; col++) {
const localCol = col - off
if (localCol >= 0 && localCol < OFFICE_COLS && row < OFFICE_ROWS && grid[row]?.[localCol] === 0) {
walkable.push({ x: col, y: row })
}
}
}
if (walkable.length === 0) return null
return walkable[Math.floor(Math.random() * walkable.length)]
}
export function getDoorwayTargets(zoneKey: string): { x: number; y: number }[] {
const zone = ZONES[zoneKey]
if (!zone) return []
return zone.doorways.map(d => ({ x: d.tileX, y: d.tileY }))
}
export function getOfficeLobbyDoorways(officeId: string): { x: number; y: number }[] {
const zone = ZONES[`${officeId}-lobby`]
if (!zone) return []
return zone.doorways.map(d => ({ x: d.tileX, y: d.tileY }))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
import { OFFICE_COLS, OFFICE_ROWS, GAP_COLS } from '../config'
export const DEFAULT_MAP_STR: string[] = [
'####################',
'####################',
'####################',
'#######......#######',
'#######..##..#######',
'#######..##..#######',
'#######..##..#######',
'#######......#######',
'#########.##########',
'#########.##########',
'#.............#....#',
'#..................#',
'#.#########....##..#',
'#...........#......#',
'#...........#..##..#',
'#.#########.#......#',
'#...........#......#',
'#...........####.###',
'##########..####.###',
'##########..#......#',
'#...........#..#...#',
'#...####....#.###..#',
'#...........#......#',
'#.##....#####....###',
'####################',
]
export const DEFAULT_SEATS: [number, number][] = [
[8, 4], [8, 5], [8, 6], [11, 4], [11, 5], [11, 6],
[3, 13], [6, 13], [9, 13], [3, 16], [6, 16], [9, 16],
[14, 12], [17, 12], [14, 14], [17, 14],
[15, 22],
]
export interface OfficeConfig {
id: string
name: string
offsetCol: number
mapStr: string[]
seats: [number, number][]
assignedAgents: string[]
}
const STORAGE_KEY = 'office-multi-config'
function makeDefaultOffices(): OfficeConfig[] {
const step = OFFICE_COLS + GAP_COLS
return [
{ id: 'office-0', name: 'Office A', offsetCol: 0, mapStr: [...DEFAULT_MAP_STR], seats: [...DEFAULT_SEATS], assignedAgents: [] },
{ id: 'office-1', name: 'Office B', offsetCol: step, mapStr: [...DEFAULT_MAP_STR], seats: [...DEFAULT_SEATS], assignedAgents: [] },
{ id: 'office-2', name: 'Office C', offsetCol: step * 2, mapStr: [...DEFAULT_MAP_STR], seats: [...DEFAULT_SEATS], assignedAgents: [] },
]
}
export const DEFAULT_OFFICES = makeDefaultOffices()
export function getOffices(): OfficeConfig[] {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as OfficeConfig[]
if (Array.isArray(parsed) && parsed.length > 0) return parsed
}
} catch { /* ignore */ }
return makeDefaultOffices()
}
export function saveOffices(offices: OfficeConfig[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(offices))
}
export function getOfficeById(id: string): OfficeConfig | undefined {
return getOffices().find(o => o.id === id)
}
export function renameOffice(id: string, name: string) {
const offices = getOffices()
const office = offices.find(o => o.id === id)
if (office) {
office.name = name
saveOffices(offices)
}
}
export function assignAgent(officeId: string, agentId: string) {
const offices = getOffices()
for (const o of offices) {
o.assignedAgents = o.assignedAgents.filter(a => a !== agentId)
}
const target = offices.find(o => o.id === officeId)
if (target) target.assignedAgents.push(agentId)
saveOffices(offices)
}
export function unassignAgent(agentId: string) {
const offices = getOffices()
for (const o of offices) {
o.assignedAgents = o.assignedAgents.filter(a => a !== agentId)
}
saveOffices(offices)
}
export function getAgentOffice(agentId: string): OfficeConfig | undefined {
return getOffices().find(o => o.assignedAgents.includes(agentId))
}
export function updateOfficeMap(officeId: string, mapStr: string[], seats: [number, number][]) {
const offices = getOffices()
const office = offices.find(o => o.id === officeId)
if (office) {
office.mapStr = mapStr
office.seats = seats
saveOffices(offices)
}
}
export function parseOfficeMapStr(mapStr: string[]): number[][] {
const grid: number[][] = []
for (let r = 0; r < OFFICE_ROWS; r++) {
const row: number[] = []
const line = r < mapStr.length ? mapStr[r] : '#'.repeat(OFFICE_COLS)
for (let c = 0; c < OFFICE_COLS; c++) {
row.push(c < line.length && line[c] === '.' ? 0 : 1)
}
grid.push(row)
}
return grid
}
export function buildCompositeGrid(offices: OfficeConfig[]): number[][] {
const worldCols = offices.length > 0
? offices[offices.length - 1].offsetCol + OFFICE_COLS
: OFFICE_COLS
const grid: number[][] = []
for (let r = 0; r < OFFICE_ROWS; r++) {
grid.push(new Array(worldCols).fill(1))
}
for (const office of offices) {
const officeGrid = parseOfficeMapStr(office.mapStr)
for (let r = 0; r < OFFICE_ROWS; r++) {
for (let c = 0; c < OFFICE_COLS; c++) {
grid[r][office.offsetCol + c] = officeGrid[r][c]
}
}
}
return grid
}
export function getWorkspaceSeatCount(office: OfficeConfig): number {
let count = 0
for (const [col, row] of office.seats) {
if (row >= 10 && row <= 17 && col < 12) count++
else if (row >= 18 && row <= 23 && col >= 13 && col <= 18) count++
}
return count
}
@@ -0,0 +1,62 @@
import Phaser from 'phaser'
const CHAR_FRAME_W = 16
const CHAR_FRAME_H = 32
const CHAR_COUNT = 6
export class BootScene extends Phaser.Scene {
constructor() {
super('Boot')
}
preload() {
this.load.image('office-bg', 'assets/office-bg.png')
this.load.spritesheet('office-tileset-32', 'assets/office-tileset-32.png', {
frameWidth: 32,
frameHeight: 32,
})
for (let i = 0; i < CHAR_COUNT; i++) {
this.load.spritesheet(`char_${i}`, `assets/characters/char_${i}.png`, {
frameWidth: CHAR_FRAME_W,
frameHeight: CHAR_FRAME_H,
})
}
const bar = this.add.graphics()
this.load.on('progress', (v: number) => {
bar.clear()
bar.fillStyle(0x4a90d9, 1)
bar.fillRect(this.scale.width / 2 - 100, this.scale.height / 2 - 8, 200 * v, 16)
})
this.load.on('complete', () => bar.destroy())
}
create() {
this.createCharacterAnimations()
console.log('[BootScene] All assets loaded')
this.scene.start('Office')
}
private createCharacterAnimations() {
const COLS = 7
const dirs = [
{ name: 'down', row: 0 },
{ name: 'up', row: 1 },
{ name: 'right', row: 2 },
{ name: 'left', row: 2 },
]
for (let p = 0; p < CHAR_COUNT; p++) {
const key = `char_${p}`
for (const dir of dirs) {
const base = dir.row * COLS
this.anims.create({ key: `${key}_walk_${dir.name}`, frames: [{ key, frame: base }, { key, frame: base + 1 }, { key, frame: base + 2 }, { key, frame: base + 1 }], frameRate: 8, repeat: -1 })
this.anims.create({ key: `${key}_idle_${dir.name}`, frames: [{ key, frame: base + 1 }], frameRate: 1 })
this.anims.create({ key: `${key}_type_${dir.name}`, frames: [{ key, frame: base + 3 }, { key, frame: base + 4 }], frameRate: 3, repeat: -1 })
this.anims.create({ key: `${key}_read_${dir.name}`, frames: [{ key, frame: base + 5 }, { key, frame: base + 6 }], frameRate: 2, repeat: -1 })
this.anims.create({ key: `${key}_coffee_${dir.name}`, frames: [{ key, frame: base + 1 }, { key, frame: base }], frameRate: 2, repeat: -1 })
this.anims.create({ key: `${key}_celebrate_${dir.name}`, frames: [{ key, frame: base }, { key, frame: base + 1 }, { key, frame: base + 2 }, { key, frame: base + 1 }], frameRate: 6, repeat: -1 })
}
}
}
}
@@ -0,0 +1,590 @@
import Phaser from 'phaser'
import {
TILE_SIZE, MAP_COLS, MAP_ROWS, OFFICE_COLS, OUTDOOR_MARGIN_X, OUTDOOR_MARGIN_TOP, OUTDOOR_MARGIN_BOTTOM,
isLocalDaytime, SCENE_CLEAR_DAY, SCENE_CLEAR_NIGHT,
} from '../config'
import { OfficeMapBuilder, type MapData } from '../map/OfficeMapBuilder'
import { Agent } from '../entities/Agent'
import { PathfindingManager } from '../systems/PathfindingManager'
import { BehaviorController } from '../systems/BehaviorController'
import type { GameBridge } from '../GameBridge'
import { getAllSeats, getOfficeDeskSeats, reloadZones } from '../map/InteractionZones'
import {
getOffices, assignAgent as storeAssignAgent,
updateOfficeMap, buildCompositeGrid, renameOffice as storeRenameOffice,
getAgentOffice,
type OfficeConfig,
} from '../map/OfficeStore'
import { AgentState, type SeatDef } from '../types'
export class OfficeScene extends Phaser.Scene {
mapData!: MapData
pathfinder!: PathfindingManager
behavior!: BehaviorController
bridge!: GameBridge
mapBuilder!: OfficeMapBuilder
private yachtLoopStarted = false
private outdoorIsDay: boolean | null = null
agents: Map<string, Agent> = new Map()
seats: SeatDef[] = []
walkableTiles: { x: number; y: number }[] = []
private walkableTilesByOffice: Map<string, { x: number; y: number }[]> = new Map()
private dragState: {
pointerId: number | null
lastX: number
lastY: number
moved: boolean
} = {
pointerId: null,
lastX: 0,
lastY: 0,
moved: false,
}
constructor() {
super('Office')
}
create() {
this.bridge = this.registry.get('bridge') as GameBridge
const isDay = isLocalDaytime()
this.outdoorIsDay = isDay
this.cameras.main.setBackgroundColor(isDay ? SCENE_CLEAR_DAY : SCENE_CLEAR_NIGHT)
this.mapBuilder = new OfficeMapBuilder()
this.mapData = this.mapBuilder.buildMap(this, isDay)
this.pathfinder = new PathfindingManager(this.mapData.collisionGrid)
this.walkableTiles = this.pathfinder.getWalkableTiles()
this.buildWalkableTilesByOffice()
this.seats = getAllSeats().map(s => ({ ...s }))
this.behavior = new BehaviorController(this)
const mapW = MAP_COLS * TILE_SIZE
const mapH = MAP_ROWS * TILE_SIZE
const worldX = -OUTDOOR_MARGIN_X
const worldY = -OUTDOOR_MARGIN_TOP
const worldW = mapW + OUTDOOR_MARGIN_X * 2
const worldH = mapH + OUTDOOR_MARGIN_TOP + OUTDOOR_MARGIN_BOTTOM
this.physics.world.setBounds(worldX, worldY, worldW, worldH)
this.cameras.main.setBounds(worldX, worldY, worldW, worldH)
this.resetCameraView(false)
this.startWaterfrontLoop()
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (!pointer.leftButtonDown()) return
this.dragState.pointerId = pointer.id
this.dragState.lastX = pointer.x
this.dragState.lastY = pointer.y
this.dragState.moved = false
})
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
if (!pointer.isDown || this.dragState.pointerId !== pointer.id) return
const dx = pointer.x - this.dragState.lastX
const dy = pointer.y - this.dragState.lastY
if (!this.dragState.moved && Math.abs(pointer.downX - pointer.x) + Math.abs(pointer.downY - pointer.y) < 6) {
return
}
this.dragState.moved = true
const cam = this.cameras.main
cam.stopFollow()
cam.scrollX -= dx / cam.zoom
cam.scrollY -= dy / cam.zoom
this.dragState.lastX = pointer.x
this.dragState.lastY = pointer.y
})
this.input.on('wheel', (_p: Phaser.Input.Pointer, _g: Phaser.GameObjects.GameObject[], _dx: number, dy: number) => {
const cam = this.cameras.main
const pointer = this.input.activePointer
const before = cam.getWorldPoint(pointer.x, pointer.y)
const minZ = this.getMinCameraZoom(cam)
const nextZoom = Phaser.Math.Clamp(cam.zoom - dy * 0.0015, minZ, 3)
cam.setZoom(nextZoom)
const after = cam.getWorldPoint(pointer.x, pointer.y)
cam.scrollX += before.x - after.x
cam.scrollY += before.y - after.y
cam.scrollX = cam.clampX(cam.scrollX)
cam.scrollY = cam.clampY(cam.scrollY)
})
this.scale.on('resize', () => {
const cam = this.cameras.main
const minZ = this.getMinCameraZoom(cam)
if (cam.zoom < minZ) cam.setZoom(minZ)
cam.scrollX = cam.clampX(cam.scrollX)
cam.scrollY = cam.clampY(cam.scrollY)
})
this.time.addEvent({
delay: 45000,
loop: true,
callback: this.checkOutdoorDayNight,
callbackScope: this,
})
this.input.on('pointerup', (pointer: Phaser.Input.Pointer) => {
if (this.dragState.pointerId !== pointer.id) return
const dragged = this.dragState.pointerId === pointer.id && this.dragState.moved
this.dragState.pointerId = null
this.dragState.moved = false
if (dragged) return
const wp = this.cameras.main.getWorldPoint(pointer.x, pointer.y)
let closest: Agent | null = null
let closestDist = Infinity
for (const agent of this.agents.values()) {
const d = Phaser.Math.Distance.Between(wp.x, wp.y, agent.x, agent.y)
if (d < 24 && d < closestDist) { closest = agent; closestDist = d }
}
if (closest) {
this.bridge.emit('agentSelected', closest.agentId)
this.cameras.main.startFollow(closest, true, 0.1, 0.1)
}
})
if (this.bridge) this.bridge.setScene(this)
console.log('[OfficeScene] Ready — walkable tiles:', this.walkableTiles.length)
}
update(_time: number, delta: number) {
const dt = delta / 1000
for (const agent of this.agents.values()) agent.update(dt)
this.behavior.updateIdle(dt)
}
private startWaterfrontLoop() {
if (this.yachtLoopStarted) return
this.yachtLoopStarted = true
const { dockCenterX, dockY, waterTopY, waterBottomY } = this.mapData.waterfront
const yachtScale = 3
const boatY = Phaser.Math.Clamp(dockY + TILE_SIZE * 5.0, waterTopY + TILE_SIZE * 3.8, waterBottomY - TILE_SIZE * 4.6)
const startX = dockCenterX - TILE_SIZE * 20
const dockX = dockCenterX + TILE_SIZE * 1.65
const exitX = dockCenterX + TILE_SIZE * 19
const shadow = this.add.ellipse(0, 13, 122, 24, 0x173443, 0.28)
const wakeA = this.add.ellipse(-78, 8, 24, 7, 0xeaf8ff, 0.42)
const wakeB = this.add.ellipse(-92, 9, 16, 5, 0xd7eef7, 0.3)
const wakeC = this.add.ellipse(-106, 10, 10, 4, 0xbfdceb, 0.18)
const hullMain = this.add.rectangle(-2, 6, 94, 16, 0xf6f7f8, 1)
const sternBlock = this.add.rectangle(-49, 5, 12, 14, 0xe5e8ec, 1)
const bowMid = this.add.rectangle(44, 4, 12, 12, 0xf6f7f8, 1)
const bowTip = this.add.triangle(56, 4, 0, -6, 13, 0, 0, 6, 0xf6f7f8, 1)
const waterline = this.add.rectangle(-1, 11, 66, 4, 0xb9c3cb, 0.95)
const hullStripe = this.add.rectangle(6, 3, 74, 3, 0x8dbfd8, 0.95)
const lowerCabin = this.add.rectangle(-6, -3, 52, 10, 0xffffff, 1)
const lowerCabinAft = this.add.rectangle(-28, -2, 18, 8, 0xecf0f2, 1)
const bridgeBase = this.add.rectangle(18, -5, 18, 8, 0xffffff, 1)
const upperDeck = this.add.rectangle(6, -13, 40, 8, 0xf8fafb, 1)
const upperDeckAft = this.add.rectangle(-18, -12, 18, 6, 0xf0f4f6, 1)
const bridgeGlass = this.add.rectangle(19, -5, 14, 4, 0x97d3f7, 0.92)
const deckGlassBand = this.add.rectangle(0, -3, 42, 3, 0xcfe9f7, 0.88)
const rail = this.add.rectangle(4, -16, 44, 2, 0xd9e1e6, 0.96)
const mast = this.add.rectangle(7, -25, 2, 9, 0xe9edf1, 0.95)
const radar = this.add.rectangle(11, -28, 9, 2, 0xe9edf1, 0.95)
const flag = this.add.triangle(17, -27, 0, -3, 7, 0, 0, 3, 0x9fc5dc, 0.95)
const yacht = this.add.container(startX, boatY, [
shadow,
wakeC,
wakeB,
wakeA,
sternBlock,
hullMain,
bowMid,
bowTip,
hullStripe,
waterline,
lowerCabinAft,
lowerCabin,
bridgeBase,
upperDeckAft,
upperDeck,
bridgeGlass,
deckGlassBand,
rail,
mast,
radar,
flag,
])
const portholes = [-26, -10, 8, 26].map(px => this.add.circle(px, 5, 2.1, 0xd9f4ff, 0.95))
yacht.add(portholes)
yacht.setDepth(-250)
yacht.setAlpha(0)
yacht.setRotation(-0.04)
yacht.setScale(yachtScale)
this.tweens.add({
targets: [wakeA, wakeB, wakeC],
scaleX: { from: 0.85, to: 1.2 },
alpha: { from: 0.5, to: 0.2 },
duration: 900,
yoyo: true,
repeat: -1,
})
const runCycle = () => {
yacht.setPosition(startX, boatY)
yacht.setAlpha(0)
yacht.setRotation(-0.04)
wakeA.setAlpha(0.55)
wakeB.setAlpha(0.42)
wakeC.setAlpha(0.24)
this.tweens.add({
targets: yacht,
x: dockX,
alpha: 1,
rotation: 0.015,
duration: 7200,
ease: 'Sine.InOut',
onComplete: () => {
wakeA.setAlpha(0.22)
wakeB.setAlpha(0.14)
wakeC.setAlpha(0.08)
this.tweens.add({
targets: yacht,
x: dockX + 4,
y: boatY + TILE_SIZE * 0.12,
duration: 1400,
yoyo: true,
repeat: 2,
ease: 'Sine.InOut',
})
this.time.delayedCall(3600, () => {
wakeA.setAlpha(0.48)
wakeB.setAlpha(0.32)
wakeC.setAlpha(0.18)
this.tweens.add({
targets: yacht,
x: exitX,
alpha: 0,
rotation: -0.055,
duration: 7600,
ease: 'Sine.InOut',
onComplete: () => {
this.time.delayedCall(2400, runCycle)
},
})
})
},
})
}
runCycle()
}
// ── Office helpers ──────────────────────────────────
private buildWalkableTilesByOffice() {
this.walkableTilesByOffice.clear()
const offices = getOffices()
for (const office of offices) {
const tiles: { x: number; y: number }[] = []
for (const t of this.walkableTiles) {
if (t.x >= office.offsetCol && t.x < office.offsetCol + OFFICE_COLS) {
tiles.push(t)
}
}
this.walkableTilesByOffice.set(office.id, tiles)
}
}
getWalkableTilesForOffice(officeId: string): { x: number; y: number }[] {
return this.walkableTilesByOffice.get(officeId) ?? []
}
resetCameraView(animate = true) {
const cam = this.cameras.main
const { worldW, worldH } = this.getWorldSize()
const minZ = this.getMinCameraZoom(cam)
const fitZoom = Math.min(this.scale.width / worldW, this.scale.height / worldH)
const maxReset = Math.max(1.22, minZ)
const targetZoom = Phaser.Math.Clamp(Math.max(fitZoom * 1.18, 0.62), minZ, maxReset)
const targetX = MAP_COLS * TILE_SIZE / 2
const targetY = (MAP_ROWS * TILE_SIZE - OUTDOOR_MARGIN_TOP + OUTDOOR_MARGIN_BOTTOM) / 2 + TILE_SIZE * 2.6
cam.stopFollow()
if (!animate) {
cam.setZoom(targetZoom)
cam.centerOn(targetX, targetY)
return
}
this.tweens.add({
targets: cam,
zoom: targetZoom,
duration: 260,
ease: 'Cubic.Out',
})
cam.pan(targetX, targetY, 260, 'Cubic.Out')
}
panToOffice(officeId: string) {
const offices = getOffices()
const office = offices.find(o => o.id === officeId)
if (!office) return
const cx = (office.offsetCol + OFFICE_COLS / 2) * TILE_SIZE
const cy = (MAP_ROWS / 2) * TILE_SIZE
const cam = this.cameras.main
const minZ = this.getMinCameraZoom(cam)
const targetZoom = Phaser.Math.Clamp(
Math.min(this.scale.width / ((OFFICE_COLS + 4) * TILE_SIZE), this.scale.height / ((MAP_ROWS - 4) * TILE_SIZE)),
minZ,
Math.max(1.5, minZ),
)
cam.stopFollow()
this.tweens.add({
targets: cam,
zoom: targetZoom,
duration: 380,
ease: 'Cubic.Out',
})
cam.pan(cx, cy, 380, 'Cubic.Out')
}
// ── Agent management ──────────────────────────────────
resolveOfficeForAgent(agentId: string): string {
const stored = getAgentOffice(agentId)
if (stored) return stored.id
const offices = getOffices()
let best: OfficeConfig | null = null
let bestFree = -1
for (const o of offices) {
const deskSeats = getOfficeDeskSeats(o.id)
const assignedCount = o.assignedAgents.length
const free = deskSeats.length - assignedCount
if (free > bestFree) { best = o; bestFree = free }
}
const officeId = best?.id ?? offices[0]?.id ?? 'office-0'
storeAssignAgent(officeId, agentId)
return officeId
}
addAgent(agentId: string, displayName?: string, isSubagent = false, parentAgentId: string | null = null, backendOfficeId?: string, backendPalette?: number, backendDeskId?: string): Agent {
if (this.agents.has(agentId)) return this.agents.get(agentId)!
const name = displayName ?? agentId
const palette = backendPalette ?? (this.agents.size % 6)
// Use backend office_id if provided, otherwise fall back to local resolution
const officeId = backendOfficeId
? (() => { storeAssignAgent(backendOfficeId, agentId); return backendOfficeId })()
: this.resolveOfficeForAgent(agentId)
// Use backend desk_id for precise seat, otherwise find free seat
const seat = backendDeskId
? (this.seats.find(s => s.id === backendDeskId && !s.assigned) ?? this.findFreeSeatInOffice(officeId))
: this.findFreeSeatInOffice(officeId)
const offices = getOffices()
const office = offices.find(o => o.id === officeId)
const fallbackX = (office?.offsetCol ?? 0) + 5
const fallbackY = 20
let startX = fallbackX
let startY = fallbackY
if (seat) {
startX = seat.tileX
startY = seat.tileY
seat.assigned = true
seat.assignedTo = agentId
}
const agent = new Agent(this, agentId, name, palette, startX, startY)
agent.officeId = officeId
agent.setPathfinder(this.pathfinder)
agent.seatId = seat?.id ?? null
agent.isSubagent = isSubagent
agent.parentAgentId = parentAgentId
if (seat) {
agent.setAgentState(AgentState.TYPE)
agent.setDirection(seat.facing)
agent.seatTimer = 10
}
this.agents.set(agentId, agent)
return agent
}
removeAgent(agentId: string) {
const agent = this.agents.get(agentId)
if (!agent) return
if (agent.seatId) {
const seat = this.seats.find(s => s.id === agent.seatId)
if (seat) { seat.assigned = false; seat.assignedTo = null }
}
agent.destroy()
this.agents.delete(agentId)
try {
const { unassignAgent } = require('../map/OfficeStore')
unassignAgent(agentId)
} catch { /* OfficeStore may not be available */ }
}
getAgent(agentId: string): Agent | undefined { return this.agents.get(agentId) }
ensureAgent(agentId: string, displayName?: string, isSubagent = false, parentAgentId: string | null = null, backendOfficeId?: string, backendPalette?: number, backendDeskId?: string): Agent {
const existing = this.agents.get(agentId)
if (existing) {
// If backend specifies a different office, reassign
if (backendOfficeId && existing.officeId !== backendOfficeId) {
this.reassignAgent(agentId, backendOfficeId)
}
// If backend specifies a specific desk, move to it
if (backendDeskId && existing.seatId !== backendDeskId) {
this.changeAgentSeat(agentId, backendDeskId)
}
return existing
}
return this.addAgent(agentId, displayName, isSubagent, parentAgentId, backendOfficeId, backendPalette, backendDeskId)
}
findFreeSeatInOffice(officeId: string): SeatDef | null {
const deskSeat = this.seats.filter(s => s.id.startsWith(`${officeId}-desk-`)).find(s => !s.assigned)
if (deskSeat) return deskSeat
return this.seats.filter(s => s.id.startsWith(`${officeId}-leader-`)).find(s => !s.assigned) ?? null
}
getSeatById(id: string): SeatDef | undefined { return this.seats.find(s => s.id === id) }
reassignAgent(agentId: string, newOfficeId: string) {
const agent = this.agents.get(agentId)
if (!agent) return
if (agent.seatId) {
const oldSeat = this.seats.find(s => s.id === agent.seatId)
if (oldSeat) { oldSeat.assigned = false; oldSeat.assignedTo = null }
}
agent.stopMovement()
storeAssignAgent(newOfficeId, agentId)
agent.officeId = newOfficeId
const newSeat = this.findFreeSeatInOffice(newOfficeId)
const offices = getOffices()
const office = offices.find(o => o.id === newOfficeId)
const fallbackX = (office?.offsetCol ?? 0) + 5
const fallbackY = 20
if (newSeat) {
newSeat.assigned = true
newSeat.assignedTo = agentId
agent.seatId = newSeat.id
agent.setPosition(newSeat.tileX * TILE_SIZE + TILE_SIZE / 2, newSeat.tileY * TILE_SIZE + TILE_SIZE / 2)
agent.setAgentState(AgentState.TYPE)
agent.setDirection(newSeat.facing)
agent.seatTimer = 10
} else {
agent.seatId = null
agent.setPosition(fallbackX * TILE_SIZE + TILE_SIZE / 2, fallbackY * TILE_SIZE + TILE_SIZE / 2)
}
}
getCharacterCards() {
const cards: Array<{
id: string; displayName: string; state: string; currentTool: string | null
isSubagent: boolean; parentAgentId: string | null; taskSummary?: string
lastEventAt: number; officeId: string; seatId: string | null
}> = []
for (const agent of this.agents.values()) {
cards.push({
id: agent.agentId, displayName: agent.displayName, state: agent.agentState,
currentTool: agent.currentTool, isSubagent: agent.isSubagent,
parentAgentId: agent.parentAgentId, taskSummary: agent.taskSummary,
lastEventAt: agent.lastEventAt, officeId: agent.officeId, seatId: agent.seatId,
})
}
return cards
}
changeAgentSeat(agentId: string, newSeatId: string) {
const agent = this.agents.get(agentId)
if (!agent) return
if (agent.seatId) {
const oldSeat = this.seats.find(s => s.id === agent.seatId)
if (oldSeat) { oldSeat.assigned = false; oldSeat.assignedTo = null }
}
const newSeat = this.seats.find(s => s.id === newSeatId)
if (!newSeat || (newSeat.assigned && newSeat.assignedTo !== agentId)) return
newSeat.assigned = true
newSeat.assignedTo = agentId
agent.seatId = newSeatId
agent.stopMovement()
agent.setPosition(newSeat.tileX * TILE_SIZE + TILE_SIZE / 2, newSeat.tileY * TILE_SIZE + TILE_SIZE / 2)
agent.setAgentState(AgentState.TYPE)
agent.setDirection(newSeat.facing)
agent.seatTimer = 10
}
rebuildOfficeCollision(officeId: string, mapStr: string[], seatCoords: [number, number][]) {
updateOfficeMap(officeId, mapStr, seatCoords)
if (this.mapData.wallBodies) {
this.mapData.wallBodies.clear(true, true)
}
const offices = getOffices()
const grid = buildCompositeGrid(offices)
const wallBodies = OfficeMapBuilder.buildWallBodies(this, grid)
this.mapData = { ...this.mapData, wallBodies, collisionGrid: grid }
this.pathfinder = new PathfindingManager(grid)
this.walkableTiles = this.pathfinder.getWalkableTiles()
this.buildWalkableTilesByOffice()
reloadZones(offices)
this.seats = getAllSeats().map(s => ({ ...s }))
for (const agent of this.agents.values()) {
agent.setPathfinder(this.pathfinder)
agent.stopMovement()
}
console.log('[OfficeScene] Office collision rebuilt —', officeId, 'walkable:', this.walkableTiles.length)
}
renameOffice(officeId: string, newName: string) {
storeRenameOffice(officeId, newName)
this.mapBuilder.updateLabel(officeId, newName)
}
/** Matches `setBounds` in create(); used for zoom floor so the viewport never extends past the world. */
private checkOutdoorDayNight() {
this.applyOutdoorLighting(isLocalDaytime())
}
/** Call after changing URL or `opc_outdoor_override` in localStorage (via GameBridge). */
syncOutdoorLighting() {
this.applyOutdoorLighting(isLocalDaytime())
}
private applyOutdoorLighting(next: boolean) {
if (next === this.outdoorIsDay) return
this.outdoorIsDay = next
this.cameras.main.setBackgroundColor(next ? SCENE_CLEAR_DAY : SCENE_CLEAR_NIGHT)
this.mapBuilder.refreshOutdoorDayNight(this, next)
}
private getWorldSize() {
const mapW = MAP_COLS * TILE_SIZE
const mapH = MAP_ROWS * TILE_SIZE
return {
worldW: mapW + OUTDOOR_MARGIN_X * 2,
worldH: mapH + OUTDOOR_MARGIN_TOP + OUTDOOR_MARGIN_BOTTOM,
}
}
/** Minimum zoom so `displayWidth/Height` never exceeds world bounds (avoids empty margin past the map). */
private getMinCameraZoom(cam: Phaser.Cameras.Scene2D.Camera) {
const { worldW, worldH } = this.getWorldSize()
return Math.max(cam.width / worldW, cam.height / worldH)
}
}
@@ -0,0 +1,548 @@
import { AgentState, Direction } from '../types'
import {
CELEBRATE_DURATION, COFFEE_DURATION_MIN, COFFEE_DURATION_MAX,
CHAT_DURATION_MIN, CHAT_DURATION_MAX,
WANDER_PAUSE_MIN, WANDER_PAUSE_MAX,
WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX,
SEAT_REST_MIN, SEAT_REST_MAX,
INACTIVE_SEAT_TIMER_MIN, INACTIVE_SEAT_TIMER_RANGE,
TILE_SIZE,
} from '../config'
import type { OfficeScene } from '../scenes/OfficeScene'
import type { Agent } from '../entities/Agent'
import { ZONES, randomTileInZone, getOfficeLobbyDoorways } from '../map/InteractionZones'
import type { VisualEvent } from '../../types/visual'
function randomRange(min: number, max: number) { return min + Math.random() * (max - min) }
function randomInt(min: number, max: number) { return Math.floor(randomRange(min, max + 1)) }
function trimPreview(s: string, maxLen: number) { return s.length > maxLen ? s.slice(0, maxLen) + '...' : s }
const READING_TOOLS = new Set([
'read', 'read_file', 'browse', 'list', 'list_dir',
'glob', 'grep', 'search', 'fetch', 'web_fetch',
'webfetch', 'web_search', 'websearch',
])
function mapToolToState(toolName: string | null): AgentState {
if (!toolName) return AgentState.TYPE
const t = toolName.toLowerCase().trim()
if (t === 'reflect') return AgentState.REFLECT
if (t === 'practice') return AgentState.PRACTICE
if (t === 'synthesize') return AgentState.PRESENT
if (READING_TOOLS.has(t)) return AgentState.THINK
return AgentState.TYPE
}
function mapToolDisplay(toolName: string | null): string {
if (!toolName) return 'Tool'
const t = toolName.toLowerCase().trim()
if (t === 'shell') return 'Shell'
if (t === 'write_file' || t === 'write') return 'Write'
if (t === 'edit_file' || t === 'edit') return 'Edit'
if (t === 'read_file' || t === 'read') return 'Read'
if (t === 'grep' || t === 'search') return 'Search'
if (t === 'web_search' || t === 'websearch') return 'WebSearch'
if (t === 'web_fetch' || t === 'webfetch') return 'Fetch'
return toolName.slice(0, 12)
}
export class BehaviorController {
private scene: OfficeScene
private pendingDespawn = new Set<string>()
constructor(scene: OfficeScene) {
this.scene = scene
}
// ── Event dispatch ────────────────────────────────────
applyEvent(evt: VisualEvent) {
const data = evt.data ?? {}
const agentId = evt.agent_id || 'openopc-main'
if (agentId === 'user') return
const isSub = agentId.startsWith('subagent-')
const parentId = isSub
? (typeof data.parent_agent_id === 'string' ? data.parent_agent_id : 'openopc-main')
: null
const displayName = isSub ? `Sub ${agentId.slice(-4)}` : undefined
const agent = this.scene.ensureAgent(agentId, displayName, isSub, parentId)
agent.lastEventAt = Date.now()
if (typeof data.task_preview === 'string') agent.taskSummary = data.task_preview
if (typeof data.result_preview === 'string') agent.taskSummary = data.result_preview
switch (evt.type) {
case 'tool_start': this.onToolStart(agent, data); break
case 'tool_done': this.onToolDone(agent, data); break
case 'agent_active': this.onAgentActive(agent); break
case 'waiting': this.onWaiting(agent); break
case 'reflect_start': this.onReflectStart(agent); break
case 'reflect_done': this.onReflectDone(agent); break
case 'skill_synthesized': this.onSkillSynthesized(agent, data); break
case 'subagent_spawn': this.onSubagentSpawn(agent, agentId, parentId); break
case 'subagent_done': this.onSubagentDone(agent, agentId); break
case 'message_in': this.onMessageIn(agent, data); break
case 'message_out': this.onMessageOut(agent, data); break
case 'practice_start': this.onPracticeStart(agent, data); break
case 'practice_done': this.onPracticeDone(agent); break
case 'task_routed': this.onTaskRouted(agent, data); break
case 'task_delegated': this.onTaskDelegated(agent, agentId, data); break
case 'delegation_done': this.onDelegationDone(agent, data); break
case 'agent_spawned': this.onAgentSpawned(agent, agentId, data); break
case 'agent_removed': this.onAgentRemoved(agent, agentId); break
case 'collab_started': this.onCollabStarted(agent); break
case 'collab_ended': this.onCollabEnded(agent); break
case 'skill_published': this.onSkillPublished(agent, data); break
case 'skill_adopted': this.onSkillAdopted(agent, data); break
case 'mycelium_transport': this.onMyceliumTransport(agent, agentId, data); break
case 'mycelium_crystallize': this.onMyceliumCrystallize(agent, agentId, data); break
case 'mycelium_spore': this.onMyceliumSpore(agent, agentId, data); break
case 'mycelium_decompose': this.onMyceliumDecompose(agent, agentId, data); break
case 'mycelium_unit_created': this.onMyceliumUnitCreated(agent, data); break
case 'mycelium_germinate': this.onMyceliumGerminate(agent, agentId, data); break
case 'hyphal_strengthen': this.onHyphalStrengthen(agentId, data); break
case 'hyphal_weaken': this.onHyphalWeaken(agentId, data); break
}
}
// ── Individual event handlers ─────────────────────────
private onToolStart(agent: Agent, data: Record<string, unknown>) {
const toolName = typeof data.tool_name === 'string' ? data.tool_name : 'tool'
const label = mapToolDisplay(toolName)
agent.urgency = 'urgent'
agent.currentTool = toolName
agent.isActive = true
agent.showBubble(`${label}...`)
this.sendToSeat(agent)
}
private onToolDone(agent: Agent, data: Record<string, unknown>) {
const toolName = typeof data.tool_name === 'string' ? data.tool_name : agent.currentTool
const label = mapToolDisplay(toolName)
agent.currentTool = null
agent.setAgentState(AgentState.CELEBRATE)
agent.stateTimer = CELEBRATE_DURATION
agent.isActive = false
agent.showBubble(`${label} done`)
}
private onAgentActive(agent: Agent) {
agent.urgency = 'urgent'
agent.isActive = true
if (!agent.currentTool) {
this.sendToSeat(agent)
}
}
private onWaiting(agent: Agent) {
agent.urgency = 'relaxed'
agent.isActive = false
agent.currentTool = null
agent.showBubble('Waiting')
const moved = this.moveToZone(agent, 'breakRoom', AgentState.COFFEE)
if (!moved) agent.setAgentState(AgentState.IDLE)
if (moved) agent.stateTimer = randomRange(COFFEE_DURATION_MIN, COFFEE_DURATION_MAX)
}
private onReflectStart(agent: Agent) {
agent.urgency = 'normal'
agent.isActive = false
agent.currentTool = 'Reflect'
const moved = this.moveToZone(agent, 'meetingRoom', AgentState.REFLECT)
if (!moved) agent.setAgentState(AgentState.REFLECT)
agent.stateTimer = 30.0
agent.showBubble('Reflecting...')
}
private onReflectDone(agent: Agent) {
agent.currentTool = null
agent.setAgentState(AgentState.CELEBRATE)
agent.stateTimer = CELEBRATE_DURATION
agent.showBubble('Insight!')
}
private onSkillSynthesized(agent: Agent, data: Record<string, unknown>) {
const name = trimPreview(String(data.skill_name ?? 'new'), 20)
agent.setAgentState(AgentState.PRESENT)
agent.showBubble(`New Skill: ${name}`)
}
private onSubagentSpawn(agent: Agent, agentId: string, parentId: string | null) {
this.placeAtDoorway(agent)
agent.urgency = 'urgent'
agent.isActive = true
agent.showBubble('Spawned')
this.sendToSeat(agent)
}
private onSubagentDone(agent: Agent, agentId: string) {
agent.urgency = 'relaxed'
agent.currentTool = null
agent.isActive = false
agent.showBubble('Finished')
const moved = this.moveToDoorway(agent)
if (moved) {
this.pendingDespawn.add(agentId)
} else {
this.scene.removeAgent(agentId)
}
}
private onMessageIn(agent: Agent, data: Record<string, unknown>) {
agent.urgency = 'urgent'
const content = typeof data.content_preview === 'string' ? data.content_preview : ''
if (content) agent.taskSummary = content
const preview = content ? trimPreview(content, 30) : 'New task'
agent.showBubble(preview)
agent.isActive = true
this.sendToSeat(agent)
}
private onMessageOut(agent: Agent, data: Record<string, unknown>) {
const content = typeof data.content_preview === 'string' ? data.content_preview : ''
if (content) agent.taskSummary = content
const preview = content ? trimPreview(content, 30) : 'Reply sent'
agent.showBubble(`Reply: ${preview}`)
}
private onPracticeStart(agent: Agent, data: Record<string, unknown>) {
agent.urgency = 'normal'
const domain = typeof data.target_domain === 'string' ? data.target_domain : 'Practice'
agent.showBubble(`Practicing: ${trimPreview(domain, 20)}`)
agent.isActive = false
agent.currentTool = 'Practice'
const moved = this.moveToZone(agent, 'meetingRoom', AgentState.PRACTICE)
if (!moved) agent.setAgentState(AgentState.PRACTICE)
}
private onPracticeDone(agent: Agent) {
agent.currentTool = null
agent.setAgentState(AgentState.CELEBRATE)
agent.stateTimer = CELEBRATE_DURATION
agent.showBubble('Practice done!')
}
private onTaskRouted(agent: Agent, data: Record<string, unknown>) {
agent.urgency = 'urgent'
const method = typeof data.method === 'string' ? data.method : 'auto'
agent.isActive = true
this.sendToSeat(agent)
agent.showBubble(`Assigned (${method})`)
}
private onTaskDelegated(agent: Agent, agentId: string, data: Record<string, unknown>) {
agent.urgency = 'normal'
agent.stateTimer = randomRange(CHAT_DURATION_MIN, CHAT_DURATION_MAX)
const target = typeof data.target === 'string' ? data.target : '?'
agent.setAgentState(AgentState.CHAT)
agent.showBubble(`Delegating to ${target}...`)
const targetAgent = this.scene.getAgent(target)
if (targetAgent) {
targetAgent.parentAgentId = agentId
targetAgent.showBubble('Receiving task...')
}
}
private onDelegationDone(agent: Agent, data: Record<string, unknown>) {
const target = typeof data.target === 'string' ? data.target : ''
agent.setAgentState(AgentState.IDLE)
agent.showBubble('Delegation complete')
if (target) {
const targetAgent = this.scene.getAgent(target)
if (targetAgent) targetAgent.parentAgentId = null
}
}
private onAgentSpawned(agent: Agent, agentId: string, data: Record<string, unknown>) {
const roleName = typeof data.role_name === 'string' ? data.role_name : agentId
agent.displayName = roleName
this.placeAtDoorway(agent)
this.sendToSeat(agent)
agent.showBubble(`${roleName} joined`)
}
private onAgentRemoved(agent: Agent, agentId: string) {
agent.showBubble('Leaving...')
const moved = this.moveToDoorway(agent)
if (!moved) {
this.scene.removeAgent(agentId)
} else {
this.pendingDespawn.add(agentId)
}
}
private onCollabStarted(agent: Agent) {
agent.urgency = 'normal'
agent.isActive = false
agent.stateTimer = randomRange(CHAT_DURATION_MIN, CHAT_DURATION_MAX)
this.moveToZone(agent, 'meetingRoom', AgentState.CHAT)
agent.showBubble('Collaborating...')
}
private onCollabEnded(agent: Agent) {
agent.stateTimer = CELEBRATE_DURATION
agent.setAgentState(AgentState.CELEBRATE)
agent.showBubble('Collaboration done!')
}
private onSkillPublished(agent: Agent, data: Record<string, unknown>) {
const name = trimPreview(String(data.skill_name ?? 'skill'), 20)
agent.setAgentState(AgentState.CELEBRATE)
agent.stateTimer = CELEBRATE_DURATION
agent.showBubble(`Published: ${name}`)
}
private onSkillAdopted(agent: Agent, data: Record<string, unknown>) {
const name = trimPreview(String(data.skill_name ?? 'skill'), 20)
agent.showBubble(`Adopted: ${name}`)
}
// ── Mycelium events ───────────────────────────────────
private onMyceliumTransport(agent: Agent, agentId: string, data: Record<string, unknown>) {
const source = typeof data.source === 'string' ? data.source : agentId
const target = typeof data.target === 'string' ? data.target : null
const domain = typeof data.domain === 'string' ? trimPreview(data.domain, 15) : '?'
const srcAgent = this.scene.ensureAgent(source)
srcAgent.myceliumEffect = 'transport_send'
srcAgent.myceliumEffectTimer = 3.0
srcAgent.showBubble(`-> [${domain}]`)
if (target) {
const tgtAgent = this.scene.ensureAgent(target)
tgtAgent.myceliumEffect = 'transport_recv'
tgtAgent.myceliumEffectTimer = 2.0
tgtAgent.showBubble('Receiving...')
const tgtPos = tgtAgent.getTilePos()
srcAgent.walkTo(tgtPos.x, tgtPos.y + 1)
}
}
private onMyceliumCrystallize(agent: Agent, agentId: string, data: Record<string, unknown>) {
const corrobAgents: string[] = Array.isArray(data.corroborating_agents)
? data.corroborating_agents : []
const contentPreview = trimPreview(String(data.content_preview ?? 'Knowledge'), 22)
const allParticipants = Array.from(new Set([agentId, ...corrobAgents]))
const meetingZoneKey = `${agent.officeId}-meetingRoom`
const meetingSeats = ZONES[meetingZoneKey]?.seats ?? []
for (let i = 0; i < allParticipants.length; i++) {
const pid = allParticipants[i]
const pAgent = this.scene.ensureAgent(pid)
pAgent.myceliumEffect = 'crystal'
pAgent.myceliumEffectTimer = 15.0
pAgent.isActive = false
const seatIdx = i % meetingSeats.length
const seat = meetingSeats[seatIdx]
pAgent.walkTo(seat.tileX, seat.tileY, () => {
pAgent.setDirection(seat.facing)
pAgent.setAgentState(AgentState.CHAT)
})
pAgent.showBubble(pid === agentId ? `Crystal: ${contentPreview}` : 'Crystal!')
}
}
private onMyceliumSpore(agent: Agent, agentId: string, data: Record<string, unknown>) {
const preview = trimPreview(String(data.content_preview ?? 'Breakthrough'), 20)
agent.myceliumEffect = 'spore_send'
agent.myceliumEffectTimer = 4.0
agent.setAgentState(AgentState.PRESENT)
agent.showBubble(`Breakthrough! ${preview}`)
// Move to break room for broadcast
this.moveToZone(agent, 'breakRoom')
}
private onMyceliumDecompose(agent: Agent, agentId: string, data: Record<string, unknown>) {
const preview = trimPreview(String(data.humus_preview ?? 'Lesson learned'), 22)
agent.myceliumEffect = 'decompose'
agent.myceliumEffectTimer = 4.0
agent.setAgentState(AgentState.REFLECT)
agent.showBubble(`Learning: ${preview}`)
this.moveToZone(agent, 'meetingRoom')
}
private onMyceliumUnitCreated(agent: Agent, data: Record<string, unknown>) {
const nutrientType = String(data.nutrient_type ?? 'insight').slice(0, 1).toUpperCase()
agent.showBubble(`[${nutrientType}]...`, 2.0)
}
private onMyceliumGerminate(agent: Agent, agentId: string, data: Record<string, unknown>) {
const targetAgent = typeof data.target_agent === 'string' ? data.target_agent : agentId
const tgt = this.scene.ensureAgent(targetAgent)
tgt.myceliumEffect = null
tgt.setAgentState(AgentState.CELEBRATE)
tgt.stateTimer = CELEBRATE_DURATION
tgt.showBubble('Insight took root!')
}
private onHyphalStrengthen(_agentId: string, _data: Record<string, unknown>) {
// Visual connection tracking could be added here
}
private onHyphalWeaken(_agentId: string, _data: Record<string, unknown>) {
// Visual connection tracking could be added here
}
// ── Idle behavior (called each frame) ─────────────────
updateIdle(dt: number) {
for (const agent of this.scene.agents.values()) {
// Check pending despawn
if (this.pendingDespawn.has(agent.agentId) && !agent.isMoving) {
this.pendingDespawn.delete(agent.agentId)
this.scene.removeAgent(agent.agentId)
continue
}
// Mycelium effect timer
if (agent.myceliumEffectTimer > 0) {
agent.myceliumEffectTimer -= dt
if (agent.myceliumEffectTimer <= 0) {
agent.myceliumEffect = null
agent.myceliumEffectTimer = 0
agent.myceliumSession = null
}
}
// Non-active agents sitting at desk: count down seatTimer then transition to IDLE
if (agent.agentState === AgentState.TYPE && !agent.isActive) {
if (agent.seatTimer > 0) {
agent.seatTimer -= dt
if (agent.seatTimer <= 0) {
agent.seatTimer = 0
agent.setAgentState(AgentState.IDLE)
agent.wanderCount = 0
agent.wanderLimit = randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX)
agent.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
}
} else {
agent.setAgentState(AgentState.IDLE)
}
continue
}
if (agent.agentState !== AgentState.IDLE) continue
// Active agents should go to seat
if (agent.isActive) {
agent.urgency = 'urgent'
if (!agent.seatId) {
agent.setAgentState(AgentState.TYPE)
continue
}
this.sendToSeat(agent)
continue
}
// Idle wander logic
agent.wanderTimer -= dt
if (agent.wanderTimer <= 0) {
// Wander limit reached => go back to seat and rest
if (agent.wanderCount >= agent.wanderLimit && agent.seatId) {
agent.urgency = 'relaxed'
this.sendToSeat(agent)
agent.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
continue
}
// Random wander (confined to agent's office)
const tiles = this.scene.getWalkableTilesForOffice(agent.officeId)
if (tiles.length > 0) {
agent.urgency = 'relaxed'
const target = tiles[Math.floor(Math.random() * tiles.length)]
agent.walkTo(target.x, target.y)
agent.wanderCount++
}
agent.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
}
}
}
// ── Movement helpers ──────────────────────────────────
sendToSeat(agent: Agent) {
if (!agent.seatId) return
const seat = this.scene.getSeatById(agent.seatId)
if (!seat) return
agent.walkTo(seat.tileX, seat.tileY, () => {
agent.setAgentState(AgentState.TYPE)
agent.setDirection(seat.facing)
if (!agent.isActive) {
agent.seatTimer = INACTIVE_SEAT_TIMER_MIN + Math.random() * INACTIVE_SEAT_TIMER_RANGE
}
}).then(moved => {
if (!moved) {
agent.setAgentState(AgentState.TYPE)
agent.setDirection(seat.facing)
if (!agent.isActive) {
agent.seatTimer = INACTIVE_SEAT_TIMER_MIN + Math.random() * INACTIVE_SEAT_TIMER_RANGE
}
}
})
}
moveToZone(agent: Agent, zoneName: string, arrivalState?: string): boolean {
const zoneKey = `${agent.officeId}-${zoneName}`
const zone = ZONES[zoneKey]
if (!zone) return false
if (zone.seats.length > 0) {
const occupiedTiles = new Set<string>()
for (const other of this.scene.agents.values()) {
if (other === agent) continue
if (other.isMoving) continue
const pos = other.getTilePos()
occupiedTiles.add(`${pos.x},${pos.y}`)
}
for (const other of this.scene.agents.values()) {
if (other === agent) continue
if (!other.isMoving) continue
const path = (other as any).currentPath as { x: number; y: number }[]
if (path?.length) {
const dest = path[path.length - 1]
occupiedTiles.add(`${dest.x},${dest.y}`)
}
}
const freeSeat = zone.seats.find(s => !occupiedTiles.has(`${s.tileX},${s.tileY}`))
if (freeSeat) {
agent.walkTo(freeSeat.tileX, freeSeat.tileY, () => {
agent.setDirection(freeSeat.facing)
if (arrivalState) agent.setAgentState(arrivalState as any)
else agent.setAgentState(AgentState.IDLE)
})
return true
}
}
const pos = randomTileInZone(zoneKey)
if (!pos) return false
agent.walkTo(pos.x, pos.y, () => {
if (arrivalState) agent.setAgentState(arrivalState as any)
else agent.setAgentState(AgentState.IDLE)
})
return true
}
moveToDoorway(agent: Agent): boolean {
const doorways = getOfficeLobbyDoorways(agent.officeId)
if (doorways.length === 0) return false
const target = doorways[Math.floor(Math.random() * doorways.length)]
agent.walkTo(target.x, target.y)
return true
}
placeAtDoorway(agent: Agent) {
const doorways = getOfficeLobbyDoorways(agent.officeId)
if (doorways.length > 0) {
const d = doorways[Math.floor(Math.random() * doorways.length)]
agent.setPosition(d.x * TILE_SIZE + TILE_SIZE / 2, d.y * TILE_SIZE + TILE_SIZE / 2)
}
}
}
@@ -0,0 +1,97 @@
import * as EasyStar from 'easystarjs'
export class PathfindingManager {
private easystar: EasyStar.js
private grid: number[][]
private gridCols: number
private gridRows: number
constructor(collisionGrid: number[][]) {
this.grid = collisionGrid.map(row => [...row])
this.gridRows = this.grid.length
this.gridCols = this.gridRows > 0 ? this.grid[0].length : 0
this.easystar = new EasyStar.js()
this.easystar.setGrid(this.grid)
this.easystar.setAcceptableTiles([0])
this.easystar.setIterationsPerCalculation(800)
}
findPath(
from: { x: number; y: number },
to: { x: number; y: number },
): Promise<{ x: number; y: number }[]> {
return new Promise((resolve) => {
if (
from.x < 0 || from.x >= this.gridCols ||
from.y < 0 || from.y >= this.gridRows ||
to.x < 0 || to.x >= this.gridCols ||
to.y < 0 || to.y >= this.gridRows
) {
resolve([])
return
}
if (this.grid[to.y]?.[to.x] === 1) {
const alt = this.findNearestWalkable(to.x, to.y)
if (!alt) { resolve([]); return }
to = alt
}
if (this.grid[from.y]?.[from.x] === 1) {
const alt = this.findNearestWalkable(from.x, from.y)
if (!alt) { resolve([]); return }
from = alt
}
this.easystar.findPath(from.x, from.y, to.x, to.y, (path) => {
resolve(path ?? [])
})
this.easystar.calculate()
})
}
blockTile(x: number, y: number) {
if (y >= 0 && y < this.grid.length && x >= 0 && x < this.grid[0].length) {
this.grid[y][x] = 1
this.easystar.setGrid(this.grid)
}
}
unblockTile(x: number, y: number) {
if (y >= 0 && y < this.grid.length && x >= 0 && x < this.grid[0].length) {
this.grid[y][x] = 0
this.easystar.setGrid(this.grid)
}
}
isWalkable(x: number, y: number): boolean {
if (y < 0 || y >= this.grid.length || x < 0 || x < 0 || x >= this.grid[0].length) return false
return this.grid[y][x] === 0
}
getWalkableTiles(): { x: number; y: number }[] {
const tiles: { x: number; y: number }[] = []
for (let r = 0; r < this.grid.length; r++) {
for (let c = 0; c < this.grid[r].length; c++) {
if (this.grid[r][c] === 0) {
tiles.push({ x: c, y: r })
}
}
}
return tiles
}
private findNearestWalkable(x: number, y: number): { x: number; y: number } | null {
for (let radius = 1; radius <= 5; radius++) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue
const nx = x + dx
const ny = y + dy
if (this.isWalkable(nx, ny)) return { x: nx, y: ny }
}
}
}
return null
}
}
@@ -0,0 +1,361 @@
/**
* Agent event test runner — multi-office edition.
* Attach to window so it can be called from browser console:
* window.__runEventTests()
*/
import type { GameBridge } from '../GameBridge'
import type { VisualEvent } from '../../types/visual'
import { ZONES } from '../map/InteractionZones'
import { getOffices } from '../map/OfficeStore'
let _eventId = 0
function makeEvent(type: string, agentId: string, data: Record<string, unknown> = {}): VisualEvent {
return {
event_id: `test-${++_eventId}`,
type,
agent_id: agentId,
data,
timestamp: Date.now() / 1000,
}
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms))
}
interface TestResult {
name: string
pass: boolean
detail: string
}
export async function runAllTests(bridge: GameBridge): Promise<TestResult[]> {
const results: TestResult[] = []
const scene = bridge.getScene()
if (!scene) {
results.push({ name: 'scene-ready', pass: false, detail: 'OfficeScene not initialized' })
return results
}
console.log('%c[EventTest] Starting full event test suite (multi-office)...', 'color: #6366f1; font-weight: bold')
for (const id of [...scene.agents.keys()]) scene.removeAgent(id)
await sleep(100)
const offices = getOffices()
// ── Test 1: Office data ──
results.push({
name: 'offices-loaded',
pass: offices.length >= 3,
detail: `${offices.length} offices loaded: ${offices.map(o => o.name).join(', ')}`,
})
// ── Test 2: Agents assigned to different offices ──
const agentIds = ['agent-A', 'agent-B', 'agent-C', 'agent-D', 'agent-E', 'agent-F']
for (const id of agentIds) {
bridge.pushEvent(makeEvent('agent_active', id))
}
await sleep(300)
const assignedSeats = new Set<string>()
let deskOverlap = false
for (const id of agentIds) {
const agent = scene.getAgent(id)
if (!agent) continue
if (agent.seatId) {
if (assignedSeats.has(agent.seatId)) deskOverlap = true
assignedSeats.add(agent.seatId)
}
}
results.push({
name: 'desk-assignment-unique',
pass: !deskOverlap && assignedSeats.size === agentIds.length,
detail: `Assigned ${assignedSeats.size} unique seats to ${agentIds.length} agents. IDs: [${[...assignedSeats].join(', ')}]`,
})
// ── Test 3: All agents have officeId set ──
const allHaveOffice = agentIds.every(id => {
const agent = scene.getAgent(id)
return agent?.officeId != null
})
results.push({
name: 'agents-have-officeId',
pass: allHaveOffice,
detail: agentIds.map(id => `${id}=${scene.getAgent(id)?.officeId}`).join(', '),
})
// ── Test 4: tool_start → active ──
bridge.pushEvent(makeEvent('tool_start', 'agent-A', { tool_name: 'shell' }))
await sleep(200)
const agA = scene.getAgent('agent-A')!
results.push({
name: 'tool_start-state',
pass: agA.isActive === true && agA.currentTool === 'shell',
detail: `isActive=${agA.isActive}, currentTool=${agA.currentTool}`,
})
// ── Test 5: tool_done → celebrate ──
bridge.pushEvent(makeEvent('tool_done', 'agent-A', { tool_name: 'shell' }))
await sleep(100)
results.push({
name: 'tool_done-celebrate',
pass: agA.agentState === 'celebrate',
detail: `state=${agA.agentState}`,
})
for (let i = 0; i < 20; i++) {
if (agA.agentState !== 'celebrate') break
await sleep(500)
}
results.push({
name: 'celebrate-to-idle',
pass: agA.agentState !== 'celebrate',
detail: `state=${agA.agentState}`,
})
// ── Test 6: waiting → break room ──
bridge.pushEvent(makeEvent('waiting', 'agent-B'))
await sleep(200)
const agB = scene.getAgent('agent-B')!
results.push({
name: 'waiting-state',
pass: agB.isActive === false && (agB.agentState === 'walk' || agB.agentState === 'idle' || agB.agentState === 'coffee'),
detail: `isActive=${agB.isActive}, state=${agB.agentState}`,
})
// ── Test 7: reflect_start → meeting room ──
bridge.pushEvent(makeEvent('reflect_start', 'agent-C'))
await sleep(200)
const agC = scene.getAgent('agent-C')!
results.push({
name: 'reflect_start-state',
pass: agC.currentTool === 'Reflect' && (agC.agentState === 'walk' || agC.agentState === 'reflect'),
detail: `currentTool=${agC.currentTool}, state=${agC.agentState}`,
})
bridge.pushEvent(makeEvent('reflect_done', 'agent-C'))
await sleep(100)
results.push({
name: 'reflect_done-celebrate',
pass: agC.agentState === 'celebrate',
detail: `state=${agC.agentState}`,
})
// ── Test 8: collab in meeting room (same office) ──
const collabAgents = ['agent-A', 'agent-B', 'agent-C', 'agent-D']
for (const id of collabAgents) {
bridge.pushEvent(makeEvent('collab_started', id))
}
await sleep(4000)
const meetingPositions = new Map<string, string>()
let meetingOverlap = false
for (const id of collabAgents) {
const agent = scene.getAgent(id)
if (!agent) continue
const pos = agent.getTilePos()
const key = `${pos.x},${pos.y}`
if (meetingPositions.has(key)) meetingOverlap = true
meetingPositions.set(key, id)
}
results.push({
name: 'collab-no-overlap',
pass: !meetingOverlap,
detail: `${collabAgents.length} agents, ${meetingPositions.size} unique positions`,
})
for (const id of collabAgents) bridge.pushEvent(makeEvent('collab_ended', id))
await sleep(100)
const allCelebrate = collabAgents.every(id => scene.getAgent(id)?.agentState === 'celebrate')
results.push({
name: 'collab_ended-celebrate',
pass: allCelebrate,
detail: collabAgents.map(id => `${id}=${scene.getAgent(id)?.agentState}`).join(', '),
})
await sleep(3000)
// ── Test 9: practice ──
bridge.pushEvent(makeEvent('practice_start', 'agent-E', { target_domain: 'TypeScript' }))
await sleep(200)
const agE = scene.getAgent('agent-E')!
results.push({
name: 'practice_start-state',
pass: agE.currentTool === 'Practice' && (agE.agentState === 'walk' || agE.agentState === 'practice'),
detail: `currentTool=${agE.currentTool}, state=${agE.agentState}`,
})
bridge.pushEvent(makeEvent('practice_done', 'agent-E'))
await sleep(100)
results.push({
name: 'practice_done-celebrate',
pass: agE.agentState === 'celebrate',
detail: `state=${agE.agentState}`,
})
await sleep(3000)
// ── Test 10: task_delegated ──
bridge.pushEvent(makeEvent('task_delegated', 'agent-A', { target: 'agent-B' }))
await sleep(100)
results.push({
name: 'task_delegated-chat',
pass: agA.agentState === 'chat',
detail: `state=${agA.agentState}`,
})
bridge.pushEvent(makeEvent('delegation_done', 'agent-A', { target: 'agent-B' }))
await sleep(300)
results.push({
name: 'delegation_done-return',
pass: ['idle', 'walk', 'type'].includes(agA.agentState),
detail: `state=${agA.agentState}`,
})
// ── Test 11: message ──
const agF = scene.getAgent('agent-F')!
bridge.pushEvent(makeEvent('message_in', 'agent-F', { content_preview: 'Hello test' }))
await sleep(200)
results.push({
name: 'message_in-active',
pass: agF.isActive === true,
detail: `isActive=${agF.isActive}, state=${agF.agentState}`,
})
bridge.pushEvent(makeEvent('message_out', 'agent-F', { content_preview: 'Reply test' }))
await sleep(100)
results.push({
name: 'message_out-bubble',
pass: agF.bubbleText?.includes('Reply') === true,
detail: `bubble="${agF.bubbleText}"`,
})
// ── Test 12: subagent ──
bridge.pushEvent(makeEvent('subagent_spawn', 'subagent-test-1', { parent_agent_id: 'agent-A' }))
await sleep(200)
const sub1 = scene.getAgent('subagent-test-1')
results.push({
name: 'subagent_spawn-created',
pass: sub1 != null && sub1.isSubagent === true,
detail: `created=${!!sub1}, isSubagent=${sub1?.isSubagent}`,
})
bridge.pushEvent(makeEvent('subagent_done', 'subagent-test-1'))
await sleep(5000)
results.push({
name: 'subagent_done-removed',
pass: scene.getAgent('subagent-test-1') == null,
detail: `still exists=${!!scene.getAgent('subagent-test-1')}`,
})
// ── Test 13: task_routed ──
bridge.pushEvent(makeEvent('task_routed', 'agent-E', { method: 'auto' }))
await sleep(200)
results.push({
name: 'task_routed-active',
pass: agE.isActive === true,
detail: `isActive=${agE.isActive}, state=${agE.agentState}`,
})
// ── Test 14: agent_removed ──
bridge.pushEvent(makeEvent('agent_removed', 'agent-F'))
await sleep(5000)
results.push({
name: 'agent_removed-destroyed',
pass: scene.getAgent('agent-F') == null,
detail: `still exists=${!!scene.getAgent('agent-F')}`,
})
// ── Test 15: crystallize ──
bridge.pushEvent(makeEvent('mycelium_crystallize', 'agent-A', {
corroborating_agents: ['agent-B', 'agent-C'],
content_preview: 'Shared knowledge',
}))
await sleep(4000)
const crystalPositions = new Map<string, string>()
let crystalOverlap = false
for (const id of ['agent-A', 'agent-B', 'agent-C']) {
const agent = scene.getAgent(id)
if (!agent) continue
const pos = agent.getTilePos()
const key = `${pos.x},${pos.y}`
if (crystalPositions.has(key)) crystalOverlap = true
crystalPositions.set(key, id)
}
results.push({
name: 'crystal-no-overlap',
pass: !crystalOverlap,
detail: `3 agents, ${crystalPositions.size} unique positions`,
})
// ── Test 16: desk seat uniqueness ──
const deskSeatMap = new Map<string, string>()
let deskConflict = false
for (const agent of scene.agents.values()) {
if (!agent.seatId) continue
if (deskSeatMap.has(agent.seatId)) deskConflict = true
deskSeatMap.set(agent.seatId, agent.agentId)
}
results.push({
name: 'desk-seat-no-conflict',
pass: !deskConflict,
detail: `${deskSeatMap.size} desk seats assigned, conflict=${deskConflict}`,
})
// ── Test 17: reassign agent across offices ──
const agD = scene.getAgent('agent-D')
if (agD) {
const oldOfficeId = agD.officeId
const targetOffice = offices.find(o => o.id !== oldOfficeId) ?? offices[1]
bridge.assignAgentToOffice('agent-D', targetOffice.id)
await sleep(300)
const newOffice = agD.officeId
results.push({
name: 'reassign-office',
pass: newOffice === targetOffice.id && newOffice !== oldOfficeId,
detail: `${oldOfficeId}${newOffice} (expected ${targetOffice.id})`,
})
} else {
results.push({ name: 'reassign-office', pass: false, detail: 'agent-D not found' })
}
// ── Test 18: cross-office seat uniqueness after reassign ──
const seatMap2 = new Map<string, string>()
let conflict2 = false
for (const agent of scene.agents.values()) {
if (!agent.seatId) continue
if (seatMap2.has(agent.seatId)) conflict2 = true
seatMap2.set(agent.seatId, agent.agentId)
}
results.push({
name: 'cross-office-seat-unique',
pass: !conflict2,
detail: `${seatMap2.size} seats, conflict=${conflict2}`,
})
// ── Summary ──
const passed = results.filter(r => r.pass).length
const failed = results.filter(r => !r.pass).length
console.log('')
console.log('%c[EventTest] ═══════════════════════════════════════', 'color: #6366f1; font-weight: bold')
console.log(`%c[EventTest] Results: ${passed} passed, ${failed} failed, ${results.length} total`, `color: ${failed > 0 ? '#f87171' : '#34d399'}; font-weight: bold`)
console.log('%c[EventTest] ═══════════════════════════════════════', 'color: #6366f1; font-weight: bold')
for (const r of results) {
const icon = r.pass ? '\u2705' : '\u274C'
const color = r.pass ? 'color: #34d399' : 'color: #f87171; font-weight: bold'
console.log(`%c ${icon} ${r.name}: ${r.detail}`, color)
}
return results
}
export function registerTestRunner(bridge: GameBridge) {
(window as any).__runEventTests = () => runAllTests(bridge)
;(window as any).__bridge = bridge
console.log(
'%c[EventTest] Test runner ready. Run: window.__runEventTests()',
'color: #fbbf24; font-weight: bold',
)
}
@@ -0,0 +1,71 @@
export const AgentState = {
IDLE: 'idle',
WALK: 'walk',
TYPE: 'type',
THINK: 'think',
CELEBRATE: 'celebrate',
COFFEE: 'coffee',
CHAT: 'chat',
PRACTICE: 'practice',
REFLECT: 'reflect',
SLEEP: 'sleep',
PRESENT: 'present',
} as const
export type AgentState = (typeof AgentState)[keyof typeof AgentState]
export const Direction = {
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
UP: 'up',
} as const
export type Direction = (typeof Direction)[keyof typeof Direction]
export interface SeatDef {
id: string
tileX: number
tileY: number
facing: Direction
assigned: boolean
assignedTo: string | null
}
export interface InteractableDef {
id: string
tileX: number
tileY: number
type: string
}
export interface ZoneDef {
bounds: { x: number; y: number; w: number; h: number }
seats: SeatDef[]
interactables: InteractableDef[]
doorways: { id: string; tileX: number; tileY: number }[]
}
export interface AgentInfo {
id: string
displayName: string
state: AgentState
isActive: boolean
currentTool: string | null
seatId: string | null
urgency: 'urgent' | 'normal' | 'relaxed'
bubble: string | null
bubbleTimer: number
isSubagent: boolean
parentAgentId: string | null
palette: number
hueShift: number
taskSummary?: string
lastEventAt: number
wanderTimer: number
wanderCount: number
wanderLimit: number
seatTimer: number
stateTimer: number
myceliumEffect: 'crystal' | 'transport_send' | 'transport_recv' | 'spore_send' | 'decompose' | null
myceliumEffectTimer: number
myceliumSession: string | null
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
@@ -0,0 +1,73 @@
import { useMemo } from 'react'
import type { AgentInfo } from '../types/visual'
import type { AgentAnimStatus, KanbanTask } from '../types/kanban'
import { AGENT_STATUS_LABEL } from '../types/kanban'
interface AgentStatusBarProps {
agents: AgentInfo[]
tasks: KanbanTask[]
}
interface AgentState {
agent: AgentInfo
status: AgentAnimStatus
currentTool?: string
taskDisplayId?: string
}
export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
const agentStates = useMemo<AgentState[]>(() => {
const tasksById = new Map(tasks.map((task) => [task.id, task]))
return agents.map(agent => {
// Find the first active (non-idle) task for this agent
const activeTask = tasks.find(
t => t.assigneeIds.includes(agent.agent_id)
&& t.agentStatus && t.agentStatus !== 'idle'
)
const runtimeTask = agent.current_task_id ? tasksById.get(agent.current_task_id) : undefined
return {
agent,
status: (activeTask?.agentStatus ?? agent.runtime_status ?? 'idle') as AgentAnimStatus,
currentTool: activeTask?.currentTool ?? agent.current_tool,
taskDisplayId: activeTask?.displayId ?? runtimeTask?.displayId,
}
})
}, [agents, tasks])
if (agents.length === 0) return null
const activeCount = agentStates.filter(s => s.status !== 'idle').length
return (
<div className="agent-status-bar">
<span className="agent-status-summary">
{activeCount > 0
? `${activeCount}/${agents.length} active`
: `${agents.length} agent${agents.length !== 1 ? 's' : ''}`}
</span>
<div className="agent-status-chips">
{agentStates.map(({ agent, status, currentTool, taskDisplayId }) => (
<div
key={agent.agent_id}
className={`agent-status-chip status-${status}`}
title={`${agent.name}: ${status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]}${taskDisplayId ? ` (${taskDisplayId})` : ''}`}
>
<span className="agent-status-avatar">{agent.name.charAt(0).toUpperCase()}</span>
<span className="agent-status-name">{agent.name}</span>
{status !== 'idle' && (
<>
<span className="kanban-runtime-dot" />
<span className="agent-status-detail">
{status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]}
</span>
</>
)}
{taskDisplayId && (
<span className="agent-status-task">{taskDisplayId}</span>
)}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import type { KanbanBoard } from '../types/kanban'
interface BoardSelectorProps {
boards: KanbanBoard[]
activeBoardId: string | null
onSelect: (id: string) => void
}
export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelectorProps) {
return (
<div className="board-selector">
<div className="board-tabs">
{boards.map(b => (
<button
key={b.id}
className={`board-tab${b.id === activeBoardId ? ' active' : ''}`}
style={{ '--board-color': b.color } as React.CSSProperties}
onClick={() => onSelect(b.id)}
>
<span className="board-tab-dot" style={{ background: b.color }} />
{b.name}
</button>
))}
</div>
</div>
)
}
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict'
/**
* Regression tests for BoardStore selection behavior.
*
* Bug history:
* - Original: BoardStore auto-selected boards[0] whenever activeBoardId was
* null. Combined with the parent's session-driven clear, this created an
* infinite toggle loop (screen flicker).
* - Previous fix: limited auto-select to boards.length === 1 — but in
* company mode with exactly 1 session (1 board) this STILL flickered
* when no session was selected.
* - Current fix: BoardStore does NOT auto-select at all. Selection is
* entirely driven by the parent (WorkspacePage) which knows the mode.
* initFromBackend only clears when the prior selection disappears.
*/
// initFromBackend: only preserve-or-clear, no auto-default
function resolveActiveBoardAfterInit(
prev: string | null,
bds: { id: string }[],
): string | null {
return prev && bds.some(b => b.id === prev) ? prev : null
}
// Parent-driven selection (simulates WorkspacePage useEffect)
function parentChooseBoard(opts: {
isCompanyMode: boolean
activeSessionBoardId: string | null
boards: { id: string }[]
currentActive: string | null
}): string | null {
const { isCompanyMode, activeSessionBoardId, boards, currentActive } = opts
const hasId = (id: string | null) => !!id && boards.some(b => b.id === id)
if (isCompanyMode) {
if (activeSessionBoardId && hasId(activeSessionBoardId)) return activeSessionBoardId
return null
}
if (currentActive && hasId(currentActive)) return currentActive
return boards.length > 0 ? boards[0].id : null
}
// ── initFromBackend ──────────────────────────────────────────────────────
// Single board: do NOT auto-select (parent picks)
assert.strictEqual(
resolveActiveBoardAfterInit(null, [{ id: 'project-board' }]),
null,
'init with null prev stays null even with 1 board',
)
// Preserve valid prior
assert.strictEqual(
resolveActiveBoardAfterInit('session-a', [{ id: 'session-a' }, { id: 'session-b' }]),
'session-a',
'preserves existing active when still present',
)
// Clear stale
assert.strictEqual(
resolveActiveBoardAfterInit('deleted', [{ id: 'session-a' }]),
null,
'clears stale active',
)
// Empty
assert.strictEqual(
resolveActiveBoardAfterInit(null, []),
null,
'empty boards → null',
)
// ── Parent-driven selection ──────────────────────────────────────────────
// Company mode: no session → null (shows empty state)
assert.strictEqual(
parentChooseBoard({
isCompanyMode: true,
activeSessionBoardId: null,
boards: [{ id: 'session-a' }],
currentActive: null,
}),
null,
'company mode + no session → null',
)
// Company mode: session selected, its board exists → select it
assert.strictEqual(
parentChooseBoard({
isCompanyMode: true,
activeSessionBoardId: 'session-a',
boards: [{ id: 'session-a' }, { id: 'session-b' }],
currentActive: null,
}),
'session-a',
'company mode + session with board → select session board',
)
// Company mode: session selected but its board doesn't exist yet → null
assert.strictEqual(
parentChooseBoard({
isCompanyMode: true,
activeSessionBoardId: 'new-session',
boards: [{ id: 'session-a' }],
currentActive: null,
}),
null,
'company mode + new session (no board yet) → null (empty state)',
)
// Non-company mode: auto-select project board
assert.strictEqual(
parentChooseBoard({
isCompanyMode: false,
activeSessionBoardId: null,
boards: [{ id: 'project-board' }],
currentActive: null,
}),
'project-board',
'non-company mode → auto-select project board',
)
// ── Flicker regression: 1-session company mode, no session selected ──────
// The original bug: boards.length===1 triggered auto-select, parent cleared
// → loop. With the current contract, BOTH init and parent agree on null.
{
const boards = [{ id: 'session-a' }]
const afterInit = resolveActiveBoardAfterInit(null, boards)
assert.strictEqual(afterInit, null, 'init: null with 1 board stays null')
const afterParent = parentChooseBoard({
isCompanyMode: true,
activeSessionBoardId: null,
boards,
currentActive: afterInit,
})
assert.strictEqual(afterParent, null, 'parent: 1-session company + no active → null (stable, no loop)')
}
console.log('BoardStore selection contract passed')
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import type { KanbanBoard, KanbanColumn, KanbanTask, TaskPriority } from '../types/kanban'
import { deriveColumnFromPhase } from '../lib/phaseHelpers'
function uid(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
}
type BoardAction =
| { type: 'SET'; boards: KanbanBoard[] }
| { type: 'UPDATE_NAME'; boardId: string; name: string }
function boardReducer(state: KanbanBoard[], action: BoardAction): KanbanBoard[] {
switch (action.type) {
case 'SET': return action.boards
case 'UPDATE_NAME':
return state.map(b => b.id === action.boardId ? { ...b, name: action.name } : b)
default: return state
}
}
type ColumnAction =
| { type: 'SET'; columns: KanbanColumn[] }
function columnReducer(state: KanbanColumn[], action: ColumnAction): KanbanColumn[] {
switch (action.type) {
case 'SET': return action.columns
default: return state
}
}
type TaskAction =
| { type: 'SET'; tasks: KanbanTask[] }
| { type: 'ADD'; task: KanbanTask }
| { type: 'UPDATE'; id: string; partial: Partial<KanbanTask> }
| { type: 'DELETE'; id: string }
| { type: 'MOVE'; id: string; columnId: string; sortOrder: number }
| { type: 'ASSIGN'; taskId: string; agentIds: string[] }
| { type: 'REMOVE_ASSIGNEE'; agentId: string }
function taskReducer(state: KanbanTask[], action: TaskAction): KanbanTask[] {
const now = Date.now()
switch (action.type) {
case 'SET': return action.tasks
case 'ADD': return state.some(t => t.id === action.task.id) ? state : [...state, action.task]
case 'UPDATE': return state.map(t => t.id === action.id ? { ...t, ...action.partial, updatedAt: now } : t)
case 'DELETE': return state.filter(t => t.id !== action.id)
case 'MOVE': return state.map(t => t.id === action.id ? { ...t, columnId: action.columnId, sortOrder: action.sortOrder, updatedAt: now } : t)
case 'ASSIGN': return state.map(t => t.id === action.taskId ? { ...t, assigneeIds: action.agentIds, updatedAt: now } : t)
case 'REMOVE_ASSIGNEE': return state.map(t =>
t.assigneeIds.includes(action.agentId)
? { ...t, assigneeIds: t.assigneeIds.filter(a => a !== action.agentId), updatedAt: now }
: t
)
default: return state
}
}
export interface BoardStoreState {
scopeProjectId: string
boards: KanbanBoard[]
columns: KanbanColumn[]
tasks: KanbanTask[]
activeBoardId: string | null
activeBoard: KanbanBoard | null
activeBoardColumns: KanbanColumn[]
tasksByColumn: Record<string, KanbanTask[]>
setActiveBoard: (boardId: string | null) => void
createTask: (opts: { boardId: string; columnId: string; title: string; description?: string; priority?: TaskPriority | null; assigneeIds?: string[]; tags?: string[]; taskId?: string; displayId?: string }) => KanbanTask
updateTask: (id: string, partial: Partial<KanbanTask>) => void
deleteTask: (id: string) => void
moveTask: (id: string, columnId: string, sortOrder: number) => void
assignTask: (taskId: string, agentIds: string[]) => void
dispatchTask: (action: TaskAction) => void
getOpenTaskCount: () => number
removeAssignee: (agentId: string) => void
initFromBackend: (
projectId: string,
boards: KanbanBoard[],
columns: KanbanColumn[],
tasks: KanbanTask[],
options?: { preserveTasksWhenIncomingEmpty?: boolean },
) => void
updateBoardName: (boardId: string, name: string) => void
}
export function useBoardStore(): BoardStoreState {
const [boards, dispatchBoard] = useReducer(boardReducer, [])
const [columns, dispatchCol] = useReducer(columnReducer, [])
const [tasks, dispatchTask] = useReducer(taskReducer, [])
const [activeBoardId, setActiveBoardId] = useState<string | null>(null)
const [scopeProjectId, setScopeProjectId] = useState<string>('default')
// NOTE: no auto-select logic here. Board selection is driven entirely by
// the parent (WorkspacePage) which knows the execution mode:
// - Non-company mode → 1 project board, parent sets it once.
// - Company mode → 1 board per session, parent syncs to activeSession.
// Having BoardStore auto-select to boards[0] would race with the parent's
// session-driven clear, producing a render loop (screen flicker).
const activeBoard = useMemo(() => boards.find(b => b.id === activeBoardId) ?? null, [boards, activeBoardId])
const activeBoardColumns = useMemo(() =>
columns.filter(c => c.boardId === activeBoardId).sort((a, b) => a.sortOrder - b.sortOrder),
[columns, activeBoardId]
)
// All tasks for active board, sorted by column.
//
// Column placement: prefer deriving from `phase` (the authoritative
// single-source-of-truth field from the backend) and fall back to the
// backend-supplied `columnId` only when phase is missing. During the
// transition window both fields should agree — in dev mode we warn
// loudly when they don't so the drift is caught immediately.
const tasksByColumn = useMemo(() => {
const boardTasks = tasks.filter(t => t.boardId === activeBoardId)
const map: Record<string, KanbanTask[]> = {}
for (const col of activeBoardColumns) map[col.id] = []
for (const t of boardTasks) {
const derived = t.phase ? deriveColumnFromPhase(t.phase) : t.columnId
if (import.meta.env.DEV && t.phase && t.columnId && derived !== t.columnId) {
// eslint-disable-next-line no-console
console.warn(
`[phase/columnId drift] task=${t.id} phase=${t.phase} derived=${derived} backendColumn=${t.columnId}`,
)
}
if (map[derived]) map[derived].push(t)
}
for (const key of Object.keys(map)) {
map[key].sort((a, b) => a.sortOrder - b.sortOrder)
}
return map
}, [tasks, activeBoardId, activeBoardColumns])
const createTask = useCallback((opts: {
boardId: string; columnId: string; title: string;
description?: string; priority?: TaskPriority | null;
assigneeIds?: string[]; tags?: string[];
taskId?: string; displayId?: string
}) => {
const board = boards.find(b => b.id === opts.boardId)
const num = board?.nextTaskNum ?? 1
const prefix = board?.prefix ?? 'T'
const task: KanbanTask = {
id: opts.taskId ?? `task-${uid()}`,
displayId: opts.displayId ?? `${prefix}-${String(num).padStart(3, '0')}`,
boardId: opts.boardId,
columnId: opts.columnId,
title: opts.title,
description: opts.description,
priority: opts.priority ?? null,
assigneeIds: opts.assigneeIds ?? [],
tags: opts.tags ?? [],
sortOrder: num,
createdAt: Date.now(),
updatedAt: Date.now(),
}
dispatchTask({ type: 'ADD', task })
return task
}, [boards])
const updateTask = useCallback((id: string, partial: Partial<KanbanTask>) => dispatchTask({ type: 'UPDATE', id, partial }), [])
const deleteTask = useCallback((id: string) => dispatchTask({ type: 'DELETE', id }), [])
const moveTask = useCallback((id: string, columnId: string, sortOrder: number) => dispatchTask({ type: 'MOVE', id, columnId, sortOrder }), [])
const assignTask = useCallback((taskId: string, agentIds: string[]) => dispatchTask({ type: 'ASSIGN', taskId, agentIds }), [])
const getOpenTaskCount = useCallback(() => {
const terminalColIds = new Set(columns.filter(c => c.isTerminal).map(c => c.id))
return tasks.filter(t => !terminalColIds.has(t.columnId)).length
}, [tasks, columns])
const removeAssignee = useCallback((agentId: string) => {
dispatchTask({ type: 'REMOVE_ASSIGNEE', agentId })
}, [])
const initFromBackend = useCallback((
projectId: string,
bds: KanbanBoard[],
cols: KanbanColumn[],
tks: KanbanTask[],
options?: { preserveTasksWhenIncomingEmpty?: boolean },
) => {
const nextProjectId = projectId || 'default'
const projectChanged = nextProjectId !== scopeProjectId
const shouldPreserveTasks =
!projectChanged
&& !!options?.preserveTasksWhenIncomingEmpty
&& tks.length === 0
setScopeProjectId(nextProjectId)
dispatchBoard({ type: 'SET', boards: bds })
dispatchCol({ type: 'SET', columns: cols })
dispatchTask({ type: 'SET', tasks: shouldPreserveTasks ? tasks : tks })
// Only reset activeBoardId when the current selection no longer exists.
// Otherwise preserve the parent's choice. Never auto-default to boards[0]
// here — the parent decides based on execution mode.
setActiveBoardId(prev => (!projectChanged && prev && bds.some(b => b.id === prev) ? prev : null))
}, [scopeProjectId, tasks])
const setActiveBoard = useCallback((boardId: string | null) => {
setActiveBoardId(boardId)
}, [])
const updateBoardName = useCallback((boardId: string, name: string) => {
dispatchBoard({ type: 'UPDATE_NAME', boardId, name })
}, [])
return useMemo(() => ({
scopeProjectId, boards, columns, tasks, activeBoardId, activeBoard, activeBoardColumns,
tasksByColumn,
setActiveBoard,
createTask, updateTask, deleteTask, moveTask, assignTask,
dispatchTask, getOpenTaskCount,
removeAssignee, initFromBackend, updateBoardName,
}), [
scopeProjectId, boards, columns, tasks, activeBoardId, activeBoard, activeBoardColumns,
tasksByColumn,
setActiveBoard,
createTask, updateTask, deleteTask, moveTask, assignTask,
dispatchTask, getOpenTaskCount,
removeAssignee, initFromBackend, updateBoardName,
])
}
@@ -0,0 +1,297 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
ProgressEntry,
RoleAggregatedStatus,
RoleWorkItemActivitySection,
RoleWorkItemRow,
RoleWorkItemSummary,
} from '../types/kanban'
import type { AgentInfo } from '../types/visual'
import { AgentProgressBlock } from '../chat/AgentProgressBlock'
import { IconClose, IconTimeline, IconWorkItem } from '../chat/SvgIcons'
interface ExecutionPanelProps {
role: RoleWorkItemSummary
focusedWorkItemId?: string
focusedExecutionTurnId?: string
agents: AgentInfo[]
onClose: () => void
}
const ROLE_STATUS_BADGE: Record<RoleAggregatedStatus, { label: string; cls: string }> = {
active: { label: 'Working', cls: 'exec-badge-running' },
waiting: { label: 'Waiting', cls: 'exec-badge-idle' },
pending: { label: 'Pending', cls: 'exec-badge-pending' },
done: { label: 'Done', cls: 'exec-badge-done' },
failed: { label: 'Failed', cls: 'exec-badge-failed' },
}
const ROW_STATUS_BADGE: Record<string, { label: string; cls: string }> = {
todo: { label: 'To do', cls: 'exec-badge-pending' },
'in-progress': { label: 'In progress', cls: 'exec-badge-running' },
'in-review': { label: 'In review', cls: 'exec-badge-idle' },
done: { label: 'Done', cls: 'exec-badge-done' },
failed: { label: 'Failed', cls: 'exec-badge-failed' },
cancelled: { label: 'Cancelled', cls: 'exec-badge-cancelled' },
}
function formatRelativeTime(ts: number): string {
const sec = Math.floor((Date.now() - ts) / 1000)
if (sec < 5) return 'now'
if (sec < 60) return `${sec}s ago`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ago`
return `${Math.floor(hr / 24)}d ago`
}
function humanize(value?: string): string {
const text = String(value ?? '').trim()
if (!text) return ''
return text.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function rowBadge(row: RoleWorkItemRow): { label: string; cls: string } {
if (row.phase === 'failed') return ROW_STATUS_BADGE.failed
if (row.phase === 'cancelled') return ROW_STATUS_BADGE.cancelled
return ROW_STATUS_BADGE[row.kanbanColumn] ?? ROW_STATUS_BADGE.todo
}
function rowSessionStatus(row: RoleWorkItemRow): string | undefined {
if (row.phase === 'failed') return 'failed'
if (row.phase === 'cancelled') return 'cancelled'
if (row.kanbanColumn === 'done') return 'done'
return undefined
}
function countActivityEntries(row: RoleWorkItemRow): number {
const sections = row.activitySections ?? []
if (sections.length > 0) {
return sections.reduce((count, section) => count + (section.entries?.length ?? 0), 0)
}
return row.progressLog.length
}
function ActivitySections({
sections,
fallbackEntries,
sessionStatus,
}: {
sections?: RoleWorkItemActivitySection[]
fallbackEntries?: ProgressEntry[]
sessionStatus?: string
}) {
const visibleSections = (sections ?? []).filter(section => (
(section.entries?.length ?? 0) > 0 || !!section.runtimeTaskId
))
if (visibleSections.length > 0) {
return (
<div className="exec-activity-sections">
{visibleSections.map((section, index) => {
const entries = section.entries ?? []
const key = `${section.runtimeTaskId || section.kind}:${index}`
return (
<section key={key} className="exec-activity-section">
<div className="exec-activity-section-head">
<span className="exec-activity-section-title">{section.title}</span>
{section.roleName && (
<span className="exec-activity-section-role">{section.roleName}</span>
)}
{entries.length > 0 && (
<span className="exec-section-count">{entries.length}</span>
)}
</div>
{entries.length > 0 ? (
<AgentProgressBlock
entries={entries}
sessionStatus={sessionStatus}
expandedByDefault
/>
) : (
<div className="exec-section-empty">No runtime activity yet</div>
)}
</section>
)
})}
</div>
)
}
if (!fallbackEntries || fallbackEntries.length === 0) {
return <div className="exec-section-empty">No activity recorded yet</div>
}
return (
<AgentProgressBlock
entries={fallbackEntries}
sessionStatus={sessionStatus}
expandedByDefault
/>
)
}
export function ExecutionPanel({
role,
focusedWorkItemId,
focusedExecutionTurnId,
agents,
onClose,
}: ExecutionPanelProps) {
const rows = useMemo(() => (
role.workItems
.slice()
.sort((a, b) => a.createdAt - b.createdAt)
), [role.workItems])
const focused = useMemo(() => (
rows.find(row => (
(!!focusedWorkItemId && row.workItemId === focusedWorkItemId)
|| (!!focusedExecutionTurnId && row.executionTurnId === focusedExecutionTurnId)
)) ?? rows[rows.length - 1] ?? null
), [focusedExecutionTurnId, focusedWorkItemId, rows])
const focusedRowKey = focused?.workItemId
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => {
const init = new Set<string>()
if (focusedRowKey) init.add(focusedRowKey)
return init
})
const autoExpandedRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!focusedRowKey || autoExpandedRef.current.has(focusedRowKey)) return
autoExpandedRef.current.add(focusedRowKey)
setExpandedIds(prev => {
if (prev.has(focusedRowKey)) return prev
const next = new Set(prev)
next.add(focusedRowKey)
return next
})
}, [focusedRowKey])
const toggleExpanded = useCallback((workItemId: string) => {
setExpandedIds(prev => {
const next = new Set(prev)
if (next.has(workItemId)) next.delete(workItemId)
else next.add(workItemId)
return next
})
}, [])
const focusedCardRef = useRef<HTMLDivElement | null>(null)
const lastScrolledIdRef = useRef<string | null>(null)
useEffect(() => {
if (!focusedRowKey || lastScrolledIdRef.current === focusedRowKey) return
lastScrolledIdRef.current = focusedRowKey
focusedCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [focusedRowKey])
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
const roleAgent = agents.find(agent => agent.agent_id === role.roleId)
const roleName = role.roleName || roleAgent?.name || humanize(role.roleId) || 'Role'
const headerBadge = ROLE_STATUS_BADGE[role.aggregatedStatus] ?? ROLE_STATUS_BADGE.pending
return (
<>
<div className="exec-panel-backdrop" onClick={onClose} />
<div className="exec-panel">
<div className="exec-panel-header">
<div className="exec-panel-title-row">
<IconTimeline />
<h3 className="exec-panel-title">{roleName}</h3>
<span className={`exec-badge ${headerBadge.cls}`}>{headerBadge.label}</span>
<span className="exec-panel-task-count">
{rows.length} Work Item{rows.length === 1 ? '' : 's'}
</span>
</div>
<button className="exec-panel-close" onClick={onClose} title="Close (Esc)">
<IconClose />
</button>
</div>
<div className="exec-panel-identity">
<div className="exec-panel-avatar">
{roleName.charAt(0).toUpperCase()}
</div>
<div className="exec-panel-agent-info">
<span className="exec-panel-agent-name">{roleName}</span>
<span className="exec-panel-agent-role">{humanize(role.roleId)}</span>
{role.roleSessionId && (
<span className="exec-panel-employee">{role.roleSessionId}</span>
)}
</div>
</div>
<div className="exec-panel-body">
{rows.length === 0 && (
<div className="exec-section-empty">No work items yet</div>
)}
{rows.map((row, index) => {
const isFocused = row.workItemId === focusedRowKey
const isExpanded = expandedIds.has(row.workItemId)
const badge = rowBadge(row)
const activityCount = countActivityEntries(row)
return (
<div
key={row.workItemId}
ref={isFocused ? focusedCardRef : null}
className={`exec-task-card${isFocused ? ' exec-task-card-focused' : ''}`}
>
<button
type="button"
className="exec-task-card-header"
onClick={() => toggleExpanded(row.workItemId)}
title={isExpanded ? 'Collapse' : 'Expand'}
>
<span className="exec-task-card-index">Work item #{index + 1}</span>
<span className="exec-task-card-title">{row.title || row.workItemId}</span>
<span className={`exec-badge ${badge.cls}`}>{badge.label}</span>
<span className="exec-task-card-time">{formatRelativeTime(row.updatedAt)}</span>
<span className={`exec-task-card-chevron${isExpanded ? ' open' : ''}`} aria-hidden="true">
</span>
</button>
{isExpanded && (
<div className="exec-task-card-body">
<div className="exec-task-card-projection">
<IconWorkItem />
<span>{humanize(row.kind) || 'Work item'}</span>
{row.workItemProjectionId && <code>{row.workItemProjectionId}</code>}
{row.executionTurnId && <span className="exec-inline-tag">Execution Turn</span>}
{row.isReviewTarget && <span className="exec-inline-tag">Review target</span>}
{row.executorRoleName && <span>{row.executorRoleName}</span>}
</div>
<div className="exec-section">
<div className="exec-section-header">
<IconTimeline />
<span>Activity</span>
<span className="exec-section-count">{activityCount}</span>
</div>
<div className="exec-section-content exec-activity-scroll">
<ActivitySections
sections={row.activitySections}
fallbackEntries={row.progressLog}
sessionStatus={rowSessionStatus(row)}
/>
</div>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
</>
)
}
@@ -0,0 +1,69 @@
import { DragDropContext, type DropResult } from '@hello-pangea/dnd'
import type { AgentInfo } from '../types/visual'
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban'
import type { BoardStoreState } from './BoardStore'
import { KanbanColumn } from './KanbanColumn'
interface KanbanBoardViewProps {
columns: KanbanColumnType[]
tasksByColumn: Record<string, KanbanTask[]>
agents: AgentInfo[]
officeMap?: Record<string, string>
store: BoardStoreState
companyMode?: boolean
selectedTaskId?: string | null
onCardClick: (task: KanbanTask) => void
onStartTask?: (taskId: string) => void
onQuickCreate?: (title: string) => void
onMoveTask?: (taskId: string, columnId: string) => void
}
export function KanbanBoardView({
columns, tasksByColumn, agents, officeMap, store, companyMode, selectedTaskId, onCardClick, onStartTask, onQuickCreate, onMoveTask,
}: KanbanBoardViewProps) {
const handleDragEnd = (result: DropResult) => {
if (companyMode) return
if (!result.destination) return
const srcColId = result.source.droppableId
const destColId = result.destination.droppableId
if (srcColId !== destColId) {
// All column transitions are automatic (driven by backend status).
// No manual drag between columns.
return
}
// Same-column reorder — compute new sort orders atomically
const destIndex = result.destination.index
const taskId = result.draggableId
const ordered = [...(tasksByColumn[destColId] ?? [])].filter(t => t.id !== taskId)
const draggedTask = (tasksByColumn[destColId] ?? []).find(t => t.id === taskId)
if (draggedTask) ordered.splice(destIndex, 0, draggedTask)
ordered.forEach((t, i) => {
store.moveTask(t.id, destColId, i)
})
}
return (
<DragDropContext onDragEnd={handleDragEnd}>
<div className="kanban-board">
{columns.map(col => (
<KanbanColumn
key={col.id}
column={col}
tasks={tasksByColumn[col.id] ?? []}
agents={agents}
officeMap={officeMap}
companyMode={companyMode}
selectedTaskId={selectedTaskId}
onCardClick={onCardClick}
onStartTask={!companyMode && col.name === 'Todo' ? onStartTask : undefined}
onQuickCreate={!companyMode && col.name === 'Todo' ? onQuickCreate : undefined}
/>
))}
</div>
</DragDropContext>
)
}
@@ -0,0 +1,187 @@
import { Draggable } from '@hello-pangea/dnd'
import type { AgentInfo } from '../types/visual'
import { PRIORITY_META, AGENT_STATUS_LABEL, type KanbanTask } from '../types/kanban'
import { getWorkItemRoleLabel, humanizeWorkItemRoleId } from '../lib/workItemIdentity'
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
todo: { label: 'To do', color: '#9ca3af' },
in_progress: { label: 'In progress', color: '#f59e0b' },
in_review: { label: 'In review', color: '#fbbf24' },
done: { label: 'Done', color: '#34d399' },
running: { label: 'Running', color: '#34d399' },
idle: { label: 'Idle', color: '#6366f1' },
blocked: { label: 'Blocked', color: '#f97316' },
awaiting_peer: { label: 'Awaiting', color: '#fbbf24' },
awaiting_manager_review: { label: 'Mgr Review', color: '#fbbf24' },
awaiting_human: { label: 'Human Review', color: '#fbbf24' },
awaiting_review: { label: 'In Review', color: '#fbbf24' },
failed: { label: 'Failed', color: '#ef4444' },
cancelled: { label: 'Cancelled', color: '#9ca3af' },
}
interface KanbanCardProps {
task: KanbanTask
index: number
agents: AgentInfo[]
officeMap?: Record<string, string>
companyMode?: boolean
isSelected?: boolean
onClick: (task: KanbanTask) => void
onStart?: (taskId: string) => void
}
export function KanbanCard({ task, index, agents, officeMap, companyMode, isSelected, onClick, onStart }: KanbanCardProps) {
const assignees = task.assigneeIds
.map(id => agents.find(a => a.agent_id === id))
.filter(Boolean) as AgentInfo[]
const priority = task.priority ? PRIORITY_META[task.priority] : null
const crossOffice = officeMap && assignees.length > 1 &&
new Set(assignees.map(a => officeMap[a.agent_id]).filter(Boolean)).size > 1
const runtimeActive = task.agentStatus && task.agentStatus !== 'idle'
const depCount = task.dependencies?.length ?? 0
// Hide status badge when runtime bar is showing (avoids "Running" + "Thinking..." redundancy)
// Also hide for 'pending' (default state, no badge needed in todo column)
const phaseBadge = task.phase
const statusBadge = (!runtimeActive && phaseBadge && phaseBadge !== 'ready')
? STATUS_BADGE[phaseBadge] ?? null : null
const employee = task.employeeAssignment
const roleLabel = getWorkItemRoleLabel(task)
const gate = task.workItemGate
const managerLabel = humanizeWorkItemRoleId(task.managerRoleId)
const blockerLabel = (task.blockedReason ?? '').trim()
const reworkLabel = (task.reworkFeedback ?? '').trim()
const linkedRuntimeTaskId = getLinkedRuntimeTaskId(task)
return (
<Draggable draggableId={task.id} index={index} isDragDisabled={!!companyMode}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={`kanban-card${snapshot.isDragging ? ' is-dragging' : ''}${runtimeActive ? ' is-active' : ''}${isSelected ? ' is-selected' : ''}`}
data-task-id={task.id}
onMouseUp={e => { if (e.button === 0 && !snapshot.isDragging) onClick(task) }}
>
<div className="kanban-card-top">
<span className="kanban-card-id">{task.displayId}</span>
{statusBadge && (
<span className="kanban-status-badge" style={{ color: statusBadge.color }}>
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: statusBadge.color, marginRight: 3 }} />
{statusBadge.label}
</span>
)}
{depCount > 0 && (
<span className="kanban-dep-badge" title={`${depCount} upstream dep(s)`}>{depCount} dep</span>
)}
{crossOffice && <span className="kanban-cross-badge" title="Cross-office">&#x21C4;</span>}
{onStart && (
<button
className="kanban-start-btn"
title={companyMode ? 'Start Work Item' : 'Start task'}
onClick={e => { e.stopPropagation(); onStart(task.id) }}
>
&#x25B6;
</button>
)}
</div>
<p className="kanban-card-title">{task.title}</p>
{(roleLabel || employee?.name || gate?.type || task.originChannel || task.workItemProjectionId) && (
<div className="kanban-card-meta-row">
{roleLabel && (
<span className="kanban-meta-badge kanban-role-badge" title={`Role: ${roleLabel}`}>
{roleLabel}
</span>
)}
{employee?.name && (
<span className="kanban-meta-badge kanban-employee-badge" title={`Employee: ${employee.name}${employee.category ? ` (${employee.category})` : ''}`}>
<span className="kanban-meta-icon">&#x1F464;</span>
{employee.name}
</span>
)}
{task.workItemProjectionId && (
<span className="kanban-meta-badge kanban-projection-badge" title={`Projection: ${task.workItemProjectionId}`}>
{task.workItemProjectionId}
</span>
)}
{gate?.type && (
<span className={`kanban-meta-badge kanban-gate-badge kanban-gate-${gate.type}`} title={`Gate: ${gate.type}${gate.reviewerRole ? ` by ${gate.reviewerRole}` : ''}`}>
{gate.type === 'review' ? '\u2709' : gate.type === 'approval' ? '\u2713' : '\u270B'}
{gate.type}
</span>
)}
{task.originChannel && (
<span className="kanban-meta-badge kanban-origin-badge" title={`Origin: ${task.originChannel}`}>
#{task.originChannel}
</span>
)}
{managerLabel && (
<span className="kanban-meta-badge" title={`Manager: ${managerLabel}`}>
{managerLabel}
</span>
)}
</div>
)}
{(blockerLabel || reworkLabel || linkedRuntimeTaskId) && (
<div className="kanban-card-tags">
{blockerLabel && <span className="kanban-tag">{blockerLabel}</span>}
{reworkLabel && <span className="kanban-tag">{reworkLabel}</span>}
{linkedRuntimeTaskId && (
<span className="kanban-tag" title={`Execution Turn: ${linkedRuntimeTaskId}`}>
Runtime
</span>
)}
</div>
)}
{runtimeActive && (
<div className={`kanban-card-runtime status-${task.agentStatus}`}>
<span className="kanban-runtime-dot" />
<span className="kanban-runtime-label">
{task.agentStatus === 'tool_active' && task.currentTool
? task.currentTool
: AGENT_STATUS_LABEL[task.agentStatus!] ?? task.agentStatus}
</span>
</div>
)}
{task.tags.length > 0 && (
<div className="kanban-card-tags">
{task.tags.slice(0, 3).map(tag => (
<span key={tag} className="kanban-tag">{tag}</span>
))}
</div>
)}
{(priority || assignees.length > 0) && (
<div className="kanban-card-footer">
<div className="kanban-card-footer-left">
{priority && (
<span className="kanban-priority" style={{ color: priority.color }} title={priority.label}>
{priority.symbol}
</span>
)}
</div>
<div className="kanban-assignee-group">
{assignees.slice(0, 3).map(a => (
<span key={a.agent_id} className="kanban-assignee-badge" title={a.name}>
{a.name.charAt(0).toUpperCase()}
</span>
))}
{assignees.length > 3 && (
<span className="kanban-assignee-badge kanban-assignee-more">+{assignees.length - 3}</span>
)}
</div>
</div>
)}
</div>
)}
</Draggable>
)
}
@@ -0,0 +1,88 @@
import { useCallback, useRef, useState } from 'react'
import { Droppable } from '@hello-pangea/dnd'
import type { AgentInfo } from '../types/visual'
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban'
import { KanbanCard } from './KanbanCard'
interface KanbanColumnProps {
column: KanbanColumnType
tasks: KanbanTask[]
agents: AgentInfo[]
officeMap?: Record<string, string>
companyMode?: boolean
selectedTaskId?: string | null
onCardClick: (task: KanbanTask) => void
onStartTask?: (taskId: string) => void
onQuickCreate?: (title: string) => void
}
export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, selectedTaskId, onCardClick, onStartTask, onQuickCreate }: KanbanColumnProps) {
const [adding, setAdding] = useState(false)
const [draft, setDraft] = useState('')
const committedRef = useRef(false)
const commitAdd = useCallback(() => {
if (committedRef.current) return // guard: prevent double-fire from Enter + onBlur
committedRef.current = true
const title = draft.trim()
if (title && onQuickCreate) {
onQuickCreate(title)
}
setDraft('')
setAdding(false)
}, [draft, onQuickCreate])
return (
<div className="kanban-column">
<div className="kanban-column-header">
<span className="kanban-col-dot" style={{ background: column.color }} />
<span className="kanban-col-label">{column.name}</span>
<span className="kanban-col-count">{tasks.length}</span>
{onQuickCreate && (
<button className="kanban-col-add" title="Add task" onClick={() => { committedRef.current = false; setAdding(true) }}>+</button>
)}
</div>
{adding && (
<div className="kanban-quick-add">
<input
className="kanban-quick-input"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); commitAdd() }
if (e.key === 'Escape') { committedRef.current = true; setDraft(''); setAdding(false) }
}}
onBlur={commitAdd}
placeholder="Task title..."
autoFocus
/>
</div>
)}
<Droppable droppableId={column.id} isDropDisabled={!!companyMode}>
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps} className="kanban-col-body">
{tasks.length === 0 && !adding && (
<div className="kanban-empty"><span className="kanban-empty-icon">·</span></div>
)}
{tasks.map((task, index) => (
<KanbanCard
key={task.id}
task={task}
index={index}
agents={agents}
officeMap={officeMap}
companyMode={companyMode}
isSelected={task.id === selectedTaskId}
onClick={onCardClick}
onStart={onStartTask}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,715 @@
/**
* collabSync — translates backend collaboration data format to frontend store types.
*
* Backend uses snake_case; frontend uses camelCase. This module bridges the two.
*/
import type { ChatChannel, ChatMessage, ChannelType } from '../types/chat'
import type { KanbanBoard, KanbanColumn, KanbanPhase, KanbanTask, TaskPriority, AgentAnimStatus, ProgressEntry, Session, SessionMode, Project, EmployeeAssignment, RoleAggregatedStatus, RoleWorkItemActivitySection, RoleWorkItemRow, RoleWorkItemSummary, WorkItemGate, WorkItemProgressEntry } from '../types/kanban'
import { normalizeProgressLog, normalizeWorkItemLog } from './progressLog'
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
/* eslint-disable @typescript-eslint/no-explicit-any */
const UNIX_MS_THRESHOLD = 1_000_000_000_000
const WORK_ITEM_EVENT_RE = /^\[Company:([^\]]+)\]\s*(.*)$/
const COMPANY_RUNTIME_EVENT_RE = /^\[Company\]\s*(.*)$/
function normalizeAgentRuntimeStatus(rawStatus: unknown, rawAgentStatus: unknown): AgentAnimStatus | undefined {
if (rawAgentStatus === 'idle' || rawAgentStatus === 'reflecting' || rawAgentStatus === 'tool_active') {
return rawAgentStatus
}
return rawStatus === 'running' ? 'reflecting' : undefined
}
function normalizeEpochMs(raw: unknown): number {
const value = typeof raw === 'string' ? Number(raw) : raw
if (typeof value !== 'number' || !Number.isFinite(value)) return Date.now()
return value < UNIX_MS_THRESHOLD ? value * 1000 : value
}
function mapBackendProgressLog(raw: any): ProgressEntry[] {
if (!Array.isArray(raw)) return []
return normalizeProgressLog(raw
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry) => ({
timestamp: normalizeEpochMs(entry.timestamp),
type: (entry.type ?? 'status_change') as ProgressEntry['type'],
summary: typeof entry.summary === 'string' ? entry.summary : '',
detail: typeof entry.detail === 'string' ? entry.detail : undefined,
turnId: typeof entry.turn_id === 'string'
? entry.turn_id
: typeof entry.turnId === 'string'
? entry.turnId
: undefined,
itemId: typeof entry.item_id === 'string'
? entry.item_id
: typeof entry.itemId === 'string'
? entry.itemId
: undefined,
streamId: typeof entry.stream_id === 'string'
? entry.stream_id
: typeof entry.streamId === 'string'
? entry.streamId
: undefined,
seq: typeof entry.seq === 'number' && Number.isFinite(entry.seq) ? entry.seq : undefined,
executionMode: typeof entry.execution_mode === 'string'
? entry.execution_mode
: typeof entry.executionMode === 'string'
? entry.executionMode
: undefined,
})))
}
function mapBackendWorkItemLog(raw: any): WorkItemProgressEntry[] {
if (!Array.isArray(raw)) return []
return normalizeWorkItemLog(raw
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry) => {
const runtimeTaskId = typeof entry.runtime_task_id === 'string'
? entry.runtime_task_id
: typeof entry.runtimeTaskId === 'string'
? entry.runtimeTaskId
: typeof entry.execution_turn_id === 'string'
? entry.execution_turn_id
: typeof entry.executionTurnId === 'string'
? entry.executionTurnId
: undefined
const executionTurnId = typeof entry.execution_turn_id === 'string'
? entry.execution_turn_id
: typeof entry.executionTurnId === 'string'
? entry.executionTurnId
: runtimeTaskId
return {
timestamp: normalizeEpochMs(entry.timestamp),
type: (entry.type ?? 'gate_result') as WorkItemProgressEntry['type'],
workItemProjectionId: typeof entry.work_item_projection_id === 'string'
? entry.work_item_projection_id
: typeof entry.workItemProjectionId === 'string'
? entry.workItemProjectionId
: undefined,
workItemTurnType: typeof entry.work_item_turn_type === 'string'
? entry.work_item_turn_type
: typeof entry.workItemTurnType === 'string'
? entry.workItemTurnType
: undefined,
workItemProjectionTitle: typeof entry.work_item_projection_title === 'string'
? entry.work_item_projection_title
: typeof entry.workItemProjectionTitle === 'string'
? entry.workItemProjectionTitle
: undefined,
runtimeTaskId,
executionTurnId,
roleName: typeof entry.role_name === 'string'
? entry.role_name
: typeof entry.roleName === 'string'
? entry.roleName
: undefined,
detail: typeof entry.detail === 'string' ? entry.detail : undefined,
}
}))
}
function workItemProjectionTitle(projectionId: string): string {
return projectionId
.replace(/[_-]/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function workItemEntryType(action: string): WorkItemProgressEntry['type'] {
const actionLower = action.toLowerCase()
if (actionLower.includes('starting') || actionLower.includes('started')) return 'work_item_started'
if (actionLower.includes('gate passed') || actionLower.includes('approved') || actionLower.includes('completed')) return 'gate_approved'
if (actionLower.includes('rejected') || actionLower.includes('reworking')) return 'gate_rejected'
if (actionLower.includes('awaiting peer')) return 'awaiting_peer'
if (actionLower.includes('awaiting manager review')) return 'awaiting_manager_review'
if (
actionLower.includes('awaiting user')
|| actionLower.includes('awaiting human review')
|| actionLower.includes('awaiting review')
|| actionLower.includes('awaiting confirmation')
|| actionLower.includes('awaiting feedback')
) {
return 'awaiting_human'
}
if (actionLower.includes('failed')) return 'work_item_failed'
if (actionLower.includes('deadlock')) return 'deadlock'
return 'gate_result'
}
function workItemEntryFromMessage(message: ChatMessage): WorkItemProgressEntry | null {
const content = message.content.trim()
const meta = (message.metadata ?? {}) as Record<string, unknown>
const roleName = message.sender !== 'system' && message.sender !== 'assistant'
? message.senderName
: undefined
const executionTurnId = typeof meta.forwarded_from === 'string'
? meta.forwarded_from
: typeof meta.task_id === 'string'
? meta.task_id
: typeof meta.taskId === 'string'
? meta.taskId
: undefined
const projectionMatch = WORK_ITEM_EVENT_RE.exec(content)
if (projectionMatch) {
const [, projectionId, actionRaw] = projectionMatch
const action = actionRaw.trim()
return {
timestamp: message.timestamp,
type: workItemEntryType(action),
workItemProjectionId: projectionId,
workItemProjectionTitle: workItemProjectionTitle(projectionId),
roleName,
detail: action || undefined,
runtimeTaskId: executionTurnId,
executionTurnId,
}
}
const runtimeMatch = COMPANY_RUNTIME_EVENT_RE.exec(content)
if (runtimeMatch) {
const action = runtimeMatch[1].trim()
return {
timestamp: message.timestamp,
type: workItemEntryType(action),
workItemProjectionId: 'company_runtime',
workItemProjectionTitle: 'Company Runtime',
roleName,
detail: action || undefined,
runtimeTaskId: executionTurnId,
executionTurnId,
}
}
return null
}
function deriveWorkItemLog(messages: ChatMessage[]): WorkItemProgressEntry[] {
return messages
.map(workItemEntryFromMessage)
.filter((entry): entry is WorkItemProgressEntry => entry != null)
.sort((a, b) => a.timestamp - b.timestamp)
}
function hydrateCompanyRuntimeSessions(sessions: Session[], messages: ChatMessage[]): Session[] {
if (sessions.length === 0 || messages.length === 0) return sessions
const messagesByChannel = new Map<string, ChatMessage[]>()
for (const message of messages) {
const bucket = messagesByChannel.get(message.channelId)
if (bucket) bucket.push(message)
else messagesByChannel.set(message.channelId, [message])
}
return sessions.map((session) => {
const derivedWorkItemLog = deriveWorkItemLog(messagesByChannel.get(session.channelId) ?? [])
const workItemLog = session.workItemLog && session.workItemLog.length > 0
? session.workItemLog
: derivedWorkItemLog
const latestConcreteWorkItem = [...workItemLog]
.reverse()
.find((entry) => {
const projectionId = entry.workItemProjectionId
return projectionId && projectionId !== 'company_runtime'
})
const workItemProjectionId = session.mode === 'primary'
? (latestConcreteWorkItem?.workItemProjectionId ?? session.workItemProjectionId)
: (session.workItemProjectionId ?? latestConcreteWorkItem?.workItemProjectionId)
const isCompanyRuntime = session.isCompanyRuntime || (session.mode === 'primary' && workItemLog.length > 0)
if (
workItemLog.length === 0
&& workItemProjectionId === session.workItemProjectionId
&& isCompanyRuntime === session.isCompanyRuntime
) {
return session
}
return {
...session,
isCompanyRuntime,
workItemProjectionId,
...(workItemLog.length > 0 ? { workItemLog } : {}),
}
})
}
function hydrateCompanyRuntimeTasks(tasks: KanbanTask[], sessions: Session[]): KanbanTask[] {
if (tasks.length === 0 || sessions.length === 0) return tasks
const sessionsByTaskId = new Map(sessions.map((session) => [session.taskId, session]))
return tasks.map((task) => {
const session = sessionsByTaskId.get(task.id)
if (!session) return task
const workItemProjectionId = session.workItemProjectionId ?? task.workItemProjectionId
const companyProfile = session.companyProfile ?? task.companyProfile
const workItemRoleId = session.workItemRoleId ?? task.workItemRoleId
const workItemRoleName = session.workItemRoleName ?? task.workItemRoleName
if (
workItemProjectionId === task.workItemProjectionId
&& companyProfile === task.companyProfile
&& workItemRoleId === task.workItemRoleId
&& workItemRoleName === task.workItemRoleName
) {
return task
}
return {
...task,
workItemProjectionId,
companyProfile,
workItemRoleId,
workItemRoleName,
}
})
}
export function mapBackendChannel(raw: any): ChatChannel {
return {
id: raw.channel_id ?? raw.id ?? '',
type: (raw.channel_type ?? raw.type ?? 'session') as ChannelType,
name: raw.name ?? '',
officeId: raw.office_id ?? raw.officeId,
participants: raw.participants ?? [],
pinned: !!raw.pinned,
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
}
}
export function mapBackendMessage(raw: any): ChatMessage {
const rawSenderName = raw.sender_name ?? raw.senderName ?? ''
const senderName = String(rawSenderName).trim().toLowerCase() === 'task generalist'
? 'OPC'
: rawSenderName
return {
id: raw.message_id ?? raw.id ?? '',
channelId: raw.channel_id ?? raw.channelId ?? '',
sender: raw.sender ?? '',
senderName,
content: raw.content ?? '',
timestamp: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.timestamp ?? Date.now()),
replyToId: raw.reply_to_id ?? raw.replyToId,
mentions: raw.mentions ?? [],
metadata: raw.metadata,
senderDeleted: !!(raw.sender_deleted ?? raw.senderDeleted),
}
}
export function mapBackendBoard(raw: any): KanbanBoard {
return {
id: raw.board_id ?? raw.id ?? '',
name: raw.name ?? '',
description: raw.description,
color: raw.color ?? '#3b82f6',
officeId: raw.office_id ?? raw.officeId,
prefix: raw.prefix ?? 'T',
nextTaskNum: raw.next_task_num ?? raw.nextTaskNum ?? 1,
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
updatedAt: typeof raw.updated_at === 'number' ? raw.updated_at * 1000 : (raw.updatedAt ?? Date.now()),
}
}
export function mapBackendColumn(raw: any): KanbanColumn {
return {
id: raw.column_id ?? raw.id ?? '',
boardId: raw.board_id ?? raw.boardId ?? '',
name: raw.name ?? '',
color: raw.color ?? '#888',
sortOrder: raw.sort_order ?? raw.sortOrder ?? 0,
isTerminal: !!(raw.is_terminal ?? raw.isTerminal),
// Phase 2: work-item column metadata
roleLabel: raw.role_label ?? raw.roleLabel,
gateType: raw.gate_type ?? raw.gateType,
isParallel: raw.is_parallel ?? raw.isParallel,
}
}
export function mapBackendTask(raw: any): KanbanTask {
const agentStatus = normalizeAgentRuntimeStatus(raw.status, raw.agent_status ?? raw.agentStatus)
// Backend now emits the work-item phase plus a `kanban_column` projection.
// The legacy `status: <column>` alias on the kanban-card payload was removed
// when DelegationWorkItem.status was retired, so we accept either shape and
// fall back to the explicit `column_id` for the few payloads that still use
// the older field name.
const columnId = raw.kanban_column ?? raw.column_id ?? raw.columnId ?? ''
const cardId = raw.work_item_id ?? raw.workItemId ?? raw.task_id ?? raw.id ?? ''
const runtimeTaskId = raw.runtime_task_id ?? raw.runtimeTaskId
?? raw.execution_turn_id ?? raw.executionTurnId
?? ((raw.work_item_id ?? raw.workItemId) ? undefined : (raw.task_id ?? raw.id))
const executionTurnId = raw.execution_turn_id ?? raw.executionTurnId ?? runtimeTaskId
const executionMode = raw.execution_mode ?? raw.executionMode
const rawProjectionId = raw.work_item_projection_id ?? raw.workItemProjectionId
const isTaskModeRuntime = executionMode === 'task_mode' || rawProjectionId === 'task_mode_execution'
return {
id: cardId,
displayId: raw.display_id ?? raw.displayId ?? '',
boardId: raw.board_id ?? raw.boardId ?? '',
columnId,
title: raw.title ?? '',
description: raw.description,
priority: (raw.priority ?? null) as TaskPriority | null,
assigneeIds: raw.assignee_ids ?? raw.assigneeIds ?? [],
tags: raw.tags ?? [],
sortOrder: raw.sort_order ?? raw.sortOrder ?? 0,
sessionId: raw.session_id ?? raw.sessionId,
workItemId: raw.work_item_id ?? raw.workItemId,
runtimeTaskId,
executionTurnId,
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
updatedAt: typeof raw.updated_at === 'number' ? raw.updated_at * 1000 : (raw.updatedAt ?? Date.now()),
// Phase 2: agent runtime state
agentStatus,
currentTool: raw.current_tool ?? raw.currentTool,
displayTool: raw.display_tool ?? raw.displayTool ?? raw.current_tool ?? raw.currentTool,
toolElapsedMs: raw.tool_elapsed_ms ?? raw.toolElapsedMs,
lastToolSummary: raw.last_tool_summary ?? raw.lastToolSummary,
contextTokens: raw.context_tokens ?? raw.contextTokens,
contextWindow: raw.context_window ?? raw.contextWindow,
contextRemainingPct: raw.context_remaining_pct ?? raw.contextRemainingPct,
inputTokens: raw.input_tokens ?? raw.inputTokens,
outputTokens: raw.output_tokens ?? raw.outputTokens,
totalTokens: raw.total_tokens ?? raw.totalTokens,
turnCostUsd: raw.turn_cost_usd ?? raw.turnCostUsd,
sessionCostUsd: raw.session_cost_usd ?? raw.sessionCostUsd,
pendingPermissionCount: raw.pending_permission_count ?? raw.pendingPermissionCount,
drainMode: raw.drain_mode ?? raw.drainMode,
residentStatus: raw.resident_status ?? raw.residentStatus,
actionableInboxCount: raw.actionable_inbox_count ?? raw.actionableInboxCount,
protocolBacklogCount: raw.protocol_backlog_count ?? raw.protocolBacklogCount,
notificationBacklogCount: raw.notification_backlog_count ?? raw.notificationBacklogCount,
latestNotification: raw.latest_notification ?? raw.latestNotification,
// Phase 2: work-item runtime & dependencies
workItemProjectionId: isTaskModeRuntime ? undefined : rawProjectionId,
workItemTurnType: isTaskModeRuntime ? undefined : (raw.work_item_turn_type ?? raw.workItemTurnType),
companyProfile: isTaskModeRuntime ? undefined : (raw.company_profile ?? raw.companyProfile),
orgId: isTaskModeRuntime ? undefined : (raw.org_id ?? raw.organization_id ?? raw.orgId ?? raw.organizationId),
workItemRoleId: isTaskModeRuntime ? undefined : (raw.work_item_role_id ?? raw.workItemRoleId),
workItemRoleName: isTaskModeRuntime ? undefined : (raw.work_item_role_name ?? raw.workItemRoleName),
workItemGate: isTaskModeRuntime ? undefined : _mapWorkItemGate(raw.work_item_gate ?? raw.workItemGate),
employeeAssignment: _mapEmployeeAssignment(raw.employee_assignment ?? raw.employeeAssignment),
selectedExecutionAgent: raw.selected_execution_agent ?? raw.selectedExecutionAgent,
originChannel: raw.origin_channel ?? raw.originChannel,
dependencies: raw.dependencies,
progressLog: mapBackendProgressLog(raw.progress_log ?? raw.progressLog),
handoffContext: raw.handoff_context ?? raw.handoffContext,
phase: raw.phase,
runtimeSessionId: raw.runtime_session_id ?? raw.runtimeSessionId,
resumeCursor: raw.resume_cursor ?? raw.resumeCursor,
worktreePath: raw.worktree_path ?? raw.worktreePath,
blockedReason: raw.blocked_reason ?? raw.blockedReason,
reviewVerdict: raw.review_verdict ?? raw.reviewVerdict,
reviewSummary: raw.review_summary ?? raw.reviewSummary,
reviewOwnerRoleId: raw.review_owner_role_id ?? raw.reviewOwnerRoleId,
reviewOwnerSeatId: raw.review_owner_seat_id ?? raw.reviewOwnerSeatId,
managerRoleId: raw.manager_role_id ?? raw.managerRoleId,
managerSeatId: raw.manager_seat_id ?? raw.managerSeatId,
scopeKey: raw.scope_key ?? raw.scopeKey,
completionReport: raw.completion_report ?? raw.completionReport,
reworkFeedback: raw.rework_feedback ?? raw.reworkFeedback,
planningContext: raw.planning_context ?? raw.planningContext,
deliverables: raw.deliverables,
acceptanceCriteria: raw.acceptance_criteria ?? raw.acceptanceCriteria,
delegationRationale: raw.delegation_rationale ?? raw.delegationRationale,
nonOverlapGuard: raw.non_overlap_guard ?? raw.nonOverlapGuard,
coordinationNotes: raw.coordination_notes ?? raw.coordinationNotes,
originalMessage: raw.original_message ?? raw.originalMessage,
residentAssignment: raw.resident_assignment ?? raw.residentAssignment,
memberSessionState: raw.member_session_state ?? raw.memberSessionState,
ownershipContract: raw.ownership_contract ?? raw.ownershipContract,
}
}
function _mapEmployeeAssignment(raw: any): EmployeeAssignment | undefined {
if (!raw || typeof raw !== 'object') return undefined
return {
name: raw.name,
employeeId: raw.employee_id ?? raw.employeeId,
category: raw.category,
experienceScore: raw.experience_score ?? raw.experienceScore,
domains: raw.domains,
preferredExternalAgent: raw.preferred_external_agent ?? raw.preferredExternalAgent,
promptContext: raw.prompt_context ?? raw.promptContext,
deltaContext: raw.delta_context ?? raw.deltaContext,
skillRefs: raw.skill_refs ?? raw.skillRefs,
}
}
function _mapWorkItemGate(raw: any): WorkItemGate | undefined {
if (!raw || typeof raw !== 'object') return undefined
return {
type: raw.type ?? raw.gate_type ?? raw.gateType,
reviewerRole: raw.reviewer_role ?? raw.reviewerRole,
autoApprove: raw.auto_approve ?? raw.autoApprove,
criteria: raw.criteria,
}
}
// Backend phase string is the source of truth — the frontend lives off
// that vocabulary too (see ``types/kanban.ts:KanbanPhase``). Validate at
// the deserialization boundary so a typo upstream surfaces here, not deep
// inside the rendering code.
const KNOWN_PHASES: ReadonlySet<KanbanPhase> = new Set<KanbanPhase>([
'queued', 'ready', 'ready_for_rework', 'waiting_dependencies',
'running', 'waiting_for_peer', 'waiting_for_children', 'paused', 'needs_attention',
'awaiting_manager_review', 'awaiting_human',
'approved', 'failed', 'cancelled',
])
const KNOWN_AGGREGATED_STATUS: ReadonlySet<RoleAggregatedStatus> = new Set<RoleAggregatedStatus>([
'active', 'waiting', 'pending', 'done', 'failed',
])
function coercePhase(value: unknown): KanbanPhase {
if (typeof value === 'string' && KNOWN_PHASES.has(value as KanbanPhase)) {
return value as KanbanPhase
}
// Falling back to ``queued`` matches the column id ``todo``, which is
// the safest "haven't done anything yet" placeholder.
return 'queued'
}
function coerceAggregatedStatus(value: unknown): RoleAggregatedStatus {
if (typeof value === 'string' && KNOWN_AGGREGATED_STATUS.has(value as RoleAggregatedStatus)) {
return value as RoleAggregatedStatus
}
return 'pending'
}
function coerceRuntimeStatus(value: unknown): AgentAnimStatus {
if (value === 'reflecting' || value === 'tool_active' || value === 'idle') return value
return 'idle'
}
function mapBackendRoleWorkItemActivitySection(raw: any): RoleWorkItemActivitySection {
const kind = typeof raw.kind === 'string' && raw.kind.trim() ? raw.kind : 'activity'
const title = typeof raw.title === 'string' && raw.title.trim()
? raw.title
: kind.replace(/_/g, ' ')
return {
kind,
title,
roleName: typeof raw.role_name === 'string'
? raw.role_name
: (typeof raw.roleName === 'string' ? raw.roleName : undefined),
runtimeTaskId: typeof raw.runtime_task_id === 'string'
? raw.runtime_task_id
: (typeof raw.runtimeTaskId === 'string' ? raw.runtimeTaskId : undefined),
entries: mapBackendProgressLog(raw.entries),
}
}
function mapBackendRoleWorkItemRow(raw: any): RoleWorkItemRow {
const rawActivitySections: unknown[] = Array.isArray(raw.activity_sections)
? raw.activity_sections
: Array.isArray(raw.activitySections)
? raw.activitySections
: []
return {
workItemId: typeof raw.work_item_id === 'string' ? raw.work_item_id : (raw.workItemId ?? ''),
workItemProjectionId: typeof raw.work_item_projection_id === 'string'
? raw.work_item_projection_id
: (raw.workItemProjectionId ?? undefined),
phase: coercePhase(raw.phase),
kanbanColumn: typeof raw.kanban_column === 'string'
? raw.kanban_column
: (raw.kanbanColumn ?? 'todo'),
title: typeof raw.title === 'string' ? raw.title : '',
kind: typeof raw.kind === 'string' ? raw.kind : (raw.kind ?? undefined),
isReviewTarget: !!(raw.is_review_target ?? raw.isReviewTarget),
executorRoleId: raw.executor_role_id ?? raw.executorRoleId ?? undefined,
executorRoleName: raw.executor_role_name ?? raw.executorRoleName ?? undefined,
reviewerRoleId: raw.reviewer_role_id ?? raw.reviewerRoleId ?? undefined,
createdAt: normalizeEpochMs(raw.created_at ?? raw.createdAt),
updatedAt: normalizeEpochMs(raw.updated_at ?? raw.updatedAt),
executionTurnId: raw.execution_turn_id ?? raw.executionTurnId ?? undefined,
activitySections: rawActivitySections
.filter((section): section is Record<string, unknown> => !!section && typeof section === 'object')
.map(mapBackendRoleWorkItemActivitySection),
progressLog: mapBackendProgressLog(raw.progress_log ?? raw.progressLog),
}
}
function mapBackendRoleWorkItems(raw: any): Record<string, RoleWorkItemSummary> | undefined {
if (!raw || typeof raw !== 'object') return undefined
const out: Record<string, RoleWorkItemSummary> = {}
for (const [key, value] of Object.entries(raw)) {
if (!value || typeof value !== 'object') continue
const summary = value as Record<string, unknown>
const workItemsRaw = Array.isArray(summary.work_items)
? summary.work_items
: Array.isArray(summary.workItems)
? summary.workItems
: []
const workItems = (workItemsRaw as any[])
.map(mapBackendRoleWorkItemRow)
// Defensive ASC sort by createdAt — backend already orders, but a
// belt-and-braces sort here means UI never sees a flapping row order
// if backend ordering ever drifts.
.sort((a, b) => a.createdAt - b.createdAt)
const roleKey = typeof summary.role_key === 'string'
? summary.role_key
: (typeof summary.roleKey === 'string' ? summary.roleKey : key)
out[key] = {
roleKey,
roleId: typeof summary.role_id === 'string'
? summary.role_id
: (typeof summary.roleId === 'string' ? summary.roleId : key),
roleName: typeof summary.role_name === 'string'
? summary.role_name
: (typeof summary.roleName === 'string' ? summary.roleName : key),
roleSessionId: typeof summary.role_session_id === 'string'
? summary.role_session_id
: (typeof summary.roleSessionId === 'string' ? summary.roleSessionId : undefined),
teamInstanceId: typeof summary.team_instance_id === 'string'
? summary.team_instance_id
: (typeof summary.teamInstanceId === 'string' ? summary.teamInstanceId : undefined),
runtimeStatus: coerceRuntimeStatus(summary.runtime_status ?? summary.runtimeStatus),
aggregatedStatus: coerceAggregatedStatus(summary.aggregated_status ?? summary.aggregatedStatus),
workItems,
}
}
return Object.keys(out).length > 0 ? out : undefined
}
export function mapBackendSession(raw: any): Session {
const agentStatus = normalizeAgentRuntimeStatus(raw.status, raw.agent_status ?? raw.agentStatus)
const taskId = raw.task_id ?? raw.taskId ?? ''
const runtimeTaskId = raw.runtime_task_id ?? raw.runtimeTaskId ?? raw.execution_turn_id ?? raw.executionTurnId ?? taskId
const executionTurnId = raw.execution_turn_id ?? raw.executionTurnId ?? runtimeTaskId
const executionMode = raw.execution_mode ?? raw.executionMode
const rawExecMode = String(raw.exec_mode ?? raw.execMode ?? '').trim().toLowerCase()
const rawProjectionId = raw.work_item_projection_id ?? raw.workItemProjectionId
const isTaskModeRuntime = (
rawExecMode === 'task'
|| rawExecMode === 'project'
|| rawExecMode === 'single'
|| executionMode === 'task_mode'
|| rawProjectionId === 'task_mode_execution'
)
const parentSessionId = isTaskModeRuntime
? undefined
: (raw.parent_session_id ?? raw.parentSessionId)
const mapped: Session = {
projectId: raw.project_id ?? raw.projectId ?? '',
taskId,
runtimeTaskId,
executionTurnId,
channelId: raw.channel_id ?? raw.channelId ?? '',
sessionId: raw.session_id ?? raw.sessionId,
parentSessionId,
mode: (isTaskModeRuntime ? 'primary' : (raw.mode ?? (parentSessionId ? 'child' : 'primary'))) as SessionMode,
title: raw.title ?? 'Untitled',
status: raw.status ?? 'pending',
columnId: raw.column_id ?? raw.columnId ?? 'todo',
assigneeIds: raw.assignee_ids ?? raw.assigneeIds ?? [],
priority: raw.priority ?? null,
tags: raw.tags ?? [],
agentStatus,
currentTool: raw.current_tool ?? raw.currentTool,
displayTool: raw.display_tool ?? raw.displayTool ?? raw.current_tool ?? raw.currentTool,
toolElapsedMs: raw.tool_elapsed_ms ?? raw.toolElapsedMs,
lastToolSummary: raw.last_tool_summary ?? raw.lastToolSummary,
contextTokens: raw.context_tokens ?? raw.contextTokens,
contextWindow: raw.context_window ?? raw.contextWindow,
contextRemainingPct: raw.context_remaining_pct ?? raw.contextRemainingPct,
inputTokens: raw.input_tokens ?? raw.inputTokens,
outputTokens: raw.output_tokens ?? raw.outputTokens,
totalTokens: raw.total_tokens ?? raw.totalTokens,
turnCostUsd: raw.turn_cost_usd ?? raw.turnCostUsd,
sessionCostUsd: raw.session_cost_usd ?? raw.sessionCostUsd,
pendingPermissionCount: raw.pending_permission_count ?? raw.pendingPermissionCount,
drainMode: raw.drain_mode ?? raw.drainMode,
progressLog: mapBackendProgressLog(raw.progress_log ?? raw.progressLog),
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
updatedAt: typeof raw.updated_at === 'number' ? raw.updated_at * 1000 : (raw.updatedAt ?? Date.now()),
messageCount: raw.message_count ?? raw.messageCount ?? 0,
latestPreview: raw.latest_preview ?? raw.latestPreview,
latestSender: raw.latest_sender ?? raw.latestSender,
latestMessageId: raw.latest_message_id ?? raw.latestMessageId,
indexLoaded: raw.index_loaded ?? raw.indexLoaded,
detailLoaded: raw.detail_loaded ?? raw.detailLoaded,
fullLoaded: raw.full_loaded ?? raw.fullLoaded,
hasMore: raw.has_more ?? raw.hasMore,
detailLoading: raw.detail_loading ?? raw.detailLoading,
detailError: raw.detail_error ?? raw.detailError,
viewGeneration: raw.view_generation ?? raw.viewGeneration,
execMode: raw.exec_mode ?? raw.execMode,
companyProfile: isTaskModeRuntime ? undefined : (raw.company_profile ?? raw.companyProfile),
orgId: isTaskModeRuntime ? undefined : (raw.org_id ?? raw.organization_id ?? raw.orgId ?? raw.organizationId),
preferredAgent: raw.preferred_agent ?? raw.preferredAgent,
// Company Mode metadata
workItemProjectionId: isTaskModeRuntime ? undefined : rawProjectionId,
workItemTurnType: isTaskModeRuntime ? undefined : (raw.work_item_turn_type ?? raw.workItemTurnType),
workItemRoleId: isTaskModeRuntime ? undefined : (raw.work_item_role_id ?? raw.workItemRoleId),
workItemRoleName: isTaskModeRuntime ? undefined : (raw.work_item_role_name ?? raw.workItemRoleName),
workItemGate: isTaskModeRuntime ? undefined : _mapWorkItemGate(raw.work_item_gate ?? raw.workItemGate),
employeeAssignment: _mapEmployeeAssignment(raw.employee_assignment ?? raw.employeeAssignment),
selectedExecutionAgent: raw.selected_execution_agent ?? raw.selectedExecutionAgent,
originChannel: raw.origin_channel ?? raw.originChannel,
originTaskId: raw.origin_task_id ?? raw.originTaskId ?? raw.task_id ?? raw.taskId,
runtimeControlState: raw.runtime_control_state ?? raw.runtimeControlState,
canStop: raw.can_stop ?? raw.canStop,
canResume: raw.can_resume ?? raw.canResume,
resumeParentTaskId: raw.resume_parent_task_id ?? raw.resumeParentTaskId,
resumeParentSessionId: raw.resume_parent_session_id ?? raw.resumeParentSessionId,
pendingRuntimeCheckpointId: raw.pending_runtime_checkpoint_id ?? raw.pendingRuntimeCheckpointId,
stopIntentId: raw.stop_intent_id ?? raw.stopIntentId,
handoffContext: raw.handoff_context ?? raw.handoffContext,
handoffTo: raw.handoff_to ?? raw.handoffTo,
artifacts: raw.artifacts,
isCompanyRuntime: isTaskModeRuntime ? false : (raw.is_company_runtime ?? raw.isCompanyRuntime),
workItemLog: isTaskModeRuntime ? [] : mapBackendWorkItemLog(raw.work_item_log ?? raw.workItemLog),
roleWorkItems: isTaskModeRuntime ? undefined : mapBackendRoleWorkItems(raw.role_work_items ?? raw.roleWorkItems),
executorRoleWorkItems: isTaskModeRuntime
? undefined
: mapBackendRoleWorkItems(raw.executor_role_work_items ?? raw.executorRoleWorkItems),
draftTurnId: raw.draft_turn_id ?? raw.draftTurnId,
runtimeSessionId: raw.runtime_session_id ?? raw.runtimeSessionId,
resumeCursor: raw.resume_cursor ?? raw.resumeCursor,
activeSubagents: raw.active_subagents ?? raw.activeSubagents,
permissionRequests: raw.permission_requests ?? raw.permissionRequests,
worktreePath: raw.worktree_path ?? raw.worktreePath,
residentStatus: raw.resident_status ?? raw.residentStatus,
actionableInboxCount: raw.actionable_inbox_count ?? raw.actionableInboxCount,
protocolBacklogCount: raw.protocol_backlog_count ?? raw.protocolBacklogCount,
notificationBacklogCount: raw.notification_backlog_count ?? raw.notificationBacklogCount,
latestNotification: raw.latest_notification ?? raw.latestNotification,
}
return canonicalizeSessionExecutionIdentity(mapped)
}
export interface CollabSyncData {
channels: ChatChannel[]
messages: ChatMessage[]
boards: KanbanBoard[]
columns: KanbanColumn[]
tasks: KanbanTask[]
sessions: Session[]
}
export function mapCollabSyncPayload(payload: any): CollabSyncData {
const channels = (payload.channels ?? []).map(mapBackendChannel)
const messages = (payload.messages ?? []).map(mapBackendMessage)
const boards = (payload.boards ?? []).map(mapBackendBoard)
const columns = (payload.columns ?? []).map(mapBackendColumn)
const sessions = hydrateCompanyRuntimeSessions(
(payload.sessions ?? []).map(mapBackendSession),
messages,
)
const tasks = hydrateCompanyRuntimeTasks(
(payload.tasks ?? []).map(mapBackendTask),
sessions,
)
return {
channels,
messages,
boards,
columns,
tasks,
sessions,
}
}
@@ -0,0 +1,81 @@
export interface ContextUsageLike {
contextTokens?: number | null
contextWindow?: number | null
contextRemainingPct?: number | null
}
export interface ContextUsageMetrics {
usedPct?: number
remainingPct?: number
usedTokens?: number
windowTokens?: number
}
function asFiniteNumber(value: number | null | undefined): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return value
}
function clampPct(value: number): number {
return Math.max(0, Math.min(Math.round(value), 100))
}
export function getContextUsageMetrics(
value: ContextUsageLike | null | undefined,
): ContextUsageMetrics {
const contextTokens = asFiniteNumber(value?.contextTokens)
const contextWindow = asFiniteNumber(value?.contextWindow)
const contextRemainingPct = asFiniteNumber(value?.contextRemainingPct)
const normalizedTokens = typeof contextTokens === 'number' ? Math.max(0, Math.round(contextTokens)) : undefined
const normalizedWindow = typeof contextWindow === 'number' && contextWindow > 0
? Math.max(1, Math.round(contextWindow))
: undefined
const normalizedRemainingPct = typeof contextRemainingPct === 'number'
? clampPct(contextRemainingPct)
: undefined
if (typeof normalizedTokens === 'number' && typeof normalizedWindow === 'number') {
const usedTokens = Math.min(normalizedTokens, normalizedWindow)
const usedPct = clampPct((usedTokens / normalizedWindow) * 100)
return {
usedPct,
remainingPct: 100 - usedPct,
usedTokens,
windowTokens: normalizedWindow,
}
}
if (typeof normalizedRemainingPct === 'number' && typeof normalizedWindow === 'number') {
const usedPct = 100 - normalizedRemainingPct
return {
usedPct,
remainingPct: normalizedRemainingPct,
usedTokens: Math.round((usedPct / 100) * normalizedWindow),
windowTokens: normalizedWindow,
}
}
if (typeof normalizedWindow === 'number') {
return {
usedPct: 0,
remainingPct: 100,
usedTokens: 0,
windowTokens: normalizedWindow,
}
}
if (typeof normalizedRemainingPct === 'number') {
// No usable window: the used/max ratio is undefined. Upstream reports an
// unknown window as remaining_pct=0, so deriving usedPct here would render
// a misleading 100% (or 0% at turn start). Surface remaining for any text
// display but do not drive the ring — the ring hides without a usedPct.
return { remainingPct: normalizedRemainingPct }
}
if (typeof normalizedTokens === 'number') {
return { usedTokens: normalizedTokens }
}
return {}
}
@@ -0,0 +1,64 @@
/**
* Locks in: frontend's PHASE_TO_COLUMN matches backend's
* ``opc/presentation/kanban.py:STATUS_TO_COLUMN`` and
* ``opc/layer2_organization/phase.py:_PHASE_TO_COLUMN``.
*
* If the backend ever adds, removes, or renames a phase / column, this
* test surfaces the drift immediately — preventing a class of silent
* UI bug where a card ends up in the wrong column because the frontend
* projection fell out of sync with the backend.
*/
import { describe, it, expect } from 'vitest'
import type { KanbanPhase } from '../types/kanban'
import { PHASE_TO_COLUMN, deriveColumnFromPhase } from './phaseHelpers'
const ALL_PHASES: KanbanPhase[] = [
'queued', 'ready', 'ready_for_rework', 'waiting_dependencies',
'running', 'waiting_for_peer', 'waiting_for_children', 'paused', 'needs_attention',
'awaiting_manager_review', 'awaiting_human',
'approved', 'failed', 'cancelled',
]
describe('PHASE_TO_COLUMN', () => {
it('covers every KanbanPhase exactly once', () => {
expect(Object.keys(PHASE_TO_COLUMN).sort()).toEqual([...ALL_PHASES].sort())
})
it('projects TODO-family phases to "todo"', () => {
for (const p of ['queued', 'ready', 'ready_for_rework', 'waiting_dependencies'] as KanbanPhase[]) {
expect(PHASE_TO_COLUMN[p]).toBe('todo')
}
})
it('projects IN-PROGRESS-family phases to "in-progress"', () => {
for (const p of ['running', 'waiting_for_peer', 'waiting_for_children', 'paused', 'needs_attention'] as KanbanPhase[]) {
expect(PHASE_TO_COLUMN[p]).toBe('in-progress')
}
})
it('projects IN-REVIEW-family phases to "in-review"', () => {
for (const p of ['awaiting_manager_review', 'awaiting_human'] as KanbanPhase[]) {
expect(PHASE_TO_COLUMN[p]).toBe('in-review')
}
})
it('projects terminal phases to "done"', () => {
for (const p of ['approved', 'failed', 'cancelled'] as KanbanPhase[]) {
expect(PHASE_TO_COLUMN[p]).toBe('done')
}
})
})
describe('deriveColumnFromPhase', () => {
it('returns "todo" for undefined / null phase', () => {
expect(deriveColumnFromPhase(undefined)).toBe('todo')
expect(deriveColumnFromPhase(null)).toBe('todo')
})
it('projects each phase using the same table', () => {
for (const p of ALL_PHASES) {
expect(deriveColumnFromPhase(p)).toBe(PHASE_TO_COLUMN[p])
}
})
})
@@ -0,0 +1,52 @@
/**
* Single-source-of-truth projection: KanbanPhase → kanban column id.
*
* This mirror MUST stay in sync with the backend projection in
* ``opc/presentation/kanban.py:STATUS_TO_COLUMN`` and
* ``opc/layer2_organization/phase.py:_PHASE_TO_COLUMN``. The test in
* ``phaseHelpers.test.ts`` locks the mapping by enumerating all 14
* phases; if the backend ever renames or reshapes the column set, that
* test catches the drift before the UI silently mis-groups cards.
*
* The UI previously grouped cards by the backend-supplied ``columnId``
* field, which is itself a projection of phase on the backend. Moving
* the projection into the frontend removes a layer of indirection and
* lets the UI stay internally consistent when future optimistic writes
* only know the phase intent, not the derived column.
*/
import type { KanbanPhase } from '../types/kanban'
export const PHASE_TO_COLUMN: Record<KanbanPhase, string> = {
// todo
queued: 'todo',
ready: 'todo',
ready_for_rework: 'todo',
waiting_dependencies: 'todo',
// in-progress
running: 'in-progress',
waiting_for_peer: 'in-progress',
waiting_for_children: 'in-progress',
paused: 'in-progress',
needs_attention: 'in-progress',
// in-review
awaiting_manager_review: 'in-review',
awaiting_human: 'in-review',
// done
approved: 'done',
failed: 'done',
cancelled: 'done',
}
/**
* Derive the kanban column for a task based on its phase.
* Falls back to ``'todo'`` when phase is missing or unknown; callers
* that already have a backend-supplied ``columnId`` should prefer that
* value during the transition window and only use this helper when the
* phase is trustworthy.
*/
export function deriveColumnFromPhase(phase: KanbanPhase | undefined | null): string {
if (!phase) return 'todo'
const mapped = PHASE_TO_COLUMN[phase]
return mapped ?? 'todo'
}
@@ -0,0 +1,36 @@
import type { ProgressEntry } from '../types/kanban'
function compact(value: unknown): string {
return String(value ?? '')
.trim()
.replace(/\s+/g, ' ')
.slice(0, 96)
}
export function progressEntryKey(entry: ProgressEntry, fallbackIndex = 0): string {
const stableId = entry.itemId || entry.streamId || entry.toolCallId || entry.permissionGroupKey
if (stableId) {
return `${entry.type}:${compact(entry.turnId)}:${compact(stableId)}`
}
if (entry.type === 'thinking') {
return `thinking:${compact(entry.turnId) || compact(entry.executionMode) || compact(entry.summary) || 'stream'}:${fallbackIndex}`
}
if (entry.type === 'tool_call' && entry.turnId) {
return `tool:${compact(entry.turnId)}:${compact(entry.summary) || 'tool'}:${fallbackIndex}`
}
if (entry.turnId && typeof entry.seq === 'number') {
return `${entry.type}:${compact(entry.turnId)}:seq:${entry.seq}`
}
return [
entry.type,
compact(entry.turnId),
Number.isFinite(entry.timestamp) ? entry.timestamp : '',
compact(entry.summary),
compact(entry.detail),
fallbackIndex,
].join(':')
}
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import { appendProgressEntry } from './progressLog'
let log = appendProgressEntry([], {
timestamp: 1,
type: 'thinking',
summary: 'Thinking',
detail: '我先',
turnId: 'rt-1:1',
itemId: 'rt-1:1:thinking',
seq: 1,
})
for (const [seq, detail] of [
[2, '先联网'],
[3, '联网抓'],
[4, '抓取'],
] as const) {
log = appendProgressEntry(log, {
timestamp: seq,
type: 'thinking',
summary: 'Thinking',
detail,
turnId: 'rt-1:1',
itemId: 'rt-1:1:thinking',
seq,
})
}
assert.equal(log.length, 1)
assert.equal(log[0]?.summary, 'Thinking')
assert.equal(log[0]?.detail, '我先联网抓取')
const unchanged = appendProgressEntry(log, {
timestamp: 5,
type: 'thinking',
summary: 'Thinking',
detail: '重复',
turnId: 'rt-1:1',
itemId: 'rt-1:1:thinking',
seq: 4,
})
assert.equal(unchanged[0]?.detail, '我先联网抓取')
let toolLog = appendProgressEntry([], {
timestamp: 10,
type: 'tool_call',
summary: 'web_search',
detail: '{"query":"weather"}',
turnId: 'rt-1:2',
toolCallId: 'call-1',
})
toolLog = appendProgressEntry(toolLog, {
timestamp: 11,
type: 'tool_call',
summary: 'web_search',
detail: 'completed',
turnId: 'rt-1:2',
toolCallId: 'call-1',
})
assert.equal(toolLog.length, 1)
assert.equal(toolLog[0]?.detail, '{"query":"weather"}\ncompleted')
let permissionLog = appendProgressEntry([], {
timestamp: 20,
type: 'autonomy',
summary: 'shell_exec: ask',
turnId: 'rt-1:3',
permissionGroupKey: 'tool:shell_exec/python:domain:example.com',
})
permissionLog = appendProgressEntry(permissionLog, {
timestamp: 21,
type: 'autonomy',
summary: 'shell_exec: allow',
turnId: 'rt-1:3',
permissionGroupKey: 'tool:shell_exec/python:domain:example.com',
})
assert.equal(permissionLog.length, 1)
assert.equal(permissionLog[0]?.summary, 'shell_exec: allow')
@@ -0,0 +1,237 @@
import type { ProgressEntry, WorkItemProgressEntry } from '../types/kanban'
const STREAM_MERGE_WINDOW_MS = 4000
function clampEntries<T>(entries: T[], maxEntries: number): T[] {
return entries.length > maxEntries ? entries.slice(-maxEntries) : entries
}
function mergeText(left: string, right: string, kind: 'thinking' | 'tool_call'): string {
if (!left) return right
if (!right) return left
if (left === right) return left
if (right.startsWith(left)) return right
if (left.startsWith(right)) return left
if (left.endsWith(right)) return left
if (right.endsWith(left)) return right
const maxOverlap = Math.min(left.length, right.length)
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
if (left.slice(-overlap) === right.slice(0, overlap)) {
return `${left}${right.slice(overlap)}`
}
}
if (kind === 'tool_call' && /[}\]"]$/.test(left) && !/^\s/.test(right)) {
return `${left}\n${right}`
}
return `${left}${right}`
}
function summarizeThinking(detail: string, fallback: string): string {
void detail
void fallback
return 'Thinking'
}
function normalizeProgressEntry(entry: ProgressEntry): ProgressEntry {
return {
timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
type: entry.type,
summary: typeof entry.summary === 'string' ? entry.summary : '',
detail: typeof entry.detail === 'string' && entry.detail ? entry.detail : undefined,
turnId: typeof entry.turnId === 'string' && entry.turnId ? entry.turnId : undefined,
itemId: typeof entry.itemId === 'string' && entry.itemId ? entry.itemId : undefined,
streamId: typeof entry.streamId === 'string' && entry.streamId ? entry.streamId : undefined,
toolCallId: typeof entry.toolCallId === 'string' && entry.toolCallId ? entry.toolCallId : undefined,
permissionGroupKey: typeof entry.permissionGroupKey === 'string' && entry.permissionGroupKey ? entry.permissionGroupKey : undefined,
seq: typeof entry.seq === 'number' && Number.isFinite(entry.seq) ? entry.seq : undefined,
executionMode: typeof entry.executionMode === 'string' && entry.executionMode ? entry.executionMode : undefined,
}
}
function streamKey(entry: ProgressEntry): string {
const itemKey = entry.itemId || entry.streamId
if (!itemKey) {
if (entry.toolCallId && (entry.type === 'tool_call' || entry.type === 'autonomy')) {
return `${entry.type}:${entry.turnId ?? ''}:${entry.toolCallId}`
}
if (entry.permissionGroupKey && entry.type === 'autonomy') {
return `${entry.type}:${entry.turnId ?? ''}:${entry.permissionGroupKey}`
}
return ''
}
return `${entry.type}:${entry.turnId ?? ''}:${itemKey}`
}
function canMergeProgress(left: ProgressEntry, right: ProgressEntry): boolean {
const leftKey = streamKey(left)
const rightKey = streamKey(right)
if (leftKey && rightKey) return leftKey === rightKey
if (right.timestamp - left.timestamp > STREAM_MERGE_WINDOW_MS) return false
if (left.type !== right.type) return false
if (left.type === 'thinking') return true
if (left.type === 'tool_call') return left.summary === right.summary
return false
}
function isDuplicateProgress(left: ProgressEntry, right: ProgressEntry): boolean {
return (
right.timestamp - left.timestamp <= STREAM_MERGE_WINDOW_MS
&& left.type === right.type
&& left.summary === right.summary
&& (left.detail ?? '') === (right.detail ?? '')
)
}
function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry {
if (left.type === 'thinking') {
const detail = mergeText(left.detail ?? left.summary, right.detail ?? right.summary, 'thinking')
return {
timestamp: right.timestamp,
type: 'thinking',
summary: summarizeThinking(detail, right.summary || left.summary),
detail: detail || undefined,
turnId: right.turnId ?? left.turnId,
itemId: right.itemId ?? left.itemId,
streamId: right.streamId ?? left.streamId,
toolCallId: right.toolCallId ?? left.toolCallId,
permissionGroupKey: right.permissionGroupKey ?? left.permissionGroupKey,
seq: right.seq ?? left.seq,
executionMode: right.executionMode ?? left.executionMode,
}
}
if (left.type === 'tool_call') {
const mergedDetail = mergeText(left.detail ?? '', right.detail ?? '', 'tool_call')
return {
timestamp: right.timestamp,
type: 'tool_call',
summary: right.summary || left.summary,
detail: mergedDetail || undefined,
turnId: right.turnId ?? left.turnId,
itemId: right.itemId ?? left.itemId,
streamId: right.streamId ?? left.streamId,
toolCallId: right.toolCallId ?? left.toolCallId,
permissionGroupKey: right.permissionGroupKey ?? left.permissionGroupKey,
seq: right.seq ?? left.seq,
executionMode: right.executionMode ?? left.executionMode,
}
}
return right
}
export function appendProgressEntry(
log: ProgressEntry[],
entry: ProgressEntry,
maxEntries = 100,
): ProgressEntry[] {
const normalized = normalizeProgressEntry(entry)
const normalizedKey = streamKey(normalized)
const targetIndex = normalizedKey
? [...log].reverse().findIndex(existing => streamKey(existing) === normalizedKey)
: -1
const actualIndex = targetIndex >= 0 ? log.length - 1 - targetIndex : log.length - 1
const last = log[actualIndex]
if (!last) return [normalized]
if (
normalizedKey
&& typeof last.seq === 'number'
&& typeof normalized.seq === 'number'
&& normalized.seq <= last.seq
) {
return log
}
if (isDuplicateProgress(last, normalized)) {
return clampEntries([
...log.slice(0, actualIndex),
{ ...last, timestamp: normalized.timestamp },
...log.slice(actualIndex + 1),
], maxEntries)
}
if (canMergeProgress(last, normalized)) {
return clampEntries([
...log.slice(0, actualIndex),
mergeProgress(last, normalized),
...log.slice(actualIndex + 1),
], maxEntries)
}
return clampEntries([...log, normalized], maxEntries)
}
export function normalizeProgressLog(log: ProgressEntry[], maxEntries = 100): ProgressEntry[] {
return (Array.isArray(log) ? log : []).reduce<ProgressEntry[]>(
(acc, entry) => appendProgressEntry(acc, entry, maxEntries),
[],
)
}
function normalizeWorkItemEntry(entry: WorkItemProgressEntry): WorkItemProgressEntry {
return {
timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
type: entry.type,
workItemProjectionId: typeof entry.workItemProjectionId === 'string' && entry.workItemProjectionId ? entry.workItemProjectionId : undefined,
workItemTurnType: typeof entry.workItemTurnType === 'string' && entry.workItemTurnType ? entry.workItemTurnType : undefined,
workItemProjectionTitle: typeof entry.workItemProjectionTitle === 'string' && entry.workItemProjectionTitle ? entry.workItemProjectionTitle : undefined,
runtimeTaskId: typeof entry.runtimeTaskId === 'string' && entry.runtimeTaskId ? entry.runtimeTaskId : undefined,
executionTurnId: typeof entry.executionTurnId === 'string' && entry.executionTurnId ? entry.executionTurnId : undefined,
roleName: typeof entry.roleName === 'string' && entry.roleName ? entry.roleName : undefined,
detail: typeof entry.detail === 'string' && entry.detail ? entry.detail : undefined,
}
}
function sameWorkItemScope(left: WorkItemProgressEntry, right: WorkItemProgressEntry): boolean {
return (
left.type === right.type
&& (left.workItemProjectionId ?? '') === (right.workItemProjectionId ?? '')
&& (left.workItemTurnType ?? '') === (right.workItemTurnType ?? '')
&& (left.workItemProjectionTitle ?? '') === (right.workItemProjectionTitle ?? '')
&& (left.executionTurnId ?? left.runtimeTaskId ?? '') === (right.executionTurnId ?? right.runtimeTaskId ?? '')
&& (left.roleName ?? '') === (right.roleName ?? '')
)
}
function canMergeWorkItem(left: WorkItemProgressEntry, right: WorkItemProgressEntry): boolean {
return (
right.timestamp - left.timestamp <= STREAM_MERGE_WINDOW_MS
&& sameWorkItemScope(left, right)
&& (left.type === 'thinking' || left.type === 'tool_call')
)
}
function isDuplicateWorkItem(left: WorkItemProgressEntry, right: WorkItemProgressEntry): boolean {
return sameWorkItemScope(left, right) && (left.detail ?? '') === (right.detail ?? '') && right.timestamp - left.timestamp <= STREAM_MERGE_WINDOW_MS
}
function mergeWorkItem(left: WorkItemProgressEntry, right: WorkItemProgressEntry): WorkItemProgressEntry {
const kind = right.type === 'tool_call' ? 'tool_call' : 'thinking'
return {
...left,
...right,
timestamp: right.timestamp,
detail: mergeText(left.detail ?? '', right.detail ?? '', kind) || undefined,
}
}
export function appendWorkItemProgressEntry(
log: WorkItemProgressEntry[],
entry: WorkItemProgressEntry,
maxEntries = 100,
): WorkItemProgressEntry[] {
const normalized = normalizeWorkItemEntry(entry)
const last = log[log.length - 1]
if (!last) return [normalized]
if (isDuplicateWorkItem(last, normalized)) {
return clampEntries([...log.slice(0, -1), { ...last, timestamp: normalized.timestamp }], maxEntries)
}
if (canMergeWorkItem(last, normalized)) {
return clampEntries([...log.slice(0, -1), mergeWorkItem(last, normalized)], maxEntries)
}
return clampEntries([...log, normalized], maxEntries)
}
export function normalizeWorkItemLog(log: WorkItemProgressEntry[], maxEntries = 100): WorkItemProgressEntry[] {
return (Array.isArray(log) ? log : []).reduce<WorkItemProgressEntry[]>(
(acc, entry) => appendWorkItemProgressEntry(acc, entry, maxEntries),
[],
)
}
@@ -0,0 +1,207 @@
import assert from 'node:assert/strict'
import { mapBackendSession } from './collabSync'
/* ──────────────────────────────────────────────────────────────────────
* roleWorkItems deserialization tests
*
* Locks the snake_case → camelCase conversion at the collabSync boundary
* so a backend rename or a missing field surfaces here, not as a silent
* UI regression in WorkItemProgressCard. The schema this matches is
* produced by ``snapshot_builder._build_role_work_items_for_session`` —
* keep these two in sync.
* ────────────────────────────────────────────────────────────────────── */
const session = mapBackendSession({
task_id: 'task-1',
channel_id: 'session:task-1',
status: 'running',
exec_mode: 'company',
is_company_runtime: true,
role_work_items: {
engineer: {
role_key: 'engineer',
role_id: 'engineer',
role_name: 'Engineer',
runtime_status: 'tool_active',
aggregated_status: 'active',
work_items: [
{
work_item_id: 'wi-1',
work_item_projection_id: 'proj-engineer-1',
phase: 'running',
kanban_column: 'in-progress',
title: 'Implement summary',
kind: 'execute',
is_review_target: false,
executor_role_id: 'engineer',
executor_role_name: 'Engineer',
reviewer_role_id: 'cto',
created_at: 100.0,
updated_at: 200.0,
execution_turn_id: 'runtime-task-1',
activity_sections: [
{
kind: 'execute',
title: 'Execute',
role_name: 'Engineer',
runtime_task_id: 'runtime-task-1',
entries: [
{ timestamp: 151.0, type: 'thinking', summary: 'plan' },
],
},
{
kind: 'report',
title: 'Report',
role_name: 'Engineer',
runtime_task_id: 'runtime-report-1',
entries: [
{ timestamp: 175.0, type: 'tool_call', summary: 'write_report' },
],
},
],
progress_log: [
{ timestamp: 150.0, type: 'tool_call', summary: 'edit_file' },
],
},
],
},
cto: {
role_key: 'cto',
role_id: 'cto',
role_name: 'CTO',
runtime_status: 'idle',
aggregated_status: 'waiting',
work_items: [
{
work_item_id: 'wi-2',
work_item_projection_id: 'proj-engineer-1',
phase: 'awaiting_manager_review',
kanban_column: 'in-review',
title: 'Implement summary',
kind: 'execute',
is_review_target: true,
executor_role_id: 'engineer',
reviewer_role_id: 'cto',
created_at: 50.0,
updated_at: 250.0,
execution_turn_id: 'runtime-task-1',
progress_log: [],
},
],
},
},
executor_role_work_items: {
engineer: {
role_key: 'engineer',
role_id: 'engineer',
role_name: 'Engineer',
runtime_status: 'idle',
aggregated_status: 'waiting',
work_items: [
{
work_item_id: 'wi-2',
work_item_projection_id: 'proj-engineer-1',
phase: 'awaiting_manager_review',
kanban_column: 'in-review',
title: 'Implement summary',
kind: 'execute',
is_review_target: true,
executor_role_id: 'engineer',
executor_role_name: 'Engineer',
reviewer_role_id: 'cto',
created_at: 50.0,
updated_at: 250.0,
execution_turn_id: 'runtime-task-1',
progress_log: [],
},
],
},
},
})
const roleWorkItems = session.roleWorkItems
assert.ok(roleWorkItems, 'roleWorkItems should be parsed')
assert.deepEqual(Object.keys(roleWorkItems!).sort(), ['cto', 'engineer'])
const engineer = roleWorkItems!.engineer
assert.equal(engineer.aggregatedStatus, 'active')
assert.equal(engineer.runtimeStatus, 'tool_active')
assert.equal(engineer.workItems.length, 1)
assert.equal(engineer.workItems[0].phase, 'running')
assert.equal(engineer.workItems[0].kanbanColumn, 'in-progress')
assert.equal(engineer.workItems[0].executionTurnId, 'runtime-task-1')
assert.equal(engineer.workItems[0].progressLog.length, 1)
assert.equal(engineer.workItems[0].progressLog[0].summary, 'edit_file')
assert.equal(engineer.workItems[0].activitySections?.length, 2)
assert.equal(engineer.workItems[0].activitySections?.[0].roleName, 'Engineer')
assert.equal(engineer.workItems[0].activitySections?.[1].runtimeTaskId, 'runtime-report-1')
assert.equal(engineer.workItems[0].activitySections?.[1].entries[0].summary, 'write_report')
const cto = roleWorkItems!.cto
assert.equal(cto.aggregatedStatus, 'waiting')
assert.equal(cto.runtimeStatus, 'idle')
assert.equal(cto.workItems.length, 1)
assert.equal(cto.workItems[0].isReviewTarget, true)
assert.equal(cto.workItems[0].kanbanColumn, 'in-review')
const executorRoleWorkItems = session.executorRoleWorkItems
assert.ok(executorRoleWorkItems, 'executorRoleWorkItems should be parsed')
assert.deepEqual(Object.keys(executorRoleWorkItems!).sort(), ['engineer'])
assert.equal(executorRoleWorkItems!.engineer.workItems.length, 1)
assert.equal(executorRoleWorkItems!.engineer.workItems[0].isReviewTarget, true)
assert.equal(executorRoleWorkItems!.engineer.workItems[0].reviewerRoleId, 'cto')
assert.equal(executorRoleWorkItems!.engineer.aggregatedStatus, 'waiting')
// Sanity: an unknown phase falls back to ``queued`` (safest "not started"
// label) rather than throwing — a backend with a temporarily stale enum
// shouldn't crash the panel.
const sessionWithBadPhase = mapBackendSession({
task_id: 'task-2',
channel_id: 'session:task-2',
status: 'running',
role_work_items: {
engineer: {
role_key: 'engineer',
role_id: 'engineer',
role_name: 'Engineer',
runtime_status: 'idle',
aggregated_status: 'pending',
work_items: [
{
work_item_id: 'wi-bad',
work_item_projection_id: 'proj-x',
phase: 'totally_made_up',
kanban_column: 'todo',
title: 'Mystery item',
created_at: 10.0,
updated_at: 10.0,
progress_log: [],
},
],
},
},
})
const fallbackRow = sessionWithBadPhase.roleWorkItems!.engineer.workItems[0]
assert.equal(fallbackRow.phase, 'queued')
// Empty / missing payload normalizes to ``undefined`` so the consumer can
// branch on ``.roleWorkItems != null`` without falsy-checking against {}.
const sessionWithoutPayload = mapBackendSession({
task_id: 'task-3',
channel_id: 'session:task-3',
status: 'running',
})
assert.equal(sessionWithoutPayload.roleWorkItems, undefined)
assert.equal(sessionWithoutPayload.executorRoleWorkItems, undefined)
const sessionWithEmptyPayload = mapBackendSession({
task_id: 'task-4',
channel_id: 'session:task-4',
status: 'running',
role_work_items: {},
executor_role_work_items: {},
})
assert.equal(sessionWithEmptyPayload.roleWorkItems, undefined)
assert.equal(sessionWithEmptyPayload.executorRoleWorkItems, undefined)
console.log('roleWorkItems deserialization checks passed')
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict'
import { getRuntimeOrgView, normalizeOrgInfoPayload } from './runtimeOrg'
const payload = normalizeOrgInfoPayload({
roles: [],
employees: [],
channels: [],
connectors: [],
company_profile: 'corporate',
organization_id: 'quantum_harbor',
organization_name: 'Quantum Harbor Research Studio',
organization_config_file: 'company_orgs/org_quantum_harbor_config.yaml',
org_version: 2,
runtime_topology_version: 2,
runtime_teams: [
{
cell_id: 'cell-2',
manager_role_id: 'mgr-2',
member_role_ids: ['role-b'],
status: 'idle',
},
],
runtime_seats: [
{
role_session_id: 'seat-2',
role_id: 'role-b',
employee_id: 'emp-2',
status: 'cold',
},
],
work_items: [
{
work_item_id: 'wi-2',
role_id: 'role-b',
cell_id: 'cell-2',
title: 'Runtime work',
kind: 'execute',
phase: 'ready',
metadata: {
adaptive: {
normalized_state: 'waiting_for_gate',
blocked_reason: 'Waiting for required signals: implementation_ready',
},
},
},
],
frontier: {
status: 'paused',
total_work_items: 1,
},
project_run: {
run_id: 'run-modern',
lifecycle_status: 'deliverable',
current_revision: 2,
},
project_dossier: {
latest_deliverable_summary: 'Ship candidate ready',
open_issues: ['Need QA sign-off'],
},
seat_digests: [
{
seat_id: 'seat-2',
team_id: 'cell-2',
manager_digest: {
pending_decisions: [{ subject: 'Approve release' }],
},
},
],
revision_links: [
{
link_id: 'link-1',
session_id: 'session-new',
linked_session_id: 'session-old',
link_type: 'revision_of',
},
],
})
assert.equal(payload.runtime_teams?.[0]?.cell_id, 'cell-2')
assert.equal(payload.runtime_seats?.[0]?.role_session_id, 'seat-2')
assert.equal(payload.work_items?.[0]?.work_item_id, 'wi-2')
assert.equal(payload.frontier?.status, 'paused')
assert.equal(payload.organization_id, 'quantum_harbor')
assert.equal(payload.organization_name, 'Quantum Harbor Research Studio')
assert.equal(payload.organization_config_file, 'company_orgs/org_quantum_harbor_config.yaml')
assert.equal(payload.project_run?.run_id, 'run-modern')
assert.equal(payload.project_dossier?.open_issues?.[0], 'Need QA sign-off')
assert.equal(payload.seat_digests?.[0]?.seat_id, 'seat-2')
assert.equal(payload.revision_links?.[0]?.link_type, 'revision_of')
const view = getRuntimeOrgView(payload)
assert.equal(view.runtimeTeams[0]?.cell_id, 'cell-2')
assert.equal(view.runtimeSeats[0]?.role_session_id, 'seat-2')
assert.equal(view.workItems[0]?.work_item_id, 'wi-2')
assert.equal(view.workItems[0]?.adaptive?.normalized_state, 'waiting_for_gate')
assert.equal(view.frontier.status, 'paused')
assert.equal(view.projectRun?.current_revision, 2)
assert.equal(view.projectDossier?.latest_deliverable_summary, 'Ship candidate ready')
assert.equal(view.seatDigests[0]?.seat_id, 'seat-2')
assert.equal(view.revisionLinks[0]?.link_type, 'revision_of')
console.log('runtimeOrg contract checks passed')
@@ -0,0 +1,227 @@
import type {
OrgInfoPayload,
ProjectDossierInfo,
ProjectRunInfo,
RuntimeFrontierSummary,
RuntimeSeatInfo,
RuntimeTeamInfo,
RuntimeWorkItemInfo,
SeatDigestInfo,
SessionLinkInfo,
} from '../types/visual'
type RecordLike = Record<string, unknown>
function isRecord(value: unknown): value is RecordLike {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function asRecordArray<T extends RecordLike = RecordLike>(value: unknown): T[] {
return Array.isArray(value) ? (value.filter(isRecord) as T[]) : []
}
function asStringList(value: unknown): string[] {
return Array.isArray(value)
? value.map((item) => String(item ?? '').trim()).filter(Boolean)
: []
}
function asRuntimeFrontierSummary(value: unknown): RuntimeFrontierSummary {
if (!isRecord(value)) return {}
return {
run_id: typeof value.run_id === 'string' ? value.run_id : undefined,
status: typeof value.status === 'string' ? value.status : undefined,
total_cells: typeof value.total_cells === 'number' ? value.total_cells : undefined,
total_role_sessions: typeof value.total_role_sessions === 'number' ? value.total_role_sessions : undefined,
total_work_items: typeof value.total_work_items === 'number' ? value.total_work_items : undefined,
ready_count: typeof value.ready_count === 'number' ? value.ready_count : undefined,
running_count: typeof value.running_count === 'number' ? value.running_count : undefined,
blocked_count: typeof value.blocked_count === 'number' ? value.blocked_count : undefined,
waiting_count: typeof value.waiting_count === 'number' ? value.waiting_count : undefined,
done_count: typeof value.done_count === 'number' ? value.done_count : undefined,
failed_count: typeof value.failed_count === 'number' ? value.failed_count : undefined,
}
}
function mapRuntimeTeam(raw: RecordLike): RuntimeTeamInfo {
return {
cell_id: String(raw.cell_id ?? raw.team_id ?? raw.id ?? '').trim(),
team_instance_id: typeof raw.team_instance_id === 'string' ? raw.team_instance_id : undefined,
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
manager_role_id: String(raw.manager_role_id ?? '').trim(),
member_role_ids: asStringList(raw.member_role_ids),
seat_ids: asStringList(raw.seat_ids),
parent_team_id: typeof raw.parent_team_id === 'string' ? raw.parent_team_id : undefined,
status: String(raw.status ?? 'idle'),
is_final_decider_cell: typeof raw.is_final_decider_cell === 'boolean' ? raw.is_final_decider_cell : undefined,
}
}
function mapRuntimeSeat(raw: RecordLike): RuntimeSeatInfo {
return {
role_session_id: String(raw.role_session_id ?? raw.id ?? '').trim(),
role_id: String(raw.role_id ?? '').trim(),
employee_id: String(raw.employee_id ?? '').trim(),
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
team_instance_id: typeof raw.team_instance_id === 'string' ? raw.team_instance_id : undefined,
seat_id: typeof raw.seat_id === 'string' ? raw.seat_id : undefined,
focused_work_item_id: typeof raw.focused_work_item_id === 'string' ? raw.focused_work_item_id : undefined,
current_work_item_id: typeof raw.current_work_item_id === 'string' ? raw.current_work_item_id : undefined,
background_work_item_ids: asStringList(raw.background_work_item_ids),
manager_role_ids: asStringList(raw.manager_role_ids),
manager_seat_id: typeof raw.manager_seat_id === 'string' ? raw.manager_seat_id : undefined,
resident_status: typeof raw.resident_status === 'string' ? raw.resident_status : undefined,
latest_notification: isRecord(raw.latest_notification) ? raw.latest_notification : undefined,
manager_digest: isRecord(raw.manager_digest) ? raw.manager_digest : undefined,
status: String(raw.status ?? 'cold'),
}
}
function mapRuntimeWorkItem(raw: RecordLike): RuntimeWorkItemInfo {
const metadata = isRecord(raw.metadata) ? raw.metadata : undefined
const adaptive = isRecord(raw.adaptive)
? raw.adaptive
: metadata && isRecord(metadata.adaptive)
? metadata.adaptive
: undefined
return {
work_item_id: String(raw.work_item_id ?? raw.id ?? '').trim(),
role_id: String(raw.role_id ?? '').trim(),
cell_id: String(raw.cell_id ?? '').trim(),
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
team_instance_id: typeof raw.team_instance_id === 'string' ? raw.team_instance_id : undefined,
seat_id: typeof raw.seat_id === 'string' ? raw.seat_id : undefined,
title: String(raw.title ?? ''),
kind: String(raw.kind ?? 'execute'),
phase: String(raw.phase ?? 'ready'),
kanban_column: String(raw.kanban_column ?? 'todo'),
batch_id: typeof raw.batch_id === 'string' ? raw.batch_id : undefined,
batch_index: typeof raw.batch_index === 'number' ? raw.batch_index : undefined,
deliverable_summary: typeof raw.deliverable_summary === 'string' ? raw.deliverable_summary : undefined,
blocked_reason: typeof raw.blocked_reason === 'string' ? raw.blocked_reason : undefined,
handoff_status: typeof raw.handoff_status === 'string' ? raw.handoff_status : undefined,
parent_work_item_id: typeof raw.parent_work_item_id === 'string' ? raw.parent_work_item_id : undefined,
work_item_projection_id: typeof raw.work_item_projection_id === 'string' ? raw.work_item_projection_id : undefined,
metadata,
adaptive,
}
}
function asProjectRunInfo(value: unknown): ProjectRunInfo | undefined {
if (!isRecord(value)) return undefined
return {
run_id: typeof value.run_id === 'string' ? value.run_id : undefined,
project_id: typeof value.project_id === 'string' ? value.project_id : undefined,
session_id: typeof value.session_id === 'string' ? value.session_id : undefined,
status: typeof value.status === 'string' ? value.status : undefined,
lifecycle_status: typeof value.lifecycle_status === 'string' ? value.lifecycle_status : undefined,
company_profile: typeof value.company_profile === 'string' ? value.company_profile : undefined,
execution_model: typeof value.execution_model === 'string' ? value.execution_model : undefined,
current_revision: typeof value.current_revision === 'number' ? value.current_revision : undefined,
latest_deliverable_summary: typeof value.latest_deliverable_summary === 'string' ? value.latest_deliverable_summary : undefined,
recovery_pointer: isRecord(value.recovery_pointer) ? value.recovery_pointer : undefined,
}
}
function asProjectDossierInfo(value: unknown): ProjectDossierInfo | undefined {
if (!isRecord(value)) return undefined
return {
project_id: typeof value.project_id === 'string' ? value.project_id : undefined,
run_id: typeof value.run_id === 'string' ? value.run_id : undefined,
latest_deliverable_summary: typeof value.latest_deliverable_summary === 'string' ? value.latest_deliverable_summary : undefined,
architecture_decisions: asRecordArray(value.architecture_decisions),
completed_work_items: asRecordArray(value.completed_work_items),
open_issues: asStringList(value.open_issues),
verification_summary: typeof value.verification_summary === 'string' ? value.verification_summary : undefined,
artifact_index: asRecordArray(value.artifact_index),
handoff_summaries: asRecordArray(value.handoff_summaries),
last_failure_summary: typeof value.last_failure_summary === 'string' ? value.last_failure_summary : undefined,
project_memory_excerpt: typeof value.project_memory_excerpt === 'string' ? value.project_memory_excerpt : undefined,
session_memory_excerpt: typeof value.session_memory_excerpt === 'string' ? value.session_memory_excerpt : undefined,
}
}
function mapSeatDigest(raw: RecordLike): SeatDigestInfo {
return {
seat_id: String(raw.seat_id ?? raw.id ?? '').trim(),
team_id: typeof raw.team_id === 'string' ? raw.team_id : undefined,
role_id: typeof raw.role_id === 'string' ? raw.role_id : undefined,
employee_id: typeof raw.employee_id === 'string' ? raw.employee_id : undefined,
role_session_id: typeof raw.role_session_id === 'string' ? raw.role_session_id : undefined,
resident_status: typeof raw.resident_status === 'string' ? raw.resident_status : undefined,
current_work_item: isRecord(raw.current_work_item) ? raw.current_work_item : undefined,
latest_notification: isRecord(raw.latest_notification) ? raw.latest_notification : undefined,
manager_digest: isRecord(raw.manager_digest) ? raw.manager_digest : undefined,
}
}
function mapSessionLink(raw: RecordLike): SessionLinkInfo {
return {
link_id: typeof raw.link_id === 'string' ? raw.link_id : undefined,
session_id: typeof raw.session_id === 'string' ? raw.session_id : undefined,
linked_session_id: typeof raw.linked_session_id === 'string' ? raw.linked_session_id : undefined,
link_type: typeof raw.link_type === 'string' ? raw.link_type : undefined,
metadata: isRecord(raw.metadata) ? raw.metadata : undefined,
created_at: typeof raw.created_at === 'string' ? raw.created_at : undefined,
}
}
export function normalizeOrgInfoPayload(raw: OrgInfoPayload | RecordLike | null | undefined): OrgInfoPayload {
const source = isRecord(raw) ? raw : {}
const runtimeTeams = asRecordArray(source.runtime_teams).map(mapRuntimeTeam)
const runtimeSeats = asRecordArray(source.runtime_seats).map(mapRuntimeSeat)
const workItems = asRecordArray(source.work_items).map(mapRuntimeWorkItem)
return {
roles: Array.isArray(source.roles) ? source.roles as OrgInfoPayload['roles'] : [],
employees: Array.isArray(source.employees) ? source.employees as OrgInfoPayload['employees'] : [],
company_profile: typeof source.company_profile === 'string' ? source.company_profile : '',
organization_id: typeof source.organization_id === 'string' ? source.organization_id : undefined,
organization_name: typeof source.organization_name === 'string' ? source.organization_name : undefined,
organization_config_file: typeof source.organization_config_file === 'string' ? source.organization_config_file : undefined,
final_decider_role_id: typeof source.final_decider_role_id === 'string' ? source.final_decider_role_id : null,
top_level_role_ids: asStringList(source.top_level_role_ids),
runtime_teams: runtimeTeams,
runtime_seats: runtimeSeats,
work_items: workItems,
frontier: asRuntimeFrontierSummary(source.frontier),
project_run: asProjectRunInfo(source.project_run),
project_dossier: asProjectDossierInfo(source.project_dossier),
seat_digests: asRecordArray(source.seat_digests).map(mapSeatDigest),
revision_links: asRecordArray(source.revision_links).map(mapSessionLink),
project_recovery: isRecord(source.project_recovery) ? source.project_recovery : undefined,
channels: Array.isArray(source.channels) ? source.channels as OrgInfoPayload['channels'] : [],
connectors: Array.isArray(source.connectors) ? source.connectors as OrgInfoPayload['connectors'] : [],
org_version: typeof source.org_version === 'number' ? source.org_version : 0,
runtime_topology_version: typeof source.runtime_topology_version === 'number' ? source.runtime_topology_version : 0,
installed_packages: Array.isArray(source.installed_packages) ? source.installed_packages as NonNullable<OrgInfoPayload['installed_packages']> : [],
runtime_policy: isRecord(source.runtime_policy) ? source.runtime_policy as NonNullable<OrgInfoPayload['runtime_policy']> : undefined,
}
}
export interface RuntimeOrgView {
runtimeTeams: RuntimeTeamInfo[]
runtimeSeats: RuntimeSeatInfo[]
workItems: RuntimeWorkItemInfo[]
frontier: RuntimeFrontierSummary
projectRun?: ProjectRunInfo
projectDossier?: ProjectDossierInfo
seatDigests: SeatDigestInfo[]
revisionLinks: SessionLinkInfo[]
projectRecovery?: Record<string, unknown>
}
export function getRuntimeOrgView(payload: OrgInfoPayload | null | undefined): RuntimeOrgView {
const normalized = normalizeOrgInfoPayload(payload)
return {
runtimeTeams: normalized.runtime_teams ?? [],
runtimeSeats: normalized.runtime_seats ?? [],
workItems: normalized.work_items ?? [],
frontier: normalized.frontier ?? {},
projectRun: normalized.project_run,
projectDossier: normalized.project_dossier,
seatDigests: normalized.seat_digests ?? [],
revisionLinks: normalized.revision_links ?? [],
projectRecovery: normalized.project_recovery,
}
}
@@ -0,0 +1,51 @@
import type { Session } from '../types/kanban'
export type CanonicalSessionExecMode = 'task' | 'company' | 'org'
export type CanonicalCompanyProfile = 'corporate' | 'custom'
export function normalizeSessionExecMode(value?: string | null): CanonicalSessionExecMode {
const normalized = String(value ?? '').trim().toLowerCase()
if (normalized === 'company') return 'company'
if (normalized === 'org' || normalized === 'custom') return 'org'
return 'task'
}
export function normalizeSessionCompanyProfile(value?: string | null): CanonicalCompanyProfile {
return String(value ?? '').trim().toLowerCase() === 'custom' ? 'custom' : 'corporate'
}
export function canonicalizeSessionExecutionIdentity<T extends Partial<Session>>(session: T): T {
const rawMode = String(session.execMode ?? '').trim().toLowerCase()
const rawProfile = String(session.companyProfile ?? '').trim().toLowerCase()
const rawOrgId = String(session.orgId ?? '').trim()
const hasExplicitMode = rawMode.length > 0
const execMode: CanonicalSessionExecMode = hasExplicitMode
? normalizeSessionExecMode(rawMode)
: (rawProfile === 'custom' || rawOrgId ? 'org' : normalizeSessionExecMode(rawMode))
if (execMode === 'org') {
return {
...session,
execMode: 'org',
companyProfile: 'custom',
orgId: rawOrgId || undefined,
}
}
if (execMode === 'company') {
return {
...session,
execMode: 'company',
companyProfile: 'corporate',
orgId: undefined,
}
}
return {
...session,
execMode: 'task',
companyProfile: undefined,
orgId: undefined,
}
}
@@ -0,0 +1,67 @@
import type { ChatMessage } from '../types/chat'
/** Channel id that buckets a session's chat messages. */
export function sessionChannelId(taskId: string): string {
return `session:${taskId}`
}
const RECRUITMENT_CHECKPOINT_TYPES = new Set([
'company_recruitment_confirmation',
'company_staffing_selection',
])
/**
* Map `role_id -> recruited person/template display names` for a single chat
* session, derived from that session's latest recruitment checkpoint message.
*
* This is display-only plumbing: org_info / `data.employees` is global (the
* project's active run / accumulated org config), so it cannot represent the
* recruitment of an arbitrary *selected* session. The recruitment a user
* confirmed in a session is persisted as a chat checkpoint message, which is
* the only per-session source the frontend already has.
*
* Returns `null` when the session has no recruitment checkpoint loaded, so the
* caller can fall back to the global employee list (previous behaviour).
*/
export function extractSessionRecruitmentByRole(
messages: ChatMessage[],
): Record<string, string[]> | null {
let latest: ChatMessage | null = null
for (const m of messages) {
const ct = m.metadata?.checkpoint_type
if (!ct || !RECRUITMENT_CHECKPOINT_TYPES.has(ct)) continue
if (!latest || (m.timestamp ?? 0) >= (latest.timestamp ?? 0)) latest = m
}
if (!latest?.metadata) return null
const meta = latest.metadata
const map: Record<string, string[]> = {}
const push = (roleId: unknown, name: unknown) => {
const r = String(roleId ?? '').trim()
const n = String(name ?? '').trim()
if (!r || !n) return
const arr = map[r] ?? (map[r] = [])
if (!arr.includes(n)) arr.push(n)
}
// Preferred: recruitment_rationales is already a flat per-role display label
// (this is exactly the "(role_id, role, recruited name, reason)" list the
// user sees in the recruitment confirmation panel).
for (const r of meta.recruitment_rationales ?? []) {
if (r?.selection_label) push(r.role_id, r.selection_label)
}
// Fallback: derive a name from the structured proposals when rationales are
// absent (older payloads / staffing-only checkpoints).
if (Object.keys(map).length === 0) {
for (const p of meta.proposals ?? []) {
const name =
p?.existing_employee?.employee_name ||
p?.candidate?.proposed_name ||
p?.candidate?.template_name ||
''
push(p?.role_id, name)
}
}
return Object.keys(map).length ? map : null
}
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import type { Session } from '../types/kanban'
import {
companyRuntimeControlPatchForBoardStatus,
isCompanyRuntimeSession,
} from './sessionRuntime'
function makeSession(overrides: Partial<Session>): Partial<Session> {
return {
taskId: 'task-a',
status: 'idle',
execMode: 'task',
...overrides,
}
}
assert.equal(
isCompanyRuntimeSession(makeSession({ execMode: 'org' })),
true,
'org sessions are company runtime sessions',
)
assert.equal(
isCompanyRuntimeSession(makeSession({ execMode: 'company' })),
true,
'company sessions are company runtime sessions',
)
assert.equal(
isCompanyRuntimeSession(makeSession({ parentSessionId: 'root-session' })),
true,
'child runtime sessions are company runtime sessions',
)
assert.equal(
isCompanyRuntimeSession(makeSession({ execMode: 'task', companyProfile: undefined })),
false,
'plain task sessions are not company runtime sessions',
)
assert.equal(
isCompanyRuntimeSession(makeSession({ execMode: 'task', companyProfile: 'corporate' })),
false,
'explicit task sessions with legacy default companyProfile stay plain task sessions',
)
assert.deepEqual(
companyRuntimeControlPatchForBoardStatus(makeSession({ execMode: 'org' }), 'running'),
{ runtimeControlState: 'running', canStop: true, canResume: false },
'company running board events must show Stop immediately',
)
assert.deepEqual(
companyRuntimeControlPatchForBoardStatus(
makeSession({ execMode: 'org', runtimeControlState: 'running', canStop: true }),
'done',
),
{ runtimeControlState: 'idle', canStop: false, canResume: false },
'company terminal board events must clear Stop after completion',
)
assert.deepEqual(
companyRuntimeControlPatchForBoardStatus(
makeSession({ execMode: 'org', runtimeControlState: 'suspended', canResume: true }),
'cancelled',
),
{},
'terminal child events must not erase an explicit suspended/continue state',
)
assert.deepEqual(
companyRuntimeControlPatchForBoardStatus(makeSession({ execMode: 'task' }), 'running'),
{},
'plain task board events must not opt into company runtime control',
)
console.log('sessionRuntime.test.ts: OK (company board status drives Stop without breaking Continue)')
@@ -0,0 +1,74 @@
import type { AgentAnimStatus, Session } from '../types/kanban'
export const TERMINAL_SESSION_STATUSES = new Set(['done', 'failed', 'cancelled'])
const COMPANY_EXEC_MODES = new Set(['company', 'org', 'custom'])
const BOARD_STATUS_IDLE = new Set(['idle', 'done', 'failed', 'cancelled'])
export function getSessionRuntimeStatus(
session: Pick<Session, 'status' | 'agentStatus' | 'runtimeControlState'>,
): AgentAnimStatus {
if (TERMINAL_SESSION_STATUSES.has(session.status)) return 'idle'
if (session.agentStatus === 'reflecting' || session.agentStatus === 'tool_active') {
return session.agentStatus
}
if (
session.runtimeControlState
&& session.runtimeControlState !== 'running'
&& session.runtimeControlState !== 'suspending'
&& session.runtimeControlState !== 'resuming'
) {
return 'idle'
}
if (session.status === 'running') return 'reflecting'
return 'idle'
}
export function isSessionWorking(
session: Pick<Session, 'status' | 'agentStatus' | 'runtimeControlState'>,
): boolean {
return getSessionRuntimeStatus(session) !== 'idle'
}
export function isCompanyRuntimeSession(session?: Partial<Session> | null): boolean {
if (!session) return false
const mode = String(session.execMode ?? '').trim().toLowerCase()
const hasExplicitTaskMode = mode === 'task'
return COMPANY_EXEC_MODES.has(mode)
|| !!session.isCompanyRuntime
|| !!session.parentSessionId
|| !!session.orgId
|| !!session.workItemProjectionId
|| !!session.roleWorkItems
|| !!session.executorRoleWorkItems
|| (!!session.companyProfile && !hasExplicitTaskMode)
}
export function companyRuntimeControlPatchForBoardStatus(
session: Partial<Session> | undefined | null,
status: string,
): Partial<Pick<Session, 'runtimeControlState' | 'canStop' | 'canResume'>> {
if (!isCompanyRuntimeSession(session)) return {}
const normalizedStatus = String(status ?? '').trim().toLowerCase()
if (normalizedStatus === 'running') {
return {
runtimeControlState: 'running',
canStop: true,
canResume: false,
}
}
if (BOARD_STATUS_IDLE.has(normalizedStatus)) {
const existingState = session?.runtimeControlState
if (existingState === 'suspending' || existingState === 'suspended') {
return {}
}
return {
runtimeControlState: 'idle',
canStop: false,
canResume: false,
}
}
return {}
}
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
import { compactSessionTitle } from './sessionTitle'
assert.equal(
compactSessionTitle('one two three four five six seven eight nine ten eleven'),
'one two three four five six seven eight nine ten...',
)
assert.equal(
compactSessionTitle('请你帮我设计实现一个后端管理系统'),
'请你帮我设计实现一个...',
)
assert.equal(
compactSessionTitle('Build API 请实现登录流程 and tests now'),
'Build API 请实现登录流程 and...',
)
assert.equal(compactSessionTitle('Short title'), 'Short title')
assert.equal(compactSessionTitle(' '), 'New Chat')
console.log('sessionTitle.test.ts: OK (session titles compact to 10 mixed units)')
@@ -0,0 +1,66 @@
function isCjkCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x3400 && codePoint <= 0x4dbf)
|| (codePoint >= 0x4e00 && codePoint <= 0x9fff)
|| (codePoint >= 0xf900 && codePoint <= 0xfaff)
|| (codePoint >= 0x3040 && codePoint <= 0x30ff)
|| (codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
function isCjkChar(ch: string): boolean {
const codePoint = ch.codePointAt(0)
return typeof codePoint === 'number' && isCjkCodePoint(codePoint)
}
function isWordChar(ch: string): boolean {
return !isCjkChar(ch) && /^[\p{L}\p{N}_]$/u.test(ch)
}
export function compactSessionTitle(input: string, maxUnits = 10, fallback = 'New Chat'): string {
const text = String(input || '').replace(/\s+/g, ' ').trim()
if (!text || maxUnits <= 0) return fallback
let units = 0
let index = 0
let cutIndex = text.length
while (index < text.length) {
const codePoint = text.codePointAt(index)
if (typeof codePoint !== 'number') break
const ch = String.fromCodePoint(codePoint)
if (/\s/.test(ch)) {
index += ch.length
continue
}
if (isCjkChar(ch)) {
units += 1
index += ch.length
if (units === maxUnits) {
cutIndex = index
break
}
continue
}
if (isWordChar(ch)) {
while (index < text.length) {
const innerCodePoint = text.codePointAt(index)
if (typeof innerCodePoint !== 'number') break
const innerChar = String.fromCodePoint(innerCodePoint)
if (!isWordChar(innerChar)) break
index += innerChar.length
}
units += 1
if (units === maxUnits) {
cutIndex = index
break
}
continue
}
index += ch.length
}
const compact = text.slice(0, cutIndex).trim() || fallback
const hasMoreUnits = Array.from(text.slice(cutIndex)).some(ch => isCjkChar(ch) || isWordChar(ch))
return hasMoreUnits ? `${compact}...` : compact
}
@@ -0,0 +1,21 @@
import type { ChatStoreState } from '../chat/ChatStore'
import type { KanbanTask } from '../types/kanban'
export function notifyTaskAssigned(
chatStore: ChatStoreState,
task: KanbanTask,
agentNames: string[],
officeChannelId?: string,
) {
const names = agentNames.join(', ')
const channelId = officeChannelId ?? `session:${task.id}`
const targetCh = chatStore.channels.find(ch => ch.id === channelId) ?? chatStore.channels.find(ch => ch.type === 'activity')
if (!targetCh) return
chatStore.sendMessage({
channelId: targetCh.id,
sender: 'system',
senderName: 'System',
content: `Task **${task.displayId}** assigned to ${names}`,
metadata: { type: 'task_assigned', taskId: task.id, boardId: task.boardId },
})
}
@@ -0,0 +1,32 @@
import type { EmployeeAssignment } from '../types/kanban'
type WorkItemIdentity = {
workItemRoleId?: string
workItemRoleName?: string
employeeAssignment?: EmployeeAssignment
}
export function humanizeWorkItemRoleId(value?: string): string {
const normalized = (value ?? '').trim()
if (!normalized) return ''
return normalized
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
export function getWorkItemRoleLabel(identity: WorkItemIdentity): string {
const explicit = (identity.workItemRoleName ?? '').trim()
if (explicit) return explicit
return humanizeWorkItemRoleId(identity.workItemRoleId)
}
export function getWorkItemEmployeeLabel(identity: WorkItemIdentity): string {
return (identity.employeeAssignment?.name ?? '').trim()
}
export function getWorkItemAssignmentLabel(identity: WorkItemIdentity): string {
const role = getWorkItemRoleLabel(identity)
const employee = getWorkItemEmployeeLabel(identity)
if (role && employee) return `${role} · ${employee}`
return role || employee
}
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import { mapBackendSession, mapBackendTask } from './collabSync'
import { getExecutionTurnId, getLinkedRuntimeTaskId, getWorkItemCardId } from './workItemRuntimeIds'
const workItemCard = mapBackendTask({
task_id: 'wi-1',
work_item_id: 'wi-1',
title: 'Prepare launch plan',
kanban_column: 'in-progress',
runtime_task_id: 'runtime-task-1',
execution_turn_id: 'runtime-task-1',
})
assert.equal(getWorkItemCardId(workItemCard), 'wi-1')
assert.equal(getLinkedRuntimeTaskId(workItemCard), 'runtime-task-1')
assert.equal(workItemCard.runtimeTaskId, 'runtime-task-1')
assert.equal(workItemCard.executionTurnId, 'runtime-task-1')
const canonicalCard = mapBackendTask({
work_item_id: 'wi-2',
title: 'Review launch plan',
kanban_column: 'in-review',
runtime_task_id: 'runtime-task-2',
execution_turn_id: 'runtime-task-2',
})
assert.equal(getWorkItemCardId(canonicalCard), 'wi-2')
assert.equal(getLinkedRuntimeTaskId(canonicalCard), 'runtime-task-2')
const plainTaskCard = mapBackendTask({
task_id: 'plain-task-1',
title: 'Plain task',
kanban_column: 'todo',
runtime_task_id: 'plain-task-1',
})
assert.equal(plainTaskCard.runtimeTaskId, 'plain-task-1')
assert.equal(getLinkedRuntimeTaskId(plainTaskCard), '')
const runtimeSession = mapBackendSession({
task_id: 'legacy-task-id',
runtime_task_id: 'runtime-task-3',
execution_turn_id: 'turn-3',
channel_id: 'session:runtime-task-3',
title: 'CTO Execution Turn',
})
assert.equal(runtimeSession.taskId, 'legacy-task-id')
assert.equal(runtimeSession.runtimeTaskId, 'runtime-task-3')
assert.equal(runtimeSession.executionTurnId, 'turn-3')
assert.equal(getExecutionTurnId(runtimeSession), 'turn-3')
const taskSessionWithRuntimeParentMetadata = mapBackendSession({
project_id: 'default',
task_id: 'task-mode-session',
session_id: 'task-session-id',
parent_session_id: 'task-session-id',
channel_id: 'session:task-mode-session',
title: 'Task mode session detail',
exec_mode: 'task',
})
assert.equal(taskSessionWithRuntimeParentMetadata.execMode, 'task')
assert.equal(taskSessionWithRuntimeParentMetadata.mode, 'primary')
assert.equal(taskSessionWithRuntimeParentMetadata.parentSessionId, undefined)
console.log('workItemRuntimeIds alias checks passed')
@@ -0,0 +1,34 @@
import type { KanbanTask, Session, WorkItemProgressEntry } from '../types/kanban'
type WorkItemCardLike = Pick<KanbanTask, 'id' | 'workItemId'>
type ExecutionTurnLike =
| Pick<Session, 'taskId' | 'runtimeTaskId' | 'executionTurnId'>
| Pick<WorkItemProgressEntry, 'runtimeTaskId' | 'executionTurnId'>
type LinkedRuntimeLike = Pick<KanbanTask, 'runtimeTaskId' | 'executionTurnId'>
& Pick<KanbanTask, 'workItemId'>
function clean(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
export function getWorkItemCardId(card: WorkItemCardLike | null | undefined): string {
return clean(card?.workItemId) || clean(card?.id)
}
export function getExecutionTurnId(turn: ExecutionTurnLike | null | undefined): string {
if (!turn) return ''
const raw = turn as Partial<Session & WorkItemProgressEntry>
return (
clean(raw.executionTurnId)
|| clean(raw.runtimeTaskId)
|| clean(raw.taskId)
)
}
export function getLinkedRuntimeTaskId(card: LinkedRuntimeLike | null | undefined): string {
return clean(card?.workItemId)
? clean(card?.executionTurnId) || clean(card?.runtimeTaskId)
: ''
}
@@ -0,0 +1,372 @@
import assert from 'node:assert/strict'
import type { ChatMessage } from '../types/chat'
import type { Session } from '../types/kanban'
import { mapBackendSession } from './collabSync'
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
import { deriveCompanyRuntimeDisplayStatus, getConversationHeaderSession, getConversationSessionView, getWorkItemChildSessions, getWorkItemRoleSessions, mergeConversationMessages, projectSessionConversation } from './workItemSessions'
function makeSession(overrides: Partial<Session> & Pick<Session, 'taskId' | 'channelId' | 'title' | 'status' | 'columnId' | 'assigneeIds' | 'priority' | 'tags' | 'progressLog' | 'createdAt' | 'updatedAt' | 'messageCount'>): Session {
return {
projectId: 'test-project',
...overrides,
}
}
const parent = makeSession({
taskId: 'root-task',
channelId: 'session:root-task',
title: 'Root',
status: 'running',
columnId: 'in-progress',
assigneeIds: [],
priority: null,
tags: [],
progressLog: [],
createdAt: 1,
updatedAt: 10,
messageCount: 1,
mode: 'primary',
originTaskId: 'root-task',
})
const child = makeSession({
taskId: 'child-task',
channelId: 'session:child-task',
title: 'CEO Intake',
status: 'done',
columnId: 'done',
assigneeIds: ['ceo'],
priority: null,
tags: [],
progressLog: [],
createdAt: 2,
updatedAt: 11,
messageCount: 2,
mode: 'child',
parentSessionId: 'parent-session-id-that-has-not-arrived-yet',
originTaskId: 'root-task',
})
const matches = getWorkItemChildSessions(parent, [parent, child])
assert.equal(matches.length, 1)
assert.equal(matches[0]?.taskId, 'child-task')
const companyParent = makeSession({
taskId: 'company-root',
channelId: 'session:company-root',
sessionId: 'company-root-session',
title: 'Work-Item Runtime Root',
status: 'running',
columnId: 'in-progress',
assigneeIds: [],
priority: null,
tags: [],
progressLog: [],
createdAt: 1,
updatedAt: 10,
messageCount: 1,
mode: 'primary',
execMode: 'company',
originTaskId: 'company-root',
})
const companyChild = makeSession({
taskId: 'company-child',
channelId: 'session:company-child',
title: 'CTO Delegation',
status: 'running',
columnId: 'in-progress',
assigneeIds: ['cto'],
priority: null,
tags: [],
progressLog: [],
createdAt: 2,
updatedAt: 25,
messageCount: 4,
mode: 'child',
parentSessionId: 'company-root-session',
originTaskId: 'company-root',
})
const companyProjection = projectSessionConversation(companyParent, [companyChild])
assert.deepEqual(
companyProjection.timelineSessions.map((session) => session.taskId),
['company-root', 'company-child'],
)
assert.equal(companyProjection.displaySession?.taskId, 'company-root')
assert.equal(companyProjection.runtimeSession?.taskId, 'company-child')
assert.equal(companyProjection.projectedFromChild, false)
const idleChildWithNewerInboxTimestamp = makeSession({
...companyChild,
taskId: 'company-idle-child',
channelId: 'session:company-idle-child',
title: 'Idle Child Inbox Update',
updatedAt: 1_000_000,
messageCount: 0,
progressLog: [],
})
const stableCompanyProjection = projectSessionConversation(companyParent, [idleChildWithNewerInboxTimestamp])
assert.equal(stableCompanyProjection.runtimeSession?.taskId, 'company-root')
const companyHeaderView = getConversationHeaderSession(
{
...companyParent,
workItemRoleName: 'CEO',
employeeAssignment: {
name: 'Root Employee',
category: 'leadership',
},
},
{
...companyChild,
workItemRoleName: 'CTO',
contextTokens: 0,
contextWindow: 128000,
inputTokens: 11,
outputTokens: 22,
totalTokens: 33,
turnCostUsd: 0.001,
sessionCostUsd: 0.002,
selectedExecutionAgent: 'codex',
employeeAssignment: {
name: 'Child Employee',
category: 'engineering',
},
},
[companyParent, companyChild],
)
assert.equal(companyHeaderView?.taskId, 'company-root')
assert.equal(companyHeaderView?.workItemRoleName, 'CEO')
assert.equal(companyHeaderView?.employeeAssignment?.name, 'Root Employee')
const resultMessage = (id: string, channelId: string, content: string, metadata: ChatMessage['metadata'], sender = 'chao'): ChatMessage => ({
id,
channelId,
sender,
senderName: sender === 'system' ? 'Company Member' : 'Chao',
content,
timestamp: 1000 + id.length,
mentions: [],
metadata,
})
const finalBody = 'Final delivery is ready with a long enough body to be considered the same user-visible result across transcript mirrors and worker notifications.'
const mergedDeliveryMessages = mergeConversationMessages([
[
resultMessage(
'opc-top-level',
'session:company-root',
finalBody,
{ source: 'engine', transcript_kind: 'top_level_reply' },
'assistant',
),
],
[
resultMessage(
'parent-mirror',
'session:company-root',
`**Deliver final result to user: Chao Intake**: ${finalBody}`,
{ source: 'engine', transcript_kind: 'child_result' },
),
],
[
resultMessage(
'child-direct',
'session:company-child',
finalBody,
{ source: 'engine', transcript_kind: 'child_task_result' },
),
resultMessage(
'worker-note',
'session:company-child',
finalBody,
{ source: 'runtime_event', kind: 'worker_notification', notification_kind: 'task_complete' },
'system',
),
],
])
assert.equal(mergedDeliveryMessages.length, 1)
assert.equal(mergedDeliveryMessages[0]?.id, 'child-direct')
assert.equal(companyHeaderView?.status, 'running')
assert.equal(companyHeaderView?.contextTokens, 0)
assert.equal(companyHeaderView?.contextWindow, 128000)
assert.equal(companyHeaderView?.inputTokens, 11)
assert.equal(companyHeaderView?.outputTokens, 22)
assert.equal(companyHeaderView?.totalTokens, 33)
assert.equal(companyHeaderView?.turnCostUsd, 0.001)
assert.equal(companyHeaderView?.sessionCostUsd, 0.002)
assert.equal(companyHeaderView?.selectedExecutionAgent, 'codex')
const customOrgRoot = makeSession({
taskId: 'custom-root',
channelId: 'session:custom-root',
sessionId: 'custom-root-session',
title: 'Custom Work-Item Runtime Root',
status: 'running',
columnId: 'in-progress',
assigneeIds: ['chief_architect'],
priority: null,
tags: [],
progressLog: [],
createdAt: 1,
updatedAt: 30,
messageCount: 1,
mode: 'primary',
execMode: 'custom',
isCompanyRuntime: true,
originTaskId: 'custom-root',
workItemRoleId: 'chief_architect',
workItemRoleName: 'Chief Architect',
})
const customOrgChild = makeSession({
taskId: 'custom-child',
channelId: 'session:custom-child',
title: 'Research Lead Turn',
status: 'running',
columnId: 'in-progress',
assigneeIds: ['research_lead'],
priority: null,
tags: [],
progressLog: [],
createdAt: 2,
updatedAt: 31,
messageCount: 2,
mode: 'child',
parentSessionId: 'custom-root-session',
originTaskId: 'custom-root',
workItemRoleId: 'research_lead',
workItemRoleName: 'Research Lead',
})
assert.deepEqual(
getWorkItemChildSessions(customOrgRoot, [customOrgRoot, customOrgChild]).map((session) => session.taskId),
['custom-child'],
)
assert.deepEqual(
getWorkItemRoleSessions(customOrgRoot, [customOrgRoot, customOrgChild]).map((session) => session.workItemRoleName),
['Chief Architect', 'Research Lead'],
)
const mergedCustomView = getConversationSessionView(
{
...customOrgRoot,
status: 'failed',
roleWorkItems: {
chief_architect: {
roleKey: 'chief_architect',
roleId: 'chief_architect',
roleName: 'Chief Architect',
runtimeStatus: 'idle',
aggregatedStatus: 'active',
workItems: [],
},
},
},
{
...customOrgChild,
runtimeControlState: 'running',
canStop: true,
},
[customOrgRoot, customOrgChild],
)
assert.equal(mergedCustomView?.runtimeControlState, 'running')
assert.equal(mergedCustomView?.canStop, true)
assert.equal(mergedCustomView?.roleWorkItems?.chief_architect.roleName, 'Chief Architect')
assert.equal(mergedCustomView?.status, 'running')
assert.equal(deriveCompanyRuntimeDisplayStatus(mergedCustomView), 'running')
const failedCustomView = getConversationSessionView(
{
...customOrgRoot,
status: 'failed',
roleWorkItems: {
chief_architect: {
roleKey: 'chief_architect',
roleId: 'chief_architect',
roleName: 'Chief Architect',
runtimeStatus: 'idle',
aggregatedStatus: 'failed',
workItems: [],
},
},
},
null,
[],
)
assert.equal(failedCustomView?.status, 'failed')
const runtimeRollupOverridesStaleActiveView = getConversationSessionView(
{
...customOrgRoot,
status: 'failed',
roleWorkItems: {
chief_architect: {
roleKey: 'chief_architect',
roleId: 'chief_architect',
roleName: 'Chief Architect',
runtimeStatus: 'idle',
aggregatedStatus: 'failed',
workItems: [],
},
},
},
{
...customOrgRoot,
status: 'failed',
roleWorkItems: {
chief_architect: {
roleKey: 'chief_architect',
roleId: 'chief_architect',
roleName: 'Chief Architect',
runtimeStatus: 'tool_active',
aggregatedStatus: 'active',
workItems: [],
},
},
},
[],
)
assert.equal(runtimeRollupOverridesStaleActiveView?.status, 'running')
const companyIdentity = canonicalizeSessionExecutionIdentity({
taskId: 'company-stale-org',
execMode: 'company',
companyProfile: 'custom',
orgId: 'quantum_harbor',
})
assert.equal(companyIdentity.execMode, 'company')
assert.equal(companyIdentity.companyProfile, 'corporate')
assert.equal(companyIdentity.orgId, undefined)
const customIdentity = canonicalizeSessionExecutionIdentity({
taskId: 'custom-org',
execMode: 'org',
companyProfile: 'corporate',
orgId: 'quantum_harbor',
})
assert.equal(customIdentity.execMode, 'org')
assert.equal(customIdentity.companyProfile, 'custom')
assert.equal(customIdentity.orgId, 'quantum_harbor')
const mappedCompanySession = mapBackendSession({
task_id: 'mapped-company',
channel_id: 'session:mapped-company',
title: 'Mapped Company',
status: 'running',
column_id: 'in-progress',
assignee_ids: [],
tags: [],
created_at: 1,
updated_at: 2,
exec_mode: 'company',
company_profile: 'custom',
org_id: 'quantum_harbor',
})
assert.equal(mappedCompanySession.execMode, 'company')
assert.equal(mappedCompanySession.companyProfile, 'corporate')
assert.equal(mappedCompanySession.orgId, undefined)
console.log('workItemSessions origin-task linking checks passed')
@@ -0,0 +1,507 @@
import type { ChatMessage } from '../types/chat'
import type { ProgressEntry, Session } from '../types/kanban'
import { getContextUsageMetrics } from './contextUsage'
import { isSessionWorking } from './sessionRuntime'
const CONTEXT_TOKENS_RE = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+tokens/i
const USED_PCT_RE = /(\d{1,3})%\s*used/i
const REMAINING_PCT_RE = /(\d{1,3})%\s*remaining/i
function compactWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
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 resultSurfacePriority(message: ChatMessage): number {
const meta = (message.metadata ?? {}) as Record<string, unknown>
const transcriptKind = String(meta.transcript_kind ?? meta.kind ?? '').trim()
switch (transcriptKind) {
case 'child_task_result':
return 80
case 'child_task_result_retry':
return 79
case 'company_role_result':
return 75
case 'company_role_result_retry':
return 74
case 'child_result':
return 70
case 'runtime_v2_assistant':
return 60
case 'runtime_v2_company_assistant':
return 20
case 'top_level_reply':
return 40
default:
break
}
if (String(meta.kind ?? '').trim() === 'worker_notification') {
return 20
}
return 0
}
function resultSurfaceDedupeKey(message: ChatMessage): string {
if (resultSurfacePriority(message) <= 0) return ''
const content = compactWhitespace(stripNarrativeTitlePrefix(message.content)).slice(0, 2000)
return content ? `result:${content}` : ''
}
function parseProgressNumber(value: string | undefined): number | undefined {
if (!value) return undefined
const normalized = value.replace(/,/g, '').trim()
if (!normalized) return undefined
const parsed = Number(normalized)
return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : undefined
}
function clampPct(value: number | undefined): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.min(Math.round(value), 100))
}
export function deriveCompanyRuntimeDisplayStatus(session: Session | null | undefined): string | undefined {
if (!session) return undefined
const execMode = String(session.execMode ?? '').trim()
const isCompanyLike = (
execMode === 'company'
|| execMode === 'org'
|| execMode === 'custom'
|| !!session.isCompanyRuntime
|| !!session.roleWorkItems
|| !!session.executorRoleWorkItems
)
if (!isCompanyLike) return undefined
const summaries = Object.values(session.executorRoleWorkItems ?? session.roleWorkItems ?? {})
if (summaries.length === 0) return undefined
const statuses = summaries.map(summary => summary.aggregatedStatus)
if (statuses.some(status => status === 'active')) return 'running'
if (statuses.some(status => status === 'waiting')) return 'pending'
if (statuses.some(status => status === 'pending')) return 'pending'
if (statuses.every(status => status === 'failed')) return 'failed'
if (statuses.every(status => status === 'done')) return 'done'
if (statuses.some(status => status === 'done')) return 'done'
return undefined
}
function deriveContextFromProgressLog(progressLog: ProgressEntry[] | undefined): {
contextTokens?: number
contextWindow?: number
contextRemainingPct?: number
} {
const derived: {
contextTokens?: number
contextWindow?: number
contextRemainingPct?: number
} = {}
for (const entry of progressLog ?? []) {
const text = `${entry.summary ?? ''} ${entry.detail ?? ''}`.trim()
if (!text) continue
const tokenMatch = CONTEXT_TOKENS_RE.exec(text)
if (tokenMatch) {
const usedTokens = parseProgressNumber(tokenMatch[1])
const windowTokens = parseProgressNumber(tokenMatch[2])
if (typeof usedTokens === 'number') derived.contextTokens = usedTokens
if (typeof windowTokens === 'number' && windowTokens > 0) derived.contextWindow = windowTokens
}
const remainingMatch = REMAINING_PCT_RE.exec(text)
if (remainingMatch) {
derived.contextRemainingPct = clampPct(parseProgressNumber(remainingMatch[1]))
continue
}
const usedMatch = USED_PCT_RE.exec(text)
if (usedMatch) {
const usedPct = clampPct(parseProgressNumber(usedMatch[1]))
if (typeof usedPct === 'number') derived.contextRemainingPct = 100 - usedPct
}
}
return derived
}
function withDerivedSessionRuntime(session: Session): Session {
const derivedContext = deriveContextFromProgressLog(session.progressLog)
const contextTokens = session.contextTokens ?? derivedContext.contextTokens
const contextWindow = session.contextWindow ?? derivedContext.contextWindow
const contextRemainingPct = session.contextRemainingPct ?? derivedContext.contextRemainingPct
if (
contextTokens === session.contextTokens
&& contextWindow === session.contextWindow
&& contextRemainingPct === session.contextRemainingPct
) {
return session
}
return {
...session,
contextTokens,
contextWindow,
contextRemainingPct,
}
}
function uniqueSessionsByTaskId(sessions: Session[]): Session[] {
const seen = new Set<string>()
const unique: Session[] = []
for (const session of sessions) {
if (seen.has(session.taskId)) continue
seen.add(session.taskId)
unique.push(session)
}
return unique
}
function hasWorkItemRoleIdentity(session: Session | null | undefined): boolean {
return !!(
String(session?.workItemRoleId ?? '').trim()
|| String(session?.workItemRoleName ?? '').trim()
)
}
export function getWorkItemChildSessions(activeSession: Session | null, sessions: Session[]): Session[] {
if (!activeSession || activeSession.mode === 'child') return []
const parentKeys = new Set<string>()
if (activeSession.sessionId) parentKeys.add(activeSession.sessionId)
parentKeys.add(activeSession.taskId)
const activeOriginTaskId = String(activeSession.originTaskId ?? activeSession.taskId ?? '').trim()
const executionTurnOrder = new Map<string, number>()
for (const entry of activeSession.workItemLog ?? []) {
const taskId = entry.executionTurnId || entry.runtimeTaskId
if (taskId && !executionTurnOrder.has(taskId)) {
executionTurnOrder.set(taskId, executionTurnOrder.size)
}
}
const seen = new Set<string>()
const matches = sessions.filter((session) => {
if (session.taskId === activeSession.taskId) return false
if (seen.has(session.taskId)) return false
const linkedByParent = !!session.parentSessionId && parentKeys.has(session.parentSessionId)
const linkedByWorkItem = executionTurnOrder.has(session.taskId)
const linkedByOrigin = !!activeOriginTaskId && String(session.originTaskId ?? '').trim() === activeOriginTaskId
if (!linkedByParent && !linkedByWorkItem && !linkedByOrigin) return false
seen.add(session.taskId)
return true
})
return matches.sort((a, b) => {
const aOrder = executionTurnOrder.get(a.taskId)
const bOrder = executionTurnOrder.get(b.taskId)
if (aOrder != null && bOrder != null && aOrder !== bOrder) return aOrder - bOrder
if (aOrder != null && bOrder == null) return -1
if (aOrder == null && bOrder != null) return 1
return b.updatedAt - a.updatedAt
})
}
export function getWorkItemRoleSessions(activeSession: Session | null, sessions: Session[]): Session[] {
const childSessions = getWorkItemChildSessions(activeSession, sessions)
if (!activeSession || activeSession.mode === 'child') return childSessions
if (!hasWorkItemRoleIdentity(activeSession)) return childSessions
return uniqueSessionsByTaskId([activeSession, ...childSessions])
}
export function getConversationPeerSessions(activeSession: Session | null, sessions: Session[]): Session[] {
if (!activeSession || activeSession.mode === 'child') return []
const activeSessionId = String(activeSession.sessionId ?? '').trim()
if (!activeSessionId) return []
const seen = new Set<string>()
return sessions.filter((session) => {
if (session.taskId === activeSession.taskId) return false
if (seen.has(session.taskId)) return false
if (String(session.sessionId ?? '').trim() !== activeSessionId) return false
seen.add(session.taskId)
return true
}).sort((a, b) => b.updatedAt - a.updatedAt)
}
function isSummaryConversationSession(session: Session | null | undefined): boolean {
const execMode = String(session?.execMode ?? '').trim()
return execMode === 'company' || execMode === 'org' || execMode === 'custom'
}
function projectedDisplayScore(session: Session): number {
const workingBonus = isSessionWorking(session) ? 1_000_000_000 : 0
const draftBonus = String(session.draftAssistantText ?? '').trim() ? 10_000_000_000 : 0
const messageBonus = Math.max(0, session.messageCount ?? 0) * 10_000
const progressBonus = (session.progressLog?.length ?? 0) * 100
return draftBonus + workingBonus + messageBonus + progressBonus + session.updatedAt
}
function runtimeProjectionScore(session: Session): number {
const contextUsage = getContextUsageMetrics(session)
const workingBonus = isSessionWorking(session) ? 100_000_000_000 : 0
const draftBonus = String(session.draftAssistantText ?? '').trim() ? 10_000_000_000 : 0
const contextBonus = (
typeof contextUsage.usedPct === 'number'
|| typeof contextUsage.usedTokens === 'number'
|| typeof contextUsage.windowTokens === 'number'
) ? 10_000_000_000 : 0
const tokenBonus = (
typeof session.inputTokens === 'number'
|| typeof session.outputTokens === 'number'
|| typeof session.totalTokens === 'number'
) ? 1_000_000_000 : 0
const messageBonus = Math.max(0, session.messageCount ?? 0) * 10_000
const progressBonus = (session.progressLog?.length ?? 0) * 100
return workingBonus + draftBonus + contextBonus + tokenBonus + messageBonus + progressBonus
}
export interface SessionConversationProjection {
timelineSessions: Session[]
displaySession: Session | null
runtimeSession: Session | null
projectedFromChild: boolean
}
export function projectSessionConversation(
activeSession: Session | null,
relatedSessions: Session[],
): SessionConversationProjection {
if (!activeSession) {
return {
timelineSessions: [],
displaySession: null,
runtimeSession: null,
projectedFromChild: false,
}
}
const normalizedActiveSession = withDerivedSessionRuntime(activeSession)
if (activeSession.mode === 'child' || relatedSessions.length === 0) {
return {
timelineSessions: [normalizedActiveSession],
displaySession: normalizedActiveSession,
runtimeSession: normalizedActiveSession,
projectedFromChild: false,
}
}
const normalizedRelatedSessions = relatedSessions.map(withDerivedSessionRuntime)
const visibleRelatedSessions = normalizedRelatedSessions.filter((session) => session.status !== 'cancelled')
const projectedSessions = visibleRelatedSessions.length > 0 ? visibleRelatedSessions : normalizedRelatedSessions
const timelineSessions = uniqueSessionsByTaskId([normalizedActiveSession, ...projectedSessions])
const summaryConversation = isSummaryConversationSession(activeSession)
const projectedDisplaySession = [...timelineSessions].sort(
(a, b) => projectedDisplayScore(b) - projectedDisplayScore(a),
)[0] ?? normalizedActiveSession
const displaySession = summaryConversation
? normalizedActiveSession
: projectedDisplaySession
const runtimeSession = [...timelineSessions].sort(
(a, b) => runtimeProjectionScore(b) - runtimeProjectionScore(a),
)[0] ?? displaySession
return {
timelineSessions,
displaySession,
runtimeSession,
projectedFromChild: !summaryConversation && displaySession.taskId !== activeSession.taskId,
}
}
export function getConversationSessionView(
activeSession: Session | null,
runtimeSession: Session | null,
timelineSessions: Session[],
): Session | null {
if (!activeSession) return null
const normalizedActiveSession = withDerivedSessionRuntime(activeSession)
const runtimeSource = runtimeSession ?? normalizedActiveSession
const companyDisplayStatus = deriveCompanyRuntimeDisplayStatus(runtimeSource)
?? deriveCompanyRuntimeDisplayStatus(normalizedActiveSession)
const mergedMessageCount = getConversationMessageCount(
timelineSessions.length > 0 ? timelineSessions : [normalizedActiveSession],
)
return {
...normalizedActiveSession,
status: companyDisplayStatus ?? (runtimeSource.status || normalizedActiveSession.status),
assigneeIds: runtimeSource.assigneeIds.length > 0
? runtimeSource.assigneeIds
: normalizedActiveSession.assigneeIds,
agentStatus: runtimeSource.agentStatus ?? normalizedActiveSession.agentStatus,
currentTool: runtimeSource.currentTool ?? normalizedActiveSession.currentTool,
displayTool: runtimeSource.displayTool ?? runtimeSource.currentTool ?? normalizedActiveSession.displayTool ?? normalizedActiveSession.currentTool,
toolElapsedMs: runtimeSource.toolElapsedMs ?? normalizedActiveSession.toolElapsedMs,
lastToolSummary: runtimeSource.lastToolSummary ?? normalizedActiveSession.lastToolSummary,
contextTokens: runtimeSource.contextTokens ?? normalizedActiveSession.contextTokens,
contextWindow: runtimeSource.contextWindow ?? normalizedActiveSession.contextWindow,
contextRemainingPct: runtimeSource.contextRemainingPct ?? normalizedActiveSession.contextRemainingPct,
inputTokens: runtimeSource.inputTokens ?? normalizedActiveSession.inputTokens,
outputTokens: runtimeSource.outputTokens ?? normalizedActiveSession.outputTokens,
totalTokens: runtimeSource.totalTokens ?? normalizedActiveSession.totalTokens,
turnCostUsd: runtimeSource.turnCostUsd ?? normalizedActiveSession.turnCostUsd,
sessionCostUsd: runtimeSource.sessionCostUsd ?? normalizedActiveSession.sessionCostUsd,
pendingPermissionCount: runtimeSource.pendingPermissionCount ?? normalizedActiveSession.pendingPermissionCount,
drainMode: runtimeSource.drainMode ?? normalizedActiveSession.drainMode,
workItemProjectionId: runtimeSource.workItemProjectionId ?? normalizedActiveSession.workItemProjectionId,
workItemTurnType: runtimeSource.workItemTurnType ?? normalizedActiveSession.workItemTurnType,
companyProfile: normalizedActiveSession.companyProfile ?? runtimeSource.companyProfile,
workItemRoleId: normalizedActiveSession.workItemRoleId ?? runtimeSource.workItemRoleId,
workItemRoleName: normalizedActiveSession.workItemRoleName ?? runtimeSource.workItemRoleName,
workItemGate: normalizedActiveSession.workItemGate ?? runtimeSource.workItemGate,
employeeAssignment: normalizedActiveSession.employeeAssignment ?? runtimeSource.employeeAssignment,
selectedExecutionAgent: runtimeSource.selectedExecutionAgent ?? normalizedActiveSession.selectedExecutionAgent,
draftAssistantText: runtimeSource.draftAssistantText ?? normalizedActiveSession.draftAssistantText,
draftUpdatedAt: runtimeSource.draftUpdatedAt ?? normalizedActiveSession.draftUpdatedAt,
draftIteration: runtimeSource.draftIteration ?? normalizedActiveSession.draftIteration,
draftTurnId: runtimeSource.draftTurnId ?? normalizedActiveSession.draftTurnId,
runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState,
canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop,
canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume,
resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId,
resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId,
pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId,
stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId,
updatedAt: Math.max(
normalizedActiveSession.updatedAt,
runtimeSource.updatedAt,
),
messageCount: Math.max(
normalizedActiveSession.messageCount ?? 0,
runtimeSource.messageCount ?? 0,
mergedMessageCount,
),
isCompanyRuntime: !!(normalizedActiveSession.isCompanyRuntime || runtimeSource.isCompanyRuntime),
workItemLog: (normalizedActiveSession.workItemLog?.length ?? 0) > 0
? normalizedActiveSession.workItemLog
: runtimeSource.workItemLog,
roleWorkItems: normalizedActiveSession.roleWorkItems ?? runtimeSource.roleWorkItems,
executorRoleWorkItems: normalizedActiveSession.executorRoleWorkItems ?? runtimeSource.executorRoleWorkItems,
activeSubagents: normalizedActiveSession.activeSubagents ?? runtimeSource.activeSubagents,
permissionRequests: normalizedActiveSession.permissionRequests ?? runtimeSource.permissionRequests,
}
}
export function getConversationHeaderSession(
activeSession: Session | null,
runtimeSession: Session | null,
timelineSessions: Session[],
): Session | null {
if (!activeSession) return null
const normalizedActiveSession = withDerivedSessionRuntime(activeSession)
const runtimeSource = runtimeSession ?? normalizedActiveSession
const companyDisplayStatus = deriveCompanyRuntimeDisplayStatus(runtimeSource)
?? deriveCompanyRuntimeDisplayStatus(normalizedActiveSession)
const mergedMessageCount = getConversationMessageCount(
timelineSessions.length > 0 ? timelineSessions : [normalizedActiveSession],
)
return {
...normalizedActiveSession,
status: companyDisplayStatus ?? (runtimeSource.status || normalizedActiveSession.status),
agentStatus: runtimeSource.agentStatus ?? normalizedActiveSession.agentStatus,
currentTool: runtimeSource.currentTool ?? normalizedActiveSession.currentTool,
displayTool: runtimeSource.displayTool ?? runtimeSource.currentTool ?? normalizedActiveSession.displayTool ?? normalizedActiveSession.currentTool,
toolElapsedMs: runtimeSource.toolElapsedMs ?? normalizedActiveSession.toolElapsedMs,
lastToolSummary: runtimeSource.lastToolSummary ?? normalizedActiveSession.lastToolSummary,
contextTokens: runtimeSource.contextTokens ?? normalizedActiveSession.contextTokens,
contextWindow: runtimeSource.contextWindow ?? normalizedActiveSession.contextWindow,
contextRemainingPct: runtimeSource.contextRemainingPct ?? normalizedActiveSession.contextRemainingPct,
inputTokens: runtimeSource.inputTokens ?? normalizedActiveSession.inputTokens,
outputTokens: runtimeSource.outputTokens ?? normalizedActiveSession.outputTokens,
totalTokens: runtimeSource.totalTokens ?? normalizedActiveSession.totalTokens,
turnCostUsd: runtimeSource.turnCostUsd ?? normalizedActiveSession.turnCostUsd,
sessionCostUsd: runtimeSource.sessionCostUsd ?? normalizedActiveSession.sessionCostUsd,
pendingPermissionCount: runtimeSource.pendingPermissionCount ?? normalizedActiveSession.pendingPermissionCount,
drainMode: runtimeSource.drainMode ?? normalizedActiveSession.drainMode,
selectedExecutionAgent: runtimeSource.selectedExecutionAgent ?? normalizedActiveSession.selectedExecutionAgent,
runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState,
canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop,
canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume,
resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId,
resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId,
pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId,
stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId,
updatedAt: Math.max(normalizedActiveSession.updatedAt, runtimeSource.updatedAt),
messageCount: Math.max(
normalizedActiveSession.messageCount ?? 0,
runtimeSource.messageCount ?? 0,
mergedMessageCount,
),
}
}
export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatMessage[] {
const seen = new Set<string>()
const resultKeyIndex = new Map<string, number>()
const merged: ChatMessage[] = []
for (const group of messageGroups) {
for (const message of group) {
const metadata = (message.metadata ?? {}) as Record<string, unknown>
const uiMessageId = typeof metadata.ui_message_id === 'string'
? metadata.ui_message_id.trim()
: ''
const resultKey = resultSurfaceDedupeKey(message)
if (resultKey) {
const existingIndex = resultKeyIndex.get(resultKey)
if (existingIndex !== undefined) {
if (resultSurfacePriority(message) > resultSurfacePriority(merged[existingIndex])) {
merged[existingIndex] = message
}
continue
}
resultKeyIndex.set(resultKey, merged.length)
}
const dedupeKey = resultKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
merged.push(message)
}
}
return merged.sort((a, b) => (
a.timestamp === b.timestamp
? a.id.localeCompare(b.id)
: a.timestamp - b.timestamp
))
}
export function mergeConversationProgressLog(timelineSessions: Session[]): ProgressEntry[] {
const seen = new Set<string>()
const merged: ProgressEntry[] = []
for (const session of timelineSessions) {
for (const entry of session.progressLog ?? []) {
const dedupeKey = `${entry.timestamp}:${entry.type}:${entry.summary}:${entry.detail ?? ''}`
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
merged.push(entry)
}
}
return merged.sort((a, b) => a.timestamp - b.timestamp)
}
export function getConversationMessageCount(timelineSessions: Session[]): number {
return timelineSessions.reduce(
(total, session) => total + Math.max(0, session.messageCount ?? 0),
0,
)
}
@@ -0,0 +1,856 @@
import type {
AgentRuntimePayload,
EmployeeDetailPayload,
KanbanViewDataPayload,
OrgInfoPayload,
OrgCreateMemberInput,
OrgSavedCreatePayload,
ReorgListPayload,
SavedOrgSummary,
SessionProgressPayload,
SocketEnvelope,
SocketStatus,
TalentListPayload,
VisualEvent,
VisualSnapshot,
WorkerNotificationPayload,
WorkItemProgressPayload,
} from '../types/visual'
import type { CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
import type { TaskPreferredAgent } from '../types/kanban'
interface SocketHandlers {
onSnapshot?: (snapshot: VisualSnapshot) => void
onEvent?: (event: VisualEvent) => void
onAck?: (payload: Record<string, unknown>) => void
onStatus?: (status: SocketStatus, detail?: string) => void
onChannelCreated?: (payload: { channel_id: string; name: string; channel_type: string; participants: string[] }) => void
onBoardEvent?: (payload: Record<string, unknown>) => void
onCrossOfficeCollab?: (payload: { agent_ids: string[]; task_id: string; action: string }) => void
onCollabMessage?: (type: string, payload: Record<string, unknown>) => void
onAgentRuntimeUpdate?: (payload: AgentRuntimePayload) => void
onWorkerNotification?: (payload: WorkerNotificationPayload) => void
onKanbanViewData?: (payload: KanbanViewDataPayload) => void
onSessionCreated?: (payload: { project_id: string; task_id: string; channel_id: string; session_id?: string; parent_session_id?: string; origin_task_id?: string; title: string; status: string; created_at: number; assignee_ids?: string[]; exec_mode?: string; company_profile?: string; org_id?: string; organization_id?: string; preferred_agent?: TaskPreferredAgent; selected_execution_agent?: TaskPreferredAgent }) => void
onSessionUpdated?: (payload: { project_id: string; task_id: string; exec_mode?: string; company_profile?: string; org_id?: string; organization_id?: string; preferred_agent?: TaskPreferredAgent; selected_execution_agent?: TaskPreferredAgent }) => void
onSessionMessage?: (payload: Record<string, unknown>) => void
onSessionTitleUpdated?: (payload: { project_id: string; task_id: string; title: string }) => void
onSessionDeleted?: (payload: { project_id: string; task_id: string }) => void
onSessionProgress?: (payload: SessionProgressPayload) => void
onChildSessionCreated?: (payload: { project_id: string; session_id: string; parent_session_id: string; task_id: string; origin_task_id?: string; title: string; agent_id?: string; org_id?: string; organization_id?: string; selected_execution_agent?: TaskPreferredAgent }) => void
onProjectSwitched?: (payload: { project_id: string; switch_seq?: string }) => void
onProjectDeleted?: (payload: { project_id: string }) => void
onOrgInfo?: (payload: OrgInfoPayload) => void
onRecoveryStatus?: (payload: any) => void
onRecoveryResult?: (payload: any) => void
onTalentList?: (payload: TalentListPayload) => void
onTalentScanLocal?: (payload: { templates: Array<{ template_id: string; name: string; description: string; category: string; domains: string[]; tags: string[] }> }) => void
onEmployeeDetail?: (payload: EmployeeDetailPayload) => void
onReorgList?: (payload: ReorgListPayload) => void
onWorkItemProgress?: (payload: WorkItemProgressPayload) => void
onMarketListInstalled?: (payload: { packages: Array<Record<string, unknown>> }) => void
onMarketBrowse?: (payload: { presets: Array<Record<string, unknown>> }) => void
onMarketPreview?: (payload: Record<string, unknown>) => void
onOrgConfigExport?: (payload: { yaml: string }) => void
onOrgConfigImport?: (payload: { ok: boolean; dry_run?: boolean; preview?: { roles_added: number; roles_removed: number; employees_changed: number }; error?: string; validation_errors?: string[] }) => void
onOrgSavedList?: (payload: { orgs: SavedOrgSummary[]; active_name?: string | null }) => void
onOrgSavedSaveAs?: (payload: { ok: boolean; name: string; error?: string }) => void
onOrgSavedCreate?: (payload: OrgSavedCreatePayload) => void
onOrgSavedLoad?: (payload: { ok: boolean; name: string; error?: string }) => void
onOrgSavedDelete?: (payload: { ok: boolean; name: string; error?: string }) => void
onCommsState?: (payload: CommsStatePayload) => void
onCommsMessage?: (payload: CommsMessagePayload) => void
}
export interface CommsMessageItem {
message_id: string
from: string
to?: string
subject: string
sent_at: string
blocking: boolean
path: string
bucket?: 'new' | 'seen' | 'sent'
}
/** @deprecated Use CommsMessageItem instead */
export type CommsRecentUnread = CommsMessageItem
export interface CommsRolePayload {
role_id: string
unread_count: number
has_blocking: boolean
seen_count: number
outbox_count: number
recent_unread: CommsMessageItem[]
recent_seen?: CommsMessageItem[]
recent_outbox?: CommsMessageItem[]
}
export interface CommsMeetingPayload {
meeting_id: string
topic: string
status: string
organizer: string
participants: string[]
entry_count: number
opened_at: string
closed_at?: string | null
decision?: string | null
transcript_path: string
}
export interface CommsFailurePayload {
operation: string
from_role: string
to_role: string
reason: string
attempted_path?: string
attempted_command?: string
recorded_at?: string
attempt_count?: number
can_retry?: boolean
}
export interface CommsStatePayload {
available: boolean
reason?: string
empty?: boolean
project_id?: string
session_id?: string
workspace_root?: string
output_root?: string
comms_root?: string
projection_status?: string
recent_failures?: CommsFailurePayload[]
roles?: CommsRolePayload[]
meetings?: CommsMeetingPayload[]
}
export interface CommsMessagePayload {
project_id: string
path: string
header: {
from?: string
to?: string
sent_at?: string
blocking?: boolean
[key: string]: unknown
}
body: string
}
const RECONNECT_BASE_MS = 2000
const RECONNECT_MAX_MS = 30000
const RECONNECT_MAX_ATTEMPTS = 20
const PENDING_QUEUE_MAX = 100
const HEARTBEAT_INTERVAL_MS = 30_000
const HEARTBEAT_TIMEOUT_MS = 10_000
const PROJECT_SCOPED_MESSAGE_TYPES = new Set([
'collab_sync',
'kanban_create_board',
'kanban_create_task',
'kanban_update_task',
'kanban_move_task',
'kanban_delete_board',
'kanban_delete_task',
'kanban_assign',
'kanban_status',
'kanban_switch_view',
'run_task',
'create_session',
'session_send',
'session_update_config',
'session_delete',
'session_detail',
'session_stop',
'session_resume',
'session_complete',
'session_update_title',
'secretary_send',
'project_index',
'recovery_action',
'comms_state',
'comms_read_message',
])
export class VisualSocketClient {
private ws: WebSocket | null = null
private reconnectTimer: number | null = null
private closedByUser = false
private reconnectAttempt = 0
private pendingQueue: string[] = []
private heartbeatTimer: number | null = null
private pongTimer: number | null = null
constructor(
private url: string,
private handlers: SocketHandlers,
) {}
updateUrl(url: string): void {
this.url = url
}
connect(): void {
this.closedByUser = false
this.handlers.onStatus?.('connecting')
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
this.reconnectAttempt = 0
this.handlers.onStatus?.('connected')
this.flushPendingQueue()
this.startHeartbeat()
}
this.ws.onmessage = (evt) => {
this.handleMessage(evt.data)
}
this.ws.onerror = () => {
this.handlers.onStatus?.('error', 'WebSocket error')
}
this.ws.onclose = () => {
this.stopHeartbeat()
this.handlers.onStatus?.('disconnected')
this.ws = null
if (!this.closedByUser) {
this.scheduleReconnect()
}
}
}
disconnect(): void {
this.closedByUser = true
this.stopHeartbeat()
if (this.reconnectTimer !== null) {
window.clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.ws?.close()
this.ws = null
}
send(payload: Record<string, unknown>): void {
if (!this.ensureProjectScope(payload)) {
return
}
const data = JSON.stringify(payload)
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
if (this.pendingQueue.length < PENDING_QUEUE_MAX) {
this.pendingQueue.push(data)
}
return
}
this.ws.send(data)
}
// ── Agent management ───────────────────────────────────────────────────
createAgent(role: Record<string, unknown>): void {
this.send({ type: 'create_agent', role })
}
deleteAgent(agentId: string): void {
this.send({ type: 'delete_agent', agent_id: agentId })
}
moveAgent(agentId: string, officeId: string, seatZone?: string): void {
this.send({ type: 'move_agent', agent_id: agentId, office_id: officeId, seat_zone: seatZone })
}
listAgents(): void {
this.send({ type: 'list_agents' })
}
createFromTemplate(templateId: string, name?: string): void {
this.send({ type: 'create_agent', role: { id: templateId, template: templateId, name: name ?? templateId } })
}
// ── Execution mode ─────────────────────────────────────────────────────
setExecutionMode(mode: string, profile?: string, preferredAgent?: TaskPreferredAgent, orgId?: string): void {
this.send({ type: 'set_execution_mode', mode, profile: profile ?? 'corporate', preferred_agent: preferredAgent, org_id: orgId })
}
// ── Kanban integration ─────────────────────────────────────────────────
assignTaskToAgent(projectId: string, taskId: string, agentId: string, taskTitle: string): void {
const pid = this.requireProjectId(projectId, 'kanban_assign')
this.send({ type: 'kanban_assign', task_id: taskId, agent_id: agentId, task_title: taskTitle, project_id: pid })
}
updateTaskStatus(projectId: string, taskId: string, status: string): void {
const pid = this.requireProjectId(projectId, 'kanban_status')
this.send({ type: 'kanban_status', task_id: taskId, status, project_id: pid })
}
kanbanCreateTask(opts: {
board_id: string; column_id: string; title: string;
description?: string; priority?: string;
assignee_ids?: string[]; tags?: string[];
task_id?: string;
project_id: string;
}): void {
const pid = this.requireProjectId(opts.project_id, 'kanban_create_task')
this.send({ type: 'kanban_create_task', ...opts, project_id: pid })
}
kanbanUpdateTask(projectId: string, taskId: string, updates: Record<string, unknown>): void {
const pid = this.requireProjectId(projectId, 'kanban_update_task')
this.send({ type: 'kanban_update_task', task_id: taskId, updates, project_id: pid })
}
kanbanMoveTask(projectId: string, taskId: string, columnId: string, sortOrder = 0): void {
const pid = this.requireProjectId(projectId, 'kanban_move_task')
this.send({ type: 'kanban_move_task', task_id: taskId, column_id: columnId, sort_order: sortOrder, project_id: pid })
}
kanbanDeleteBoard(projectId: string, boardId: string): void {
const pid = this.requireProjectId(projectId, 'kanban_delete_board')
this.send({ type: 'kanban_delete_board', project_id: pid, board_id: boardId })
}
kanbanDeleteTask(projectId: string, taskId: string): void {
const pid = this.requireProjectId(projectId, 'kanban_delete_task')
this.send({ type: 'kanban_delete_task', task_id: taskId, project_id: pid })
}
kanbanSwitchView(projectId: string, level: 'global' | 'office' | 'agent', targetId?: string): void {
const pid = this.requireProjectId(projectId, 'kanban_switch_view')
this.send({ type: 'kanban_switch_view', level, target_id: targetId, project_id: pid })
}
getAgentDetail(agentId: string): void {
this.send({ type: 'get_agent_detail', agent_id: agentId })
}
// ── Collaboration protocol ─────────────────────────────────────────────
collabSync(projectId: string, switchSeq?: string, viewGeneration?: number): void {
const pid = this.requireProjectId(projectId, 'collab_sync')
this.send({ type: 'collab_sync', project_id: pid, switch_seq: switchSeq, view_generation: viewGeneration })
}
projectIndex(projectId: string, switchSeq?: string, viewGeneration?: number): void {
const pid = this.requireProjectId(projectId, 'project_index')
this.send({ type: 'project_index', project_id: pid, switch_seq: switchSeq, view_generation: viewGeneration })
}
// ── Session protocol ───────────────────────────────────────────────────
createSession(projectId: string, title?: string, execMode?: string, companyProfile?: string, preferredAgent?: TaskPreferredAgent, orgId?: string): void {
const pid = this.requireProjectId(projectId, 'create_session')
this.send({
type: 'create_session',
project_id: pid,
title: title ?? 'New Chat',
exec_mode: execMode,
company_profile: companyProfile,
preferred_agent: preferredAgent,
org_id: orgId,
})
}
sessionSend(
projectId: string,
taskId: string,
content: string,
attachments?: OutgoingAttachmentPayload[],
metadata?: CheckpointReplyMetadata,
): void {
const pid = this.requireProjectId(projectId, 'session_send')
this.send({
type: 'session_send',
project_id: pid,
task_id: taskId,
content,
attachments: attachments ?? [],
metadata,
})
}
deleteSession(projectId: string, taskId: string): void {
const pid = this.requireProjectId(projectId, 'session_delete')
this.send({ type: 'session_delete', project_id: pid, task_id: taskId })
}
sessionUpdateTitle(projectId: string, taskId: string, title: string): void {
const pid = this.requireProjectId(projectId, 'session_update_title')
this.send({ type: 'session_update_title', project_id: pid, task_id: taskId, title })
}
sessionUpdateConfig(projectId: string, taskId: string, execMode: string, companyProfile?: string, preferredAgent?: TaskPreferredAgent, orgId?: string): void {
const pid = this.requireProjectId(projectId, 'session_update_config')
this.send({
type: 'session_update_config',
project_id: pid,
task_id: taskId,
exec_mode: execMode,
company_profile: companyProfile,
preferred_agent: preferredAgent,
org_id: orgId,
})
}
sessionStop(projectId: string, taskId: string): void {
const pid = this.requireProjectId(projectId, 'session_stop')
this.send({ type: 'session_stop', project_id: pid, task_id: taskId })
}
sessionResume(projectId: string, taskId: string, content?: string): void {
const pid = this.requireProjectId(projectId, 'session_resume')
this.send({ type: 'session_resume', project_id: pid, task_id: taskId, content })
}
sessionComplete(projectId: string, taskId: string): void {
const pid = this.requireProjectId(projectId, 'session_complete')
this.send({ type: 'session_complete', project_id: pid, task_id: taskId })
}
sessionDetail(
projectId: string,
taskId: string,
opts?: { limit?: number; beforeCreatedAt?: number; beforeMessageId?: string; detailLevel?: 'summary' | 'full'; include?: string[]; viewGeneration?: number },
): void {
const pid = this.requireProjectId(projectId, 'session_detail')
this.send({
type: 'session_detail',
project_id: pid,
task_id: taskId,
limit: opts?.limit,
before_created_at: opts?.beforeCreatedAt,
before_message_id: opts?.beforeMessageId,
detail_level: opts?.detailLevel,
include: opts?.include,
view_generation: opts?.viewGeneration,
})
}
secretarySend(projectId: string, content: string): void {
const pid = this.requireProjectId(projectId, 'secretary_send')
this.send({ type: 'secretary_send', project_id: pid, content })
}
// ── Project management ──────────────────────────────────────────────
listProjects(): void {
this.send({ type: 'list_projects' })
}
createProject(projectId: string): void {
this.send({ type: 'create_project', project_id: this.normalizeProjectId(projectId) })
}
deleteProject(projectId: string): void {
this.send({ type: 'delete_project', project_id: this.normalizeProjectId(projectId) })
}
switchProject(projectId: string, switchSeq?: string): void {
this.send({ type: 'switch_project', project_id: this.normalizeProjectId(projectId), switch_seq: switchSeq })
}
// ── Org info ──────────────────────────────────────────────────────────
orgInfo(): void {
this.send({ type: 'org_info' })
}
// ── Phase 4: Talent Market, Employee Detail, Reorg ───────────────────
talentImport(repoPath: string): void {
this.send({ type: 'talent_import', repo_path: repoPath })
}
talentList(): void {
this.send({ type: 'talent_list' })
}
talentScanLocal(): void {
this.send({ type: 'talent_scan_local' })
}
talentImportSelected(templateIds: string[]): void {
this.send({ type: 'talent_import_selected', template_ids: templateIds })
}
talentHire(templateId: string, roleId: string, employeeName?: string, orgId?: string): void {
this.send({ type: 'talent_hire', template_id: templateId, role_id: roleId, employee_name: employeeName, org_id: orgId })
}
employeeDetail(employeeId: string): void {
this.send({ type: 'employee_detail', employee_id: employeeId })
}
reorgList(): void {
this.send({ type: 'reorg_list' })
}
reorgDecide(proposalId: string, approved: boolean, notes?: string): void {
this.send({ type: 'reorg_decide', proposal_id: proposalId, approved, notes })
}
importEmployeeAsAgent(employeeId: string, officeId?: string): void {
this.send({ type: 'import_employee_as_agent', employee_id: employeeId, office_id: officeId })
}
// ── OPC Market ─────────────────────────────────────────────────────────
marketBrowse(): void {
this.send({ type: 'market_browse' })
}
marketPreview(presetId: string): void {
this.send({ type: 'market_preview', preset_id: presetId })
}
marketApplyPreset(presetId: string, strategy: string = 'namespace'): void {
this.send({ type: 'market_apply_preset', preset_id: presetId, strategy })
}
marketListInstalled(): void {
this.send({ type: 'market_list_installed' })
}
marketExport(data: { package_id: string; name: string; description: string; version: string }): void {
this.send({ type: 'market_export', ...data })
}
marketInstall(path: string, strategy: string = 'namespace'): void {
this.send({ type: 'market_install', path, strategy })
}
marketUninstall(packageId: string): void {
this.send({ type: 'market_uninstall', package_id: packageId })
}
// ── Org Editing ───────────────────────────────────────────────────────
addRole(roleId: string, name: string, responsibility: string, reportsTo: string = 'owner', icon?: string | null): void {
this.send({ type: 'add_role', role_id: roleId, name, responsibility, reports_to: reportsTo, icon: icon || null })
}
bulkAddRoles(roles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string; icon?: string | null }>): void {
this.send({ type: 'bulk_add_roles', roles })
}
updateRole(roleId: string, updates: {
name?: string
responsibility?: string
reports_to?: string
can_spawn?: string[]
icon?: string | null
execution_strategy?: string
preferred_external_agent?: string | null
prompt_refs?: string[]
tools?: string[]
}): void {
this.send({ type: 'update_role', role_id: roleId, ...updates })
}
deleteRole(roleId: string): void {
this.send({ type: 'delete_role', role_id: roleId })
}
updateRuntimePolicy(policy: Record<string, any>): void {
this.send({ type: 'update_runtime_policy', policy })
}
updateOrgStrategy(data: { final_decider_role_id?: string | null }): void {
this.send({ type: 'update_org_strategy', ...data })
}
resetArchitecture(): void {
this.send({ type: 'reset_architecture' })
}
orgConfigExport(): void {
this.send({ type: 'org_config_export' })
}
orgConfigImport(yaml: string, dryRun: boolean): void {
this.send({ type: 'org_config_import', yaml, dry_run: dryRun })
}
orgSavedList(): void {
this.send({ type: 'org_saved_list' })
}
orgSavedSaveAs(name: string, overwrite: boolean): void {
this.send({ type: 'org_saved_save_as', name, overwrite })
}
orgSavedCreate(organizationName: string, members: OrgCreateMemberInput[]): void {
this.send({ type: 'org_saved_create', organization_name: organizationName, members })
}
orgSavedLoad(name: string): void {
this.send({ type: 'org_saved_load', name })
}
orgSavedDelete(name: string): void {
this.send({ type: 'org_saved_delete', name })
}
recoveryAction(projectId: string, action: 'resume' | 'cancel' | 'scan', parentTaskId?: string): void {
const pid = this.requireProjectId(projectId, 'recovery_action')
this.send({ type: 'recovery_action', project_id: pid, action, parent_task_id: parentTaskId })
}
commsState(projectId: string, opts?: { task_id?: string; session_id?: string }): void {
const pid = this.requireProjectId(projectId, 'comms_state')
this.send({ type: 'comms_state', project_id: pid, ...(opts || {}) })
}
commsReadMessage(projectId: string, path: string): void {
const pid = this.requireProjectId(projectId, 'comms_read_message')
this.send({ type: 'comms_read_message', project_id: pid, path })
}
// ── Internal ───────────────────────────────────────────────────────────
private normalizeProjectId(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
private requireProjectId(projectId: unknown, action: string): string {
const pid = this.normalizeProjectId(projectId)
if (!pid) {
throw new Error(`${action} requires non-empty project_id`)
}
return pid
}
private ensureProjectScope(payload: Record<string, unknown>): boolean {
const messageType = typeof payload.type === 'string' ? payload.type : ''
if (!PROJECT_SCOPED_MESSAGE_TYPES.has(messageType)) {
return true
}
const pid = this.normalizeProjectId(payload.project_id ?? payload.projectId)
if (!pid) {
const error = `${messageType} requires non-empty project_id`
console.error(`[wsClient] ${error}`, payload)
this.handlers.onAck?.({ ok: false, error, action: messageType })
return false
}
payload.project_id = pid
return true
}
private handleMessage(raw: unknown): void {
if (typeof raw !== 'string') {
return
}
let parsed: SocketEnvelope | null = null
try {
parsed = JSON.parse(raw) as SocketEnvelope
} catch {
return
}
if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) {
return
}
try { switch (parsed.type) {
case 'snapshot':
this.handlers.onSnapshot?.(parsed.payload)
break
case 'event':
this.handlers.onEvent?.(parsed.payload)
break
case 'ack':
this.handlers.onAck?.(parsed.payload)
break
case 'channel_created':
this.handlers.onChannelCreated?.(parsed.payload)
break
case 'board_task_created':
case 'board_task_moved':
this.handlers.onBoardEvent?.(parsed.payload as unknown as Record<string, unknown>)
break
case 'board_task_status_changed':
case 'execution_mode_resolved':
case 'project_run_updated':
case 'seat_digest_updated':
case 'work_item_batch_updated':
case 'session_runtime_control':
this.handlers.onCollabMessage?.(parsed.type, parsed.payload as Record<string, unknown>)
break
case 'cross_office_collab':
this.handlers.onCrossOfficeCollab?.(parsed.payload)
break
case 'chat_new_message':
case 'chat_channel_created':
case 'kanban_updated':
case 'kanban_board_created':
case 'collab_sync_push':
case 'project_index_push':
this.handlers.onCollabMessage?.(parsed.type, parsed.payload as Record<string, unknown>)
break
case 'agent_runtime_update':
this.handlers.onAgentRuntimeUpdate?.(parsed.payload)
break
case 'worker_notification':
this.handlers.onWorkerNotification?.(parsed.payload as WorkerNotificationPayload)
break
case 'session_progress':
this.handlers.onSessionProgress?.(parsed.payload)
break
case 'work_item_progress':
this.handlers.onWorkItemProgress?.(parsed.payload as unknown as WorkItemProgressPayload)
break
case 'kanban_view_data':
this.handlers.onKanbanViewData?.(parsed.payload)
break
case 'session_created':
this.handlers.onSessionCreated?.(parsed.payload)
break
case 'session_updated':
this.handlers.onSessionUpdated?.(parsed.payload)
break
case 'session_message':
this.handlers.onSessionMessage?.(parsed.payload as Record<string, unknown>)
break
case 'session_title_updated':
this.handlers.onSessionTitleUpdated?.(parsed.payload)
break
case 'session_deleted':
this.handlers.onSessionDeleted?.(parsed.payload)
break
case 'child_session_created':
this.handlers.onChildSessionCreated?.(parsed.payload)
break
case 'project_switched':
this.handlers.onProjectSwitched?.(parsed.payload)
break
case 'project_deleted':
this.handlers.onProjectDeleted?.(parsed.payload)
break
case 'org_info':
this.handlers.onOrgInfo?.(parsed.payload)
break
case 'comms_state':
this.handlers.onCommsState?.(parsed.payload as unknown as CommsStatePayload)
break
case 'comms_message':
this.handlers.onCommsMessage?.(parsed.payload as unknown as CommsMessagePayload)
break
case 'comms_state_dirty':
// Server pushed a "something changed" hint after a comms message
// was sent. Re-issue the snapshot request so the panel updates
// immediately instead of waiting for its polling tick.
try {
const projectId = typeof parsed.payload?.project_id === 'string' ? parsed.payload.project_id : ''
if (projectId) this.commsState(projectId)
} catch { /* ignore */ }
break
case 'recovery_status':
this.handlers.onRecoveryStatus?.(parsed.payload)
break
case 'recovery_result':
this.handlers.onRecoveryResult?.(parsed.payload)
break
case 'talent_list':
this.handlers.onTalentList?.(parsed.payload)
break
case 'talent_scan_local':
this.handlers.onTalentScanLocal?.(parsed.payload)
break
case 'employee_detail':
this.handlers.onEmployeeDetail?.(parsed.payload)
break
case 'reorg_list':
this.handlers.onReorgList?.(parsed.payload)
break
case 'market_list_installed':
this.handlers.onMarketListInstalled?.(parsed.payload as unknown as { packages: Array<Record<string, unknown>> })
break
case 'market_browse':
this.handlers.onMarketBrowse?.(parsed.payload as unknown as { presets: Array<Record<string, unknown>> })
break
case 'market_preview':
this.handlers.onMarketPreview?.(parsed.payload as Record<string, unknown>)
break
case 'org_config_export':
this.handlers.onOrgConfigExport?.(parsed.payload as { yaml: string })
break
case 'org_config_import':
this.handlers.onOrgConfigImport?.(parsed.payload as any)
break
case 'org_saved_list':
this.handlers.onOrgSavedList?.(parsed.payload as { orgs: SavedOrgSummary[]; active_name?: string | null })
break
case 'org_saved_save_as':
this.handlers.onOrgSavedSaveAs?.(parsed.payload as { ok: boolean; name: string; error?: string })
break
case 'org_saved_create':
this.handlers.onOrgSavedCreate?.(parsed.payload as OrgSavedCreatePayload)
break
case 'org_saved_load':
this.handlers.onOrgSavedLoad?.(parsed.payload as { ok: boolean; name: string; error?: string })
break
case 'org_saved_delete':
this.handlers.onOrgSavedDelete?.(parsed.payload as { ok: boolean; name: string; error?: string })
break
case 'pong':
this.handlePong()
break
default:
break
}
} catch (e) { console.error('[wsClient] Error handling message:', parsed.type, e) }
}
private flushPendingQueue(): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
const queued = this.pendingQueue.splice(0)
for (const data of queued) {
this.ws.send(data)
}
}
private startHeartbeat(): void {
this.stopHeartbeat()
this.heartbeatTimer = window.setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(JSON.stringify({ type: 'ping' }))
this.pongTimer = window.setTimeout(() => {
this.pongTimer = null
this.ws?.close()
}, HEARTBEAT_TIMEOUT_MS)
}, HEARTBEAT_INTERVAL_MS)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer !== null) {
window.clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.pongTimer !== null) {
window.clearTimeout(this.pongTimer)
this.pongTimer = null
}
}
private handlePong(): void {
if (this.pongTimer !== null) {
window.clearTimeout(this.pongTimer)
this.pongTimer = null
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer !== null) return
if (this.reconnectAttempt >= RECONNECT_MAX_ATTEMPTS) {
this.handlers.onStatus?.('error', 'max reconnect attempts reached')
return
}
const delay = Math.min(
RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempt),
RECONNECT_MAX_MS,
)
this.reconnectAttempt++
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null
this.connect()
}, delay)
}
}
@@ -0,0 +1,76 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ error: Error | null; info: string }
> {
state = { error: null as Error | null, info: '' }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[ErrorBoundary] React crashed:', error, errorInfo)
this.setState({ info: errorInfo.componentStack ?? '' })
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 32, color: '#ff6b6b', background: '#1a1a2e', minHeight: '100vh', fontFamily: 'monospace' }}>
<h2>UI Error React crashed</h2>
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 13 }}>
{this.state.error.message}
</pre>
<details open style={{ marginTop: 12, fontSize: 12, color: '#ccc' }}>
<summary>Stack trace</summary>
<pre style={{ whiteSpace: 'pre-wrap' }}>{this.state.error.stack}</pre>
</details>
{this.state.info && (
<details style={{ marginTop: 12, fontSize: 12, color: '#888' }}>
<summary>Component stack</summary>
<pre style={{ whiteSpace: 'pre-wrap' }}>{this.state.info}</pre>
</details>
)}
<button
onClick={() => { this.setState({ error: null, info: '' }) }}
style={{ marginTop: 16, padding: '8px 16px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}
>
Try to recover
</button>
</div>
)
}
return this.props.children
}
}
// Global error handler for uncaught JS errors
window.addEventListener('error', (e) => {
console.error('[Global] Uncaught error:', e.error ?? e.message)
})
window.addEventListener('unhandledrejection', (e) => {
console.error('[Global] Unhandled promise rejection:', e.reason)
})
const root = document.getElementById('root')
if (!root) {
document.body.innerHTML = '<h1 style="color:red">Root element not found</h1>'
throw new Error('Root element #root not found')
}
try {
createRoot(root).render(
<ErrorBoundary>
<App />
</ErrorBoundary>
)
} catch (err) {
console.error('React render error:', err)
root.innerHTML = `<h1 style="color:red">React Error: ${err}</h1>`
}
@@ -0,0 +1,449 @@
import { useMemo, useState, type ReactNode } from 'react'
import type {
ArchitecturePreset,
ArchitecturePresetDetail,
InstalledPackageInfo,
ChannelStatusInfo,
ReorgProposalInfo,
} from '../types/visual'
import { PackageCard } from './PackageCard'
import { CollapsibleSection } from './CollapsibleSection'
/* ── Inline SVG icon data-URIs (no external CDN) ────────────────── */
const ICON = {
search: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z'/%3E%3C/svg%3E",
arch: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z'/%3E%3C/svg%3E",
packages: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z'/%3E%3C/svg%3E",
channels: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z'/%3E%3C/svg%3E",
reorg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z'/%3E%3C/svg%3E",
importPkg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z'/%3E%3C/svg%3E",
arrow: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z'/%3E%3C/svg%3E",
gateReview: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23f59e0b' d='M12 2L4 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-3zm-2 16l-4-4 1.41-1.41L10 15.17l6.59-6.59L18 10l-8 8z'/%3E%3C/svg%3E",
gateApproval: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E",
gateHold: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23ef4444' d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E",
}
const PATTERN_LABELS: Record<string, string> = {
pipeline: 'Pipeline',
hub_spoke: 'Hub & Spoke',
review_loop: 'Review Loop',
hierarchical: 'Hierarchical',
flat: 'Flat Team',
}
interface ArchitectureMarketplaceProps {
presets: ArchitecturePreset[]
installedIds: Set<string>
previewData: ArchitecturePresetDetail | null
applyingPresetId: string | null
readOnly: boolean
onPreview: (presetId: string) => void
onApplyPreset: (presetId: string, strategy: string) => void
onClearPreview: () => void
installedPackages: InstalledPackageInfo[]
channels: ChannelStatusInfo[]
reorgProposals: ReorgProposalInfo[]
isCustomMode: boolean
onReorgDecide: (proposalId: string, approved: boolean, notes?: string) => void
onMarketInstall: (path: string, strategy: string) => void
onMarketUninstall: (packageId: string) => void
}
export function ArchitectureMarketplace({
presets, installedIds, previewData, applyingPresetId, readOnly,
onPreview, onApplyPreset, onClearPreview,
installedPackages, channels, reorgProposals, isCustomMode,
onReorgDecide, onMarketInstall, onMarketUninstall,
}: ArchitectureMarketplaceProps) {
const [search, setSearch] = useState('')
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [activePattern, setActivePattern] = useState<string | null>(null)
const [showImportForm, setShowImportForm] = useState(false)
const [importPath, setImportPath] = useState('')
const [uninstallingId, setUninstallingId] = useState<string | null>(null)
const categories = useMemo(() => {
const cats = new Set<string>()
for (const p of presets) { if (p.category) cats.add(p.category) }
return Array.from(cats).sort()
}, [presets])
const patterns = useMemo(() => {
const pats = new Set<string>()
for (const p of presets) { if (p.collaboration_pattern) pats.add(p.collaboration_pattern) }
return Array.from(pats).sort()
}, [presets])
const filteredPresets = useMemo(() => {
let result = presets
if (activeCategory) result = result.filter(p => p.category === activeCategory)
if (activePattern) result = result.filter(p => p.collaboration_pattern === activePattern)
if (search.trim()) {
const q = search.trim().toLowerCase()
result = result.filter(p =>
p.name.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
p.tags.some(t => t.toLowerCase().includes(q)) ||
(PATTERN_LABELS[p.collaboration_pattern] || '').toLowerCase().includes(q),
)
}
return result
}, [presets, activeCategory, activePattern, search])
const handleImport = () => {
if (!importPath.trim()) return
onMarketInstall(importPath.trim(), 'namespace')
setShowImportForm(false)
setImportPath('')
}
const handleUninstall = (pkgId: string) => {
if (!confirm('Uninstall this package? Roles and work-item templates from this package will be removed.')) return
setUninstallingId(pkgId)
onMarketUninstall(pkgId)
setTimeout(() => setUninstallingId(null), 3000)
}
return (
<div className="mkt-container" data-testid="architecture-marketplace">
{/* Toolbar */}
<div className="mkt-toolbar">
<div className="mkt-search-wrap">
<img src={ICON.search} alt="" className="mkt-search-icon" />
<input
className="mkt-search"
placeholder="Search architectures..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<span className="mkt-count">{filteredPresets.length} architectures</span>
</div>
{/* Filter pills */}
<div className="mkt-filters">
{categories.length > 0 && (
<div className="mkt-pill-row">
<button className={`mkt-pill${!activeCategory ? ' active' : ''}`}
onClick={() => setActiveCategory(null)}>All</button>
{categories.map(cat => (
<button key={cat} className={`mkt-pill${activeCategory === cat ? ' active' : ''}`}
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
>{cat}</button>
))}
</div>
)}
{patterns.length > 0 && (
<div className="mkt-pill-row">
<button className={`mkt-pill mkt-pill-pattern${!activePattern ? ' active' : ''}`}
onClick={() => setActivePattern(null)}>All Patterns</button>
{patterns.map(pat => (
<button key={pat} className={`mkt-pill mkt-pill-pattern${activePattern === pat ? ' active' : ''}`}
onClick={() => setActivePattern(activePattern === pat ? null : pat)}
>{PATTERN_LABELS[pat] || pat}</button>
))}
</div>
)}
</div>
{/* Architecture Blueprints grid */}
{filteredPresets.length > 0 ? (
<div className="mkt-section">
<div className="mkt-section-header">
<img src={ICON.arch} alt="" className="mkt-section-icon" />
<h3 className="mkt-section-title">Architecture Blueprints</h3>
<span className="mkt-section-count">{filteredPresets.length}</span>
</div>
<div className="mkt-arch-grid">
{filteredPresets.map(p => (
<ArchCard key={p.id} preset={p}
isInstalled={installedIds.has(p.id)}
isApplying={applyingPresetId === p.id}
readOnly={readOnly}
onPreview={() => onPreview(p.id)}
onApply={() => onApplyPreset(p.id, 'namespace')}
/>
))}
</div>
</div>
) : (
<div className="mkt-empty"><p>No architectures match your search.</p></div>
)}
{/* Installed Packages */}
<CollapsibleSection icon={ICON.packages} title="Installed Packages" count={installedPackages.length}
extra={isCustomMode ? (
<button className="myorg-inline-btn" onClick={() => setShowImportForm(!showImportForm)}>
<img src={ICON.importPkg} alt="" className="myorg-inline-icon" /> Import
</button>
) : undefined}>
{isCustomMode && showImportForm && (
<div className="myorg-form">
<div className="oc-form-row">
<label>Path</label>
<input value={importPath} onChange={e => setImportPath(e.target.value)}
placeholder="/path/to/package.opcpkg" />
</div>
<div className="oc-form-actions">
<button className="oc-btn-primary" onClick={handleImport} disabled={!importPath.trim()}>Install</button>
<button className="oc-btn-ghost" onClick={() => setShowImportForm(false)}>Cancel</button>
</div>
</div>
)}
{installedPackages.length > 0 ? (
<div className="pkg-grid">
{installedPackages.map(pkg => (
<PackageCard key={pkg.package_id} pkg={pkg}
onUninstall={isCustomMode ? handleUninstall : undefined}
uninstallingId={uninstallingId} />
))}
</div>
) : (
<div className="myorg-empty-hint">No packages installed.</div>
)}
</CollapsibleSection>
{/* Channels & Connectors */}
<CollapsibleSection icon={ICON.channels} title="Channels & Connectors" count={channels.length}>
{channels.length > 0 ? (
<div className="org-channels-grid">
{channels.map(ch => (
<div key={ch.name} className={`org-channel-card${ch.running ? ' org-ch-running' : ''}${!ch.enabled ? ' org-ch-disabled' : ''}`}>
<div className="org-ch-header">
<span className={`org-ch-dot${ch.running ? ' running' : ch.ready ? ' ready' : ch.configured ? ' configured' : ''}`} />
<span className="org-ch-name">{ch.name}</span>
</div>
<div className="org-ch-status-row">
{ch.enabled && <span className="org-ch-badge org-ch-enabled">enabled</span>}
{ch.running && <span className="org-ch-badge org-ch-running-badge">running</span>}
{!ch.enabled && <span className="org-ch-badge org-ch-off">disabled</span>}
</div>
</div>
))}
</div>
) : (
<div className="myorg-empty-hint">No channels configured.</div>
)}
</CollapsibleSection>
{/* Reorg Proposals */}
{reorgProposals.length > 0 && (
<CollapsibleSection icon={ICON.reorg} title="Reorg Proposals" count={reorgProposals.length}>
<div className="org-reorg-list">
{reorgProposals.map(p => {
const isPending = p.status === 'proposed'
return (
<div key={p.proposal_id} className={`org-reorg-card org-reorg-${p.status}`}>
<div className="org-reorg-header">
<span className="org-reorg-title">{p.title || p.summary || 'Untitled'}</span>
<span className="org-reorg-status">{p.status}</span>
</div>
{p.summary && <div className="org-reorg-summary">{p.summary}</div>}
{isPending && isCustomMode && (
<div className="org-reorg-actions">
<button className="org-reorg-approve" onClick={() => onReorgDecide(p.proposal_id, true)}>Approve</button>
<button className="org-reorg-deny" onClick={() => onReorgDecide(p.proposal_id, false)}>Deny</button>
</div>
)}
</div>
)
})}
</div>
</CollapsibleSection>
)}
{/* Architecture preview modal */}
{previewData && (
<ArchPreviewModal
data={previewData}
isInstalled={installedIds.has(previewData.id)}
isApplying={applyingPresetId === previewData.id}
onApply={() => onApplyPreset(previewData.id, 'namespace')}
onClose={onClearPreview}
/>
)}
</div>
)
}
/* ── Architecture Card ───────────────────────────────────────────────── */
function ArchCard({ preset: p, isInstalled, isApplying, readOnly, onPreview, onApply }: {
preset: ArchitecturePreset
isInstalled: boolean
isApplying: boolean
readOnly: boolean
onPreview: () => void
onApply: () => void
}) {
const patternLabel = PATTERN_LABELS[p.collaboration_pattern] || p.collaboration_pattern
return (
<div className="mkt-arch-card" style={{ borderLeftColor: p.color || 'var(--accent)' }}
onClick={onPreview}>
<div className="mkt-arch-header">
<span className="mkt-arch-emoji">{p.emoji}</span>
<div className="mkt-arch-title-wrap">
<span className="mkt-arch-name">{p.name}</span>
<div className="mkt-arch-badges">
<span className="mkt-arch-category">{p.category}</span>
{p.collaboration_pattern && <span className="mkt-arch-pattern">{patternLabel}</span>}
</div>
</div>
</div>
{p.dag_summary && <div className="mkt-arch-dag-summary">{p.dag_summary}</div>}
<div className="mkt-arch-desc">{p.description}</div>
<div className="mkt-arch-stats">
<span className="mkt-arch-stat">{p.roles_count} roles</span>
<span className="mkt-arch-stat">{p.work_item_templates_count} templates</span>
{p.gates_count > 0 && <span className="mkt-arch-stat">{p.gates_count} checkpoints</span>}
{p.team_size && <span className="mkt-arch-stat">{p.team_size} people</span>}
</div>
{p.tags.length > 0 && (
<div className="mkt-arch-tags">
{p.tags.slice(0, 4).map(t => <span key={t} className="mkt-tag">{t}</span>)}
</div>
)}
<div className="mkt-arch-actions">
{isInstalled ? (
<span className="mkt-installed-badge">Installed</span>
) : !readOnly ? (
<button className="mkt-btn mkt-btn-primary mkt-btn-sm"
disabled={isApplying}
onClick={e => { e.stopPropagation(); onApply() }}
>{isApplying ? 'Applying...' : 'Use This'}</button>
) : null}
<button className="mkt-btn mkt-btn-ghost mkt-btn-sm"
onClick={e => { e.stopPropagation(); onPreview() }}
>Preview</button>
</div>
</div>
)
}
/* ── Architecture Preview Modal ──────────────────────────────────────── */
function ArchPreviewModal({ data, isInstalled, isApplying, onApply, onClose }: {
data: ArchitecturePresetDetail
isInstalled: boolean
isApplying: boolean
onApply: () => void
onClose: () => void
}) {
return (
<div className="mkt-modal-overlay" onClick={onClose}>
<div className="mkt-modal" onClick={e => e.stopPropagation()}>
<div className="mkt-modal-header" style={{ borderBottomColor: data.color || 'var(--border)' }}>
<span className="mkt-modal-emoji">{data.emoji}</span>
<div>
<h2 className="mkt-modal-name">{data.name}</h2>
<span className="mkt-modal-category">{data.category}</span>
</div>
<button className="mkt-modal-close" onClick={onClose}>&times;</button>
</div>
<div className="mkt-modal-body">
<p className="mkt-modal-desc">{data.description}</p>
{data.tags.length > 0 && (
<div className="mkt-modal-section">
<div className="mkt-modal-label">Tags</div>
<div className="mkt-modal-tags">
{data.tags.map(t => <span key={t} className="mkt-tag">{t}</span>)}
</div>
</div>
)}
<div className="mkt-modal-section">
<div className="mkt-modal-label">Roles ({data.roles.length})</div>
<div className="mkt-role-list">
{data.roles.map(r => (
<div key={r.id} className="mkt-role-item">
<div className="mkt-role-name">{r.name} <code>{r.id}</code></div>
<div className="mkt-role-resp">{r.responsibility}</div>
<div className="mkt-role-meta">
reports to: <code>{r.reports_to}</code>
{r.can_spawn && r.can_spawn.length > 0 && (
<> &middot; spawns: {r.can_spawn.map(s => <code key={s}>{s}</code>)}</>
)}
</div>
</div>
))}
</div>
</div>
{/* Work item templates */}
<div className="mkt-modal-section">
<div className="mkt-modal-label">Work item templates ({data.work_item_templates.length} templates)</div>
<div className="mkt-dag-wrap">
<ModalWorkItemTemplates templates={data.work_item_templates} />
</div>
</div>
</div>
<div className="mkt-modal-footer">
{isInstalled ? (
<span className="mkt-installed-badge">Already Installed</span>
) : (
<button className="mkt-btn mkt-btn-primary" disabled={isApplying} onClick={onApply}>
{isApplying ? 'Applying...' : 'Use This Architecture'}
</button>
)}
</div>
</div>
</div>
)
}
function ModalWorkItemTemplates({ templates }: { templates: ArchitecturePresetDetail['work_item_templates'] }): ReactNode {
type Group = { group: string | null; templates: typeof templates }
const groups: Group[] = []
let currentGroup: string | null = '__init__'
let currentTemplates: typeof templates = []
for (const template of templates) {
if (template.parallel_group !== currentGroup) {
if (currentTemplates.length > 0) groups.push({ group: currentGroup, templates: currentTemplates })
currentGroup = template.parallel_group
currentTemplates = [template]
} else {
currentTemplates.push(template)
}
}
if (currentTemplates.length > 0) groups.push({ group: currentGroup, templates: currentTemplates })
return (
<div className="org-dag">
{groups.map((g, gi) => (
<div key={gi} className="org-dag-group-wrap">
{gi > 0 && <div className="org-dag-arrow"><img src={ICON.arrow} alt="→" className="org-dag-arrow-icon" /></div>}
<div className={`org-dag-group${g.templates.length > 1 ? ' org-dag-parallel' : ''}`}>
{g.templates.length > 1 && <div className="org-dag-parallel-label">parallel</div>}
{g.templates.map(template => (
<div key={template.id} className="org-dag-node">
<div className="org-dag-node-header">
<span className="org-dag-node-title">{template.title}</span>
<span className="org-dag-node-role">{template.role_id}</span>
</div>
<div className="org-dag-node-id">{template.id}</div>
{template.gate && (
<div className={`org-dag-gate org-gate-${template.gate.type}`}>
<img
src={template.gate.type === 'review' ? ICON.gateReview : template.gate.type === 'approval' ? ICON.gateApproval : ICON.gateHold}
alt="" className="org-gate-icon"
/>
<span>{template.gate.type}</span>
{template.gate.reviewer_role && <span className="org-gate-reviewer">by {template.gate.reviewer_role}</span>}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,26 @@
import { useState, type ReactNode } from 'react'
interface CollapsibleSectionProps {
icon: string
title: string
count: number
extra?: ReactNode
children: ReactNode
defaultExpanded?: boolean
}
export function CollapsibleSection({ icon, title, count, extra, children, defaultExpanded = false }: CollapsibleSectionProps) {
const [expanded, setExpanded] = useState(defaultExpanded)
return (
<div className="myorg-collapsible">
<button className="myorg-collapsible-toggle" onClick={() => setExpanded(!expanded)}>
<span className="myorg-toggle-icon">{expanded ? '\u25BE' : '\u25B8'}</span>
<img src={icon} alt="" className="myorg-section-icon" />
<span className="myorg-collapsible-title">{title}</span>
<span className="myorg-collapsible-count">{count}</span>
{extra && <span className="myorg-collapsible-extra" onClick={e => e.stopPropagation()}>{extra}</span>}
</button>
{expanded && <div className="myorg-collapsible-body">{children}</div>}
</div>
)
}
@@ -0,0 +1,191 @@
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
/* ── Inline SVG icon data-URIs ──────────────────────────────────── */
const ICON = {
download: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z'/%3E%3C/svg%3E",
upload: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z'/%3E%3C/svg%3E",
check: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E",
warn: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23ef4444' d='M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z'/%3E%3C/svg%3E",
}
interface ConfigImportExportPanelProps {
onExport: () => void
onImport: (yaml: string, dryRun: boolean) => void
configExportYaml?: string | null
importPreview?: { roles_added: number; roles_removed: number; employees_changed: number } | null
importError?: string | null
}
export function ConfigImportExportPanel({
onExport, onImport, configExportYaml, importPreview, importError,
}: ConfigImportExportPanelProps) {
const [yamlText, setYamlText] = useState('')
const [fileName, setFileName] = useState<string | null>(null)
const [dryRunDone, setDryRunDone] = useState(false)
const exportPending = useRef(false)
const lastExportedYaml = useRef<string | null>(null)
// Trigger browser download when server returns the exported YAML
useEffect(() => {
if (!exportPending.current) return
if (!configExportYaml) return
if (configExportYaml === lastExportedYaml.current) return
lastExportedYaml.current = configExportYaml
exportPending.current = false
const blob = new Blob([configExportYaml], { type: 'application/x-yaml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
a.download = `org_config_${stamp}.yaml`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [configExportYaml])
// Reset dry-run state when user edits the YAML
useEffect(() => {
setDryRunDone(false)
}, [yamlText])
// Flip dry-run state on successful preview (not on error)
useEffect(() => {
if (importPreview && !importError) setDryRunDone(true)
}, [importPreview, importError])
const handleExport = () => {
exportPending.current = true
onExport()
}
const handleFile = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setFileName(file.name)
const reader = new FileReader()
reader.onload = () => {
const result = reader.result
if (typeof result === 'string') setYamlText(result)
}
reader.readAsText(file)
e.target.value = '' // allow re-selecting same file
}
const handleDryRun = () => {
if (!yamlText.trim()) return
onImport(yamlText, true)
}
const handleApply = () => {
if (!dryRunDone || !yamlText.trim()) return
if (!confirm('Apply this config? The current company architecture will be overwritten.')) return
onImport(yamlText, false)
setDryRunDone(false)
setYamlText('')
setFileName(null)
}
const handleClear = () => {
setYamlText('')
setFileName(null)
setDryRunDone(false)
}
return (
<div className="cfg-io-panel" data-testid="config-import-export-panel">
<div className="cfg-io-header">
<h3 className="cfg-io-title">Config Import / Export</h3>
<p className="cfg-io-subtitle">Download the current company architecture as YAML, or upload one to replace it.</p>
</div>
{/* Export */}
<div className="cfg-io-section">
<div className="cfg-io-section-header">
<img src={ICON.download} alt="" className="cfg-io-section-icon" />
<span className="cfg-io-section-title">Download current config</span>
</div>
<button className="cfg-io-btn cfg-io-btn-primary" onClick={handleExport}>
<img src={ICON.download} alt="" className="cfg-io-btn-icon" /> Download YAML
</button>
</div>
{/* Import */}
<div className="cfg-io-section">
<div className="cfg-io-section-header">
<img src={ICON.upload} alt="" className="cfg-io-section-icon" />
<span className="cfg-io-section-title">Upload config</span>
</div>
<div className="cfg-io-upload-row">
<label className="cfg-io-file-label">
<input type="file" accept=".yaml,.yml" onChange={handleFile} className="cfg-io-file-input" />
<span className="cfg-io-file-btn">Choose file</span>
<span className="cfg-io-file-name">{fileName ?? 'no file selected'}</span>
</label>
</div>
<textarea
className="cfg-io-textarea"
placeholder="…or paste YAML here"
value={yamlText}
onChange={e => setYamlText(e.target.value)}
spellCheck={false}
rows={10}
/>
<div className="cfg-io-actions">
<button className="cfg-io-btn cfg-io-btn-ghost"
onClick={handleDryRun}
disabled={!yamlText.trim()}>
Dry run
</button>
<button className="cfg-io-btn cfg-io-btn-primary"
onClick={handleApply}
disabled={!dryRunDone || !yamlText.trim()}>
Apply
</button>
{yamlText && (
<button className="cfg-io-btn cfg-io-btn-ghost" onClick={handleClear}>Clear</button>
)}
</div>
{/* Preview */}
{importPreview && !importError && (
<div className="cfg-io-preview">
<img src={ICON.check} alt="" className="cfg-io-preview-icon" />
<div className="cfg-io-preview-body">
<div className="cfg-io-preview-title">Dry run OK ready to apply</div>
<div className="cfg-io-preview-stats">
<span className="cfg-io-preview-stat">
<span className="cfg-io-stat-label">Roles added</span>
<span className="cfg-io-stat-value">{importPreview.roles_added}</span>
</span>
<span className="cfg-io-preview-stat">
<span className="cfg-io-stat-label">Roles removed</span>
<span className="cfg-io-stat-value">{importPreview.roles_removed}</span>
</span>
<span className="cfg-io-preview-stat">
<span className="cfg-io-stat-label">Employees changed</span>
<span className="cfg-io-stat-value">{importPreview.employees_changed}</span>
</span>
</div>
</div>
</div>
)}
{/* Error */}
{importError && (
<div className="cfg-io-error">
<img src={ICON.warn} alt="" className="cfg-io-error-icon" />
<div className="cfg-io-error-body">
<div className="cfg-io-error-title">Validation failed</div>
<pre className="cfg-io-error-text">{importError}</pre>
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,179 @@
import type { OrgRole, RuntimeFrontierSummary, RuntimeSeatInfo, RuntimeTeamInfo, RuntimeWorkItemInfo, RuntimePolicy } from '../types/visual'
interface DelegationStrategyPanelProps {
roles: OrgRole[]
runtimeTeams: RuntimeTeamInfo[]
runtimeSeats: RuntimeSeatInfo[]
workItems: RuntimeWorkItemInfo[]
frontier: RuntimeFrontierSummary
companyProfile: string
runtimePolicy?: RuntimePolicy
finalDeciderRoleId?: string | null
topLevelRoleIds?: string[]
readOnly?: boolean
onUpdateOrgStrategy?: (data: { final_decider_role_id?: string | null }) => void
onUpdateRuntimePolicy?: (policy: Record<string, any>) => void
}
function adaptiveForWorkItem(item: RuntimeWorkItemInfo): Record<string, unknown> | undefined {
if (item.adaptive && typeof item.adaptive === 'object') return item.adaptive
const metadata = item.metadata
if (metadata && typeof metadata === 'object' && metadata.adaptive && typeof metadata.adaptive === 'object') {
return metadata.adaptive as Record<string, unknown>
}
return undefined
}
function missingSignalsForWorkItem(item: RuntimeWorkItemInfo): string[] {
const adaptive = adaptiveForWorkItem(item)
const signals = Array.isArray(adaptive?.signals) ? adaptive.signals : []
return signals
.filter(signal => signal && typeof signal === 'object')
.filter(signal => Boolean((signal as Record<string, unknown>).required ?? true) && !Boolean((signal as Record<string, unknown>).satisfied))
.map(signal => String((signal as Record<string, unknown>).name ?? '').trim())
.filter(Boolean)
}
function gateOwnerForWorkItem(item: RuntimeWorkItemInfo): string {
const adaptive = adaptiveForWorkItem(item)
const stageProfile = adaptive?.work_item_profile
if (stageProfile && typeof stageProfile === 'object') {
return String((stageProfile as Record<string, unknown>).gate_owner_role_id ?? '').trim()
}
return ''
}
function adaptiveConfidenceLabel(item: RuntimeWorkItemInfo): string {
const adaptive = adaptiveForWorkItem(item)
const confidence = typeof adaptive?.confidence === 'number' ? adaptive.confidence : undefined
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : ''
}
export function DelegationStrategyPanel({
roles,
runtimeTeams,
runtimeSeats,
workItems,
frontier,
companyProfile,
runtimePolicy,
finalDeciderRoleId,
topLevelRoleIds,
readOnly = false,
onUpdateOrgStrategy,
onUpdateRuntimePolicy,
}: DelegationStrategyPanelProps) {
const topLevel = roles.filter(r => (topLevelRoleIds ?? []).includes(r.role_id))
const selectedFinalDecider = finalDeciderRoleId || (topLevel.length === 1 ? topLevel[0]?.role_id : '')
const hasSelectionError = topLevel.length > 1 && !selectedFinalDecider
const roleNameMap = new Map(roles.map(r => [r.role_id, r.name]))
return (
<div className="wfe-container">
<div className="wfe-header">
<h3 className="wfe-title">Actor Runtime</h3>
<span className="wfe-profile-badge">{companyProfile}</span>
</div>
<div className="myorg-collapsible" style={{ margin: '0 0 8px' }}>
<div className="myorg-collapsible-body" style={{ display: 'block' }}>
<div className="oc-form-row">
<label>Final decider</label>
<select
value={selectedFinalDecider}
disabled={readOnly}
onChange={e => {
if (readOnly) return
onUpdateOrgStrategy?.({ final_decider_role_id: e.target.value || null })
}}
>
<option value="">{topLevel.length > 1 ? 'Select top-level role' : 'Auto-select only top-level role'}</option>
{topLevel.map(role => (
<option key={role.role_id} value={role.role_id}>{role.name}</option>
))}
</select>
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
Runtime wakeups, delegation, approvals, and recovery are seat-scoped.
</div>
{hasSelectionError && (
<div className="org-toast org-toast--warn" style={{ margin: '0 0 8px' }}>
Multiple top-level roles exist. Select one final decider before company execution can start.
</div>
)}
</div>
</div>
{(runtimeTeams.length || runtimeSeats.length || workItems.length) ? (
<div className="myorg-collapsible" style={{ margin: '0 0 8px' }}>
<div className="myorg-collapsible-body" style={{ display: 'block' }}>
<div className="oc-form-row">
<label>Runtime</label>
<div>
{frontier.status || 'running'}
{frontier.run_id ? ` (${frontier.run_id.slice(0, 8)})` : ''}
</div>
</div>
<div className="oc-form-row">
<label>Frontier</label>
<div>
{frontier.running_count ?? 0} running, {frontier.ready_count ?? 0} ready, {frontier.blocked_count ?? 0} blocked, {frontier.waiting_count ?? 0} waiting
</div>
</div>
<div className="oc-form-row">
<label>Teams</label>
<div>{runtimeTeams.length}</div>
</div>
<div className="oc-form-row">
<label>Seats</label>
<div>{runtimeSeats.length}</div>
</div>
<div className="oc-form-row">
<label>Work items</label>
<div>{workItems.length}</div>
</div>
{workItems.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{workItems.slice(0, 6).map(item => {
const adaptive = adaptiveForWorkItem(item)
const normalizedState = typeof adaptive?.normalized_state === 'string' ? adaptive.normalized_state : ''
const blockedReason = typeof adaptive?.blocked_reason === 'string' ? adaptive.blocked_reason : (item.blocked_reason ?? '')
const gateOwner = gateOwnerForWorkItem(item)
const missingSignals = missingSignalsForWorkItem(item)
const confidence = adaptiveConfidenceLabel(item)
const summary = [
blockedReason ? `waiting ${blockedReason}` : '',
gateOwner ? `gate ${roleNameMap.get(gateOwner) ?? gateOwner}` : '',
missingSignals.length ? `signals ${missingSignals.join(', ')}` : '',
confidence ? `confidence ${confidence}` : '',
normalizedState === 'invalidated' ? 'invalidated' : '',
].filter(Boolean)
return (
<div key={item.work_item_id} style={{ marginBottom: 6 }}>
<div>
{roleNameMap.get(item.role_id) ?? item.role_id}: {item.title} [{item.phase}]
{item.kanban_column && (
<span style={{ opacity: 0.5 }}> · {item.kanban_column}</span>
)}
{item.batch_id && <span style={{ opacity: 0.5 }}> batch:{item.batch_id}</span>}
{normalizedState && normalizedState !== item.phase && (
<span style={{ opacity: 0.7 }}> state:{normalizedState}</span>
)}
</div>
{summary.length > 0 && (
<div style={{ marginLeft: 12, opacity: 0.85 }}>
{summary.join(' • ')}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,136 @@
import { useMemo, useState } from 'react'
import type { TalentTemplate, OrgRole, HireTalentHandler } from '../types/visual'
import { TalentCard } from './TalentCard'
import { TalentDetailModal } from './TalentDetailModal'
import { HireToRoleModal } from './HireToRoleModal'
/* ── Inline SVG icon data-URIs ──────────────────────────────────── */
const ICON = {
search: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z'/%3E%3C/svg%3E",
talent: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
}
interface EmployeesMarketplaceProps {
templates: TalentTemplate[]
vacantRoles: OrgRole[]
hiringTemplateId: string | null
readOnly: boolean
onHireTalent: HireTalentHandler
}
export function EmployeesMarketplace({
templates, vacantRoles, hiringTemplateId, readOnly, onHireTalent,
}: EmployeesMarketplaceProps) {
const [search, setSearch] = useState('')
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [detailTemplate, setDetailTemplate] = useState<TalentTemplate | null>(null)
const [hireForTemplate, setHireForTemplate] = useState<TalentTemplate | null>(null)
const categories = useMemo(() => {
const cats = new Set<string>()
for (const t of templates) { if (t.category) cats.add(t.category) }
return Array.from(cats).sort()
}, [templates])
const filteredTemplates = useMemo(() => {
let result = templates
if (activeCategory) result = result.filter(t => t.category === activeCategory)
if (search.trim()) {
const q = search.trim().toLowerCase()
result = result.filter(t =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.domains.some(d => d.toLowerCase().includes(q)) ||
t.tags.some(tag => tag.toLowerCase().includes(q)) ||
(t.vibe ?? '').toLowerCase().includes(q),
)
}
return result
}, [templates, activeCategory, search])
const handleCardHire = (templateId: string) => {
if (readOnly) return
const template = templates.find(t => t.template_id === templateId)
if (template) setHireForTemplate(template)
}
return (
<div className="mkt-container" data-testid="employees-marketplace">
{/* Toolbar */}
<div className="mkt-toolbar">
<div className="mkt-search-wrap">
<img src={ICON.search} alt="" className="mkt-search-icon" />
<input
className="mkt-search"
placeholder="Search talent templates..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<span className="mkt-count">
{filteredTemplates.length} employees
{vacantRoles.length > 0 && <> · {vacantRoles.length} vacant role{vacantRoles.length === 1 ? '' : 's'}</>}
</span>
</div>
{/* Category pills */}
{categories.length > 0 && (
<div className="mkt-filters">
<div className="mkt-pill-row">
<button className={`mkt-pill${!activeCategory ? ' active' : ''}`}
onClick={() => setActiveCategory(null)}>All</button>
{categories.map(cat => (
<button key={cat} className={`mkt-pill${activeCategory === cat ? ' active' : ''}`}
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
>{cat}</button>
))}
</div>
</div>
)}
{/* Talent grid */}
{filteredTemplates.length > 0 ? (
<div className="mkt-section">
<div className="mkt-section-header">
<img src={ICON.talent} alt="" className="mkt-section-icon" />
<h3 className="mkt-section-title">Talent Templates</h3>
<span className="mkt-section-count">{filteredTemplates.length}</span>
</div>
<div className="tm-grid">
{filteredTemplates.map(t => (
<TalentCard key={t.template_id} template={t}
hiringId={hiringTemplateId}
onHire={handleCardHire}
onClick={setDetailTemplate}
/>
))}
</div>
</div>
) : (
<div className="mkt-empty">
<p>{templates.length === 0 ? 'No talent templates available.' : 'No employees match your search.'}</p>
</div>
)}
{/* Detail modal */}
{detailTemplate && (
<TalentDetailModal
template={detailTemplate}
vacantRoles={vacantRoles}
hiringId={hiringTemplateId}
readOnly={readOnly}
onHire={(tid, rid) => { if (!readOnly) { onHireTalent(tid, rid); setDetailTemplate(null) } }}
onClose={() => setDetailTemplate(null)}
/>
)}
<HireToRoleModal
open={hireForTemplate !== null}
template={hireForTemplate}
vacantRoles={vacantRoles}
onConfirm={(tid, rid) => { onHireTalent(tid, rid); setHireForTemplate(null) }}
onClose={() => setHireForTemplate(null)}
/>
</div>
)
}
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import type { OrgRole, TalentTemplate, RoleId, TemplateId } from '../types/visual'
import { asRoleId, asTemplateId } from '../types/visual'
interface HireToRoleModalProps {
open: boolean
template: TalentTemplate | null
vacantRoles: OrgRole[]
onConfirm: (template: TemplateId, role: RoleId) => void
onClose: () => void
}
export function HireToRoleModal({
open, template, vacantRoles, onConfirm, onClose,
}: HireToRoleModalProps) {
const [selectedRoleId, setSelectedRoleId] = useState<string>('')
useEffect(() => {
if (open) setSelectedRoleId('')
}, [open, template?.template_id])
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open || !template) return null
const noVacancies = vacantRoles.length === 0
const canConfirm = !noVacancies && selectedRoleId !== ''
const handleConfirm = () => {
if (!canConfirm) return
onConfirm(asTemplateId(template.template_id), asRoleId(selectedRoleId))
}
return (
<div className="htr-overlay" role="dialog" aria-modal="true" onMouseDown={onClose}>
<div className="htr-modal" onMouseDown={event => event.stopPropagation()}>
<header className="htr-header">
<div>
<h3 className="htr-title">Hire {template.name}</h3>
<p className="htr-subtitle">Pick the role to fill with this employee.</p>
</div>
<button className="htr-close" type="button" onClick={onClose} aria-label="Close">x</button>
</header>
<div className="htr-body">
{noVacancies ? (
<div className="htr-empty">
<p className="htr-empty-title">No vacant roles.</p>
<p className="htr-empty-hint">
Add a role in the Team tab first, then come back to hire.
</p>
</div>
) : (
<div className="htr-role-list" role="listbox" aria-label="Vacant roles">
{vacantRoles.map(role => {
const selected = role.role_id === selectedRoleId
return (
<button
key={role.role_id}
type="button"
role="option"
aria-selected={selected}
className={`htr-role-row${selected ? ' is-selected' : ''}`}
onClick={() => setSelectedRoleId(role.role_id)}
>
<span className="htr-role-name">{role.name}</span>
{role.responsibility && (
<span className="htr-role-resp">{role.responsibility}</span>
)}
</button>
)
})}
</div>
)}
</div>
<footer className="htr-footer">
<button type="button" className="btn btn-ghost" onClick={onClose}>
{noVacancies ? 'Close' : 'Cancel'}
</button>
{!noVacancies && (
<button
type="button"
className="btn btn-primary"
onClick={handleConfirm}
disabled={!canConfirm}
>
Hire to selected role
</button>
)}
</footer>
</div>
</div>
)
}
@@ -0,0 +1,257 @@
import { useEffect, useMemo, useState } from 'react'
import type { OrgCreateMemberInput, OrgSavedCreatePayload } from '../types/visual'
interface OrgCreateResult extends OrgSavedCreatePayload {
nonce: number
}
interface OrgCreateModalProps {
open: boolean
pending?: boolean
result?: OrgCreateResult | null
onClose: () => void
onCreate: (organizationName: string, members: OrgCreateMemberInput[]) => void
}
type MemberDraft = {
name: string
responsibility: string
prompt: string
reportsToIndex: number | null
}
const INITIAL_MEMBERS: MemberDraft[] = [
{ name: '', responsibility: '', prompt: '', reportsToIndex: null },
{ name: '', responsibility: '', prompt: '', reportsToIndex: 0 },
]
function slugLabel(value: string): string {
return value.trim() || 'Member'
}
export function OrgCreateModal({ open, pending, result, onClose, onCreate }: OrgCreateModalProps) {
const [step, setStep] = useState(1)
const [organizationName, setOrganizationName] = useState('')
const [members, setMembers] = useState<MemberDraft[]>(INITIAL_MEMBERS)
const [localError, setLocalError] = useState('')
useEffect(() => {
if (!open) return
setStep(1)
setOrganizationName('')
setMembers(INITIAL_MEMBERS)
setLocalError('')
}, [open])
useEffect(() => {
if (!open || !result) return
if (result.ok) {
onClose()
return
}
setLocalError(result.error || 'Failed to create organization')
}, [open, result, onClose])
const organizationValid = organizationName.trim().length > 0
const validMembers = useMemo(
() => members.map((member, index) => ({ member, index })).filter(item => item.member.name.trim()),
[members],
)
const originalIndexToCreateIndex = useMemo(
() => new Map(validMembers.map((item, createIndex) => [item.index, createIndex])),
[validMembers],
)
const membersValid = validMembers.length >= 2
const canCreate = organizationValid && membersValid && !pending
const previewMembers = useMemo(
() => validMembers.map(({ member, index }, createIndex) => {
const mappedParent = member.reportsToIndex == null ? null : originalIndexToCreateIndex.get(member.reportsToIndex)
return {
...member,
roleName: slugLabel(member.name),
managerName: mappedParent != null && mappedParent < createIndex
? slugLabel(validMembers[mappedParent]?.member.name || '')
: 'Owner',
index,
}
}),
[originalIndexToCreateIndex, validMembers],
)
if (!open) return null
const updateMember = (index: number, patch: Partial<MemberDraft>) => {
setMembers(prev => prev.map((member, idx) => idx === index ? { ...member, ...patch } : member))
}
const addMember = () => {
setMembers(prev => [...prev, { name: '', responsibility: '', prompt: '', reportsToIndex: 0 }])
}
const removeMember = (index: number) => {
setMembers(prev => {
const next = prev.filter((_, idx) => idx !== index)
return next.map((member, idx) => ({
...member,
reportsToIndex: member.reportsToIndex == null
? null
: member.reportsToIndex >= index
? Math.max(0, member.reportsToIndex - 1)
: member.reportsToIndex,
})).map((member, idx) => idx === 0 ? { ...member, reportsToIndex: null } : member)
})
}
const submit = () => {
if (!canCreate) return
setLocalError('')
onCreate(
organizationName.trim(),
validMembers.map(({ member }, createIndex) => {
const mappedParent = member.reportsToIndex == null ? null : originalIndexToCreateIndex.get(member.reportsToIndex)
return {
name: member.name.trim(),
responsibility: member.responsibility.trim(),
prompt: member.prompt.trim(),
reports_to_index: mappedParent != null && mappedParent < createIndex
? mappedParent
: createIndex === 0 ? null : 0,
}
}),
)
}
return (
<div className="org-create-backdrop" role="presentation" onMouseDown={onClose}>
<div className="org-create-modal" role="dialog" aria-modal="true" aria-labelledby="org-create-title" onMouseDown={e => e.stopPropagation()}>
<div className="org-create-header">
<div>
<span className="org-create-eyebrow">New organization</span>
<h3 id="org-create-title" className="org-create-title">Create a saved org</h3>
</div>
<button type="button" className="org-create-close" onClick={onClose} aria-label="Close">x</button>
</div>
<div className="org-create-steps" aria-label="Create organization steps">
{[
['1', 'Name'],
['2', 'Members'],
['3', 'Review'],
].map(([id, label]) => (
<span key={id} className={`org-create-step${step === Number(id) ? ' org-create-step--active' : step > Number(id) ? ' org-create-step--done' : ''}`}>
<span>{id}</span>{label}
</span>
))}
</div>
{step === 1 && (
<div className="org-create-panel">
<label className="org-create-field">
<span>Organization name</span>
<input
value={organizationName}
onChange={e => setOrganizationName(e.target.value)}
placeholder="HKU Research Lab"
autoFocus
/>
</label>
</div>
)}
{step === 2 && (
<div className="org-create-panel">
<div className="org-create-member-list">
{members.map((member, index) => (
<div className="org-create-member-row" key={index}>
<input
value={member.name}
onChange={e => updateMember(index, { name: e.target.value })}
placeholder={index === 0 ? 'Lead role' : 'Member role'}
/>
<input
value={member.responsibility}
onChange={e => updateMember(index, { responsibility: e.target.value })}
placeholder="Responsibility"
/>
<select
value={member.reportsToIndex == null ? 'owner' : String(member.reportsToIndex)}
onChange={e => updateMember(index, { reportsToIndex: e.target.value === 'owner' ? null : Number(e.target.value) })}
disabled={index === 0}
aria-label="Reports to"
>
<option value="owner">Owner</option>
{members.slice(0, index).map((candidate, candidateIndex) => (
<option key={candidateIndex} value={candidateIndex}>
{slugLabel(candidate.name)}
</option>
))}
</select>
<button
type="button"
className="org-create-icon-btn"
onClick={() => removeMember(index)}
disabled={members.length <= 2}
title="Remove member"
aria-label="Remove member"
>
-
</button>
<textarea
value={member.prompt}
onChange={e => updateMember(index, { prompt: e.target.value })}
placeholder="Prompt optional"
aria-label={`${index === 0 ? 'Lead role' : 'Member role'} prompt optional`}
/>
</div>
))}
</div>
<button type="button" className="org-create-add" onClick={addMember}>+ Add member</button>
</div>
)}
{step === 3 && (
<div className="org-create-panel">
<div className="org-create-review">
<div className="org-create-review-head">
<span>{organizationName.trim()}</span>
<b>{validMembers.length} members</b>
</div>
{previewMembers.map(member => (
<div className="org-create-review-row" key={member.index}>
<strong>{member.roleName}</strong>
<span>
{member.managerName}
{member.prompt.trim() ? <em>Prompt</em> : null}
</span>
</div>
))}
</div>
</div>
)}
{localError && <div className="org-create-error">{localError}</div>}
<div className="org-create-actions">
<button type="button" className="btn btn-ghost" onClick={step === 1 ? onClose : () => setStep(step - 1)}>
{step === 1 ? 'Cancel' : 'Back'}
</button>
{step < 3 ? (
<button
type="button"
className="btn btn-primary"
onClick={() => setStep(step + 1)}
disabled={step === 1 ? !organizationValid : !membersValid}
>
Next
</button>
) : (
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canCreate}>
{pending ? 'Creating...' : 'Create organization'}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,128 @@
/**
* Structural regression test for OrgTab's 4-tab layout.
*
* Guards the sub-tab rename (flow → runtime, marketplace → architecture +
* employees) and the three new marketplace panels. Reads OrgTab.tsx as
* source text and asserts against it — the existing zero-framework test
* convention (see runtimeOrg.test.ts, workItemSessions.test.ts) runs with
* plain `tsx` and requires no vitest / jsdom / @testing-library install.
*
* Why source-scan instead of React render:
* OrgTab.tsx imports './org.css'; Node can't load CSS without a vite
* transform. A source-scan catches the primary regression concerns
* (tab label rename, legacy label removal, panel import presence,
* default active tab) without pulling in a test runtime.
*
* Run with:
* tsx opc/plugins/office_ui/frontend_src/org/OrgTab.test.tsx
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'OrgTab.tsx'), 'utf8')
const createModalSrc = readFileSync(join(here, 'OrgCreateModal.tsx'), 'utf8')
const visualTypesSrc = readFileSync(join(here, '..', 'types', 'visual.ts'), 'utf8')
const orgCssSrc = readFileSync(join(here, 'org.css'), 'utf8')
// ── 1. Four sub-tab labels declared ──
for (const label of ['Team', 'Runtime', 'Architecture', 'Employees']) {
assert.match(
src,
new RegExp(`label:\\s*['"]${label}['"]`),
`OrgTab.tsx must declare tab label "${label}" (sub-tab rename regression)`,
)
}
// ── 2. Legacy labels removed ──
assert.doesNotMatch(
src,
/label:\s*['"]Marketplace['"]/,
'OrgTab.tsx must NOT declare legacy "Marketplace" sub-tab label',
)
assert.doesNotMatch(
src,
/label:\s*['"]Flow['"]/,
'OrgTab.tsx must NOT declare legacy "Flow" sub-tab label',
)
// ── 3. Active tabs typed against the new SubTab union ──
assert.match(
src,
/type\s+SubTab\s*=\s*['"]team['"]\s*\|\s*['"]runtime['"]\s*\|\s*['"]architecture['"]\s*\|\s*['"]employees['"]/,
'OrgTab.tsx must define SubTab = "team" | "runtime" | "architecture" | "employees"',
)
// ── 4. The three marketplace panels are imported ──
for (const comp of [
'ArchitectureMarketplace',
'EmployeesMarketplace',
'ConfigImportExportPanel',
]) {
assert.match(
src,
new RegExp(`import\\s*\\{\\s*${comp}\\s*\\}\\s*from\\s*['"]\\./${comp}['"]`),
`OrgTab.tsx must import ${comp} from './${comp}'`,
)
}
// ── 5. Default active tab is 'team' ──
assert.match(
src,
/useState<SubTab>\(\s*['"]team['"]\s*\)/,
'OrgTab.tsx must initialize activeTab state to "team"',
)
// ── 6. data-testid wired on each marketplace panel root ──
for (const [file, testId] of [
['EmployeesMarketplace.tsx', 'employees-marketplace'],
['ArchitectureMarketplace.tsx', 'architecture-marketplace'],
['ConfigImportExportPanel.tsx', 'config-import-export-panel'],
] as const) {
const panelSrc = readFileSync(join(here, file), 'utf8')
assert.match(
panelSrc,
new RegExp(`data-testid="${testId}"`),
`${file} root must carry data-testid="${testId}"`,
)
}
// ── 7. Create-org prompt is optional and carried in the member payload ──
assert.match(
visualTypesSrc,
/prompt\?:\s*string/,
'OrgCreateMemberInput must allow an optional prompt field',
)
assert.match(
createModalSrc,
/<textarea[\s\S]+placeholder="Prompt optional"/,
'OrgCreateModal must render an optional prompt textarea for each role',
)
assert.match(
createModalSrc,
/prompt:\s*member\.prompt\.trim\(\)/,
'OrgCreateModal submit payload must trim and include each role prompt',
)
assert.doesNotMatch(
createModalSrc,
/membersValid[\s\S]{0,120}prompt/,
'OrgCreateModal must not require prompt text for member validity',
)
// ── 8. Native select option popovers must have readable themed colors ──
assert.match(
orgCssSrc,
/\.org-switcher-select option\s*\{[\s\S]*background:\s*var\(--bg-elevated\);[\s\S]*color:\s*var\(--text\);/,
'Organization select options must use explicit themed colors',
)
assert.match(
orgCssSrc,
/\.org-create-member-row select option\s*\{[\s\S]*background:\s*var\(--bg-elevated\);[\s\S]*color:\s*var\(--text\);/,
'Create-org reports-to select options must use explicit themed colors',
)
console.log(
'OrgTab.test.tsx: OK (tabs, marketplace panels, create-org prompt, select option theme colors)',
)
@@ -0,0 +1,436 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
OrgInfoPayload,
OrgCreateMemberInput,
OrgSavedCreatePayload,
SavedOrgSummary,
TalentTemplate,
EmployeeDetailPayload,
ReorgProposalInfo,
ArchitecturePreset,
ArchitecturePresetDetail,
HireTalentHandler,
} from '../types/visual'
import { TeamView } from './TeamView'
import { DelegationStrategyPanel } from './DelegationStrategyPanel'
import { ArchitectureMarketplace } from './ArchitectureMarketplace'
import { EmployeesMarketplace } from './EmployeesMarketplace'
import { ConfigImportExportPanel } from './ConfigImportExportPanel'
import { OrgCreateModal } from './OrgCreateModal'
import { getRuntimeOrgView } from '../lib/runtimeOrg'
import './org.css'
import './team.css'
import './marketplace.css'
import './config.css'
import './structure.css'
/* ── Inline SVG icon data-URIs (no external CDN) ────────────────── */
const TAB_ICON = {
team: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z'/%3E%3C/svg%3E",
runtime: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M13 2.05v2.02c3.95.49 7 3.85 7 7.93 0 3.21-1.81 6-4.72 7.72L13 17v5h5l-1.22-1.22C19.91 19.07 22 15.76 22 12c0-5.18-3.95-9.45-9-9.95zM11 2.05C5.95 2.55 2 6.82 2 12c0 3.76 2.09 7.07 5.22 8.78L6 22h5V2.05z'/%3E%3C/svg%3E",
architecture: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z'/%3E%3C/svg%3E",
employees: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
}
const STAT_ICON = {
agents: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20 9V7c0-1.1-.9-2-2-2h-3c0-1.66-1.34-3-3-3S9 3.34 9 5H6c-1.1 0-2 .9-2 2v2c-1.66 0-3 1.34-3 3s1.34 3 3 3v4c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-4c1.66 0 3-1.34 3-3s-1.34-3-3-3z'/%3E%3C/svg%3E",
active: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z'/%3E%3C/svg%3E",
}
const SECTION_ICON = {
packages: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z'/%3E%3C/svg%3E",
channels: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z'/%3E%3C/svg%3E",
reorg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z'/%3E%3C/svg%3E",
importPkg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z'/%3E%3C/svg%3E",
}
type SubTab = 'team' | 'runtime' | 'architecture' | 'employees'
function humanizeOrgName(value?: string | null): string {
const normalized = String(value ?? '').trim()
if (!normalized) return ''
return normalized
.replace(/^org[_-]/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, char => char.toUpperCase())
}
interface OrgTabProps {
data: OrgInfoPayload | null
/** role_id -> recruited names for the selected session (canvas display only). */
sessionRecruitmentByRole?: Record<string, string[]> | null
talents: TalentTemplate[]
employeeDetail: EmployeeDetailPayload | null
reorgProposals: ReorgProposalInfo[]
isCustomMode?: boolean
onRequestData: () => void
onRequestTalents: () => void
onRequestEmployeeDetail: (employeeId: string) => void
onHireTalent: HireTalentHandler
hiringTemplateId?: string | null
onImportEmployee?: (employeeId: string) => void
onRequestReorgList: () => void
onReorgDecide: (proposalId: string, approved: boolean, notes?: string) => void
// Market
onMarketExport?: (data: { package_id: string; name: string; description: string; version: string }) => void
onMarketInstall?: (path: string, strategy: string) => void
onMarketUninstall?: (packageId: string) => void
// Architecture gallery
marketPresets?: ArchitecturePreset[]
marketPreviewData?: ArchitecturePresetDetail | null
onMarketBrowse?: () => void
onMarketPreview?: (presetId: string) => void
onMarketApplyPreset?: (presetId: string, strategy: string) => void
onMarketClearPreview?: () => void
// Config import/export
onConfigExport?: () => void
onConfigImport?: (yaml: string, dryRun: boolean) => void
configExportYaml?: string | null
configImportPreview?: { roles_added: number; roles_removed: number; employees_changed: number } | null
configImportError?: string | null
// Saved org architectures (named snapshots) — rendered in the Team tab toolbar
onSavedOrgsList?: () => void
onSavedOrgSaveAs?: (name: string, overwrite: boolean) => void
onSavedOrgCreate?: (organizationName: string, members: OrgCreateMemberInput[]) => void
onSavedOrgLoad?: (name: string) => void
onSavedOrgDelete?: (name: string) => void
savedOrgsList?: SavedOrgSummary[] | null
activeSavedOrg?: string | null
activeSavedOrgVersionAtLoad?: number | null
orgCreatePending?: boolean
orgCreateResult?: (OrgSavedCreatePayload & { nonce: number }) | null
onSelectCorporate?: () => void
// Org editing
onAddRole?: (roleId: string, name: string, responsibility: string, reportsTo: string, icon?: string | null) => void
onBulkAddRoles?: (roles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string }>) => void
onUpdateRole?: (roleId: string, updates: { name?: string; responsibility?: string; reports_to?: string; can_spawn?: string[]; icon?: string | null; execution_strategy?: string; preferred_external_agent?: string | null; prompt_refs?: string[] }) => void
onDeleteRole?: (roleId: string) => void
onUpdateOrgStrategy?: (data: { final_decider_role_id?: string | null }) => void
onUpdateRuntimePolicy?: (policy: Record<string, any>) => void
onResetArchitecture?: () => void
}
export function OrgTab({
data, sessionRecruitmentByRole, talents, employeeDetail, reorgProposals, isCustomMode,
onRequestData, onRequestTalents, onRequestEmployeeDetail,
onHireTalent, hiringTemplateId, onImportEmployee, onRequestReorgList, onReorgDecide,
onMarketExport, onMarketInstall, onMarketUninstall,
marketPresets, marketPreviewData, onMarketBrowse, onMarketPreview, onMarketApplyPreset, onMarketClearPreview,
onAddRole, onBulkAddRoles, onUpdateRole, onDeleteRole, onUpdateOrgStrategy,
onUpdateRuntimePolicy, onResetArchitecture,
onConfigExport, onConfigImport, configExportYaml, configImportPreview, configImportError,
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgCreate, onSavedOrgLoad, onSavedOrgDelete, savedOrgsList,
activeSavedOrg, activeSavedOrgVersionAtLoad, orgCreatePending, orgCreateResult, onSelectCorporate,
}: OrgTabProps) {
const [activeTab, setActiveTab] = useState<SubTab>('team')
const [createOpen, setCreateOpen] = useState(false)
const switchTab = useCallback((tab: SubTab) => {
setActiveTab(tab)
}, [])
const [applyingPresetId, setApplyingPresetId] = useState<string | null>(null)
const versionAtApply = useRef<number>(-1) // track org_version when apply starts
const [toast, setToast] = useState<{ msg: string; type: 'info' | 'warn' } | null>(null)
const toastTimer = useRef<ReturnType<typeof setTimeout>>(null)
const showToast = useCallback((msg: string, type: 'info' | 'warn' = 'info') => {
setToast({ msg, type })
if (toastTimer.current) clearTimeout(toastTimer.current)
toastTimer.current = setTimeout(() => setToast(null), 3000)
}, [])
useEffect(() => () => {
if (toastTimer.current) clearTimeout(toastTimer.current)
}, [])
const onRequestDataRef = useRef(onRequestData)
const onRequestTalentsRef = useRef(onRequestTalents)
const onRequestReorgListRef = useRef(onRequestReorgList)
const onMarketBrowseRef = useRef(onMarketBrowse)
const onSavedOrgsListRef = useRef(onSavedOrgsList)
onRequestDataRef.current = onRequestData
onRequestTalentsRef.current = onRequestTalents
onRequestReorgListRef.current = onRequestReorgList
onMarketBrowseRef.current = onMarketBrowse
onSavedOrgsListRef.current = onSavedOrgsList
useEffect(() => {
onRequestDataRef.current()
onRequestTalentsRef.current()
onRequestReorgListRef.current()
onMarketBrowseRef.current?.()
onSavedOrgsListRef.current?.()
}, [])
useEffect(() => {
if (!orgCreateResult || !orgCreateResult.ok) return
setCreateOpen(false)
setActiveTab('team')
showToast(`Created ${orgCreateResult.organization_name || orgCreateResult.name} and saved automatically`)
}, [orgCreateResult, showToast])
// In org mode: show only user-owned roles. In company mode: show all roles read-only.
const allRoles = data?.roles ?? []
const displayRoles = useMemo(() => isCustomMode ? allRoles.filter(r => !r.is_builtin) : allRoles, [allRoles, isCustomMode])
const displayEmployees = useMemo(() => data?.employees ?? [], [data?.employees])
const runtimeView = useMemo(() => getRuntimeOrgView(data), [data])
const activeAgents = useMemo(() => displayEmployees.filter(e => e.linked_agent_id), [displayEmployees])
const configuredOrgName = data?.organization_name?.trim()
const activeOrgLabel = configuredOrgName || humanizeOrgName(activeSavedOrg) || (isCustomMode ? 'Custom org' : 'Corporate company')
const activeOrgId = (isCustomMode ? (data?.organization_id || activeSavedOrg) : 'corporate') || ''
const architectureKindLabel = isCustomMode ? 'Saved org' : 'Corporate'
const architectureStateLabel = isCustomMode
? activeSavedOrg ? 'Editable saved architecture' : 'Editable draft architecture'
: 'Built-in read-only architecture'
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || 'ready'
// Roles that already have at least one non-placeholder employee.
const filledRoleIds = useMemo(
() => {
const ids = new Set<string>()
for (const employee of displayEmployees) {
if (employee.is_default_employee) continue
const roleIds = employee.role_ids?.length ? employee.role_ids : [employee.role_id]
for (const roleId of roleIds) {
if (roleId) ids.add(roleId)
}
}
return ids
},
[displayEmployees],
)
const vacantRoles = useMemo(() => displayRoles.filter(r => !filledRoleIds.has(r.role_id)), [displayRoles, filledRoleIds])
const installedIds = useMemo(() => new Set((data?.installed_packages ?? []).map(p => p.package_id)), [data?.installed_packages])
// When applying a preset, wait for org_version to change (every config.save() increments it)
const orgVersion = data?.org_version ?? 0
useEffect(() => {
if (applyingPresetId && versionAtApply.current >= 0 && orgVersion !== versionAtApply.current) {
setApplyingPresetId(null)
versionAtApply.current = -1
setActiveTab('team')
showToast('Architecture applied successfully')
}
}, [orgVersion, applyingPresetId, showToast])
const handleApplyPreset = (presetId: string, strategy: string) => {
versionAtApply.current = orgVersion // snapshot current version
setApplyingPresetId(presetId)
onMarketApplyPreset?.(presetId, strategy)
}
const handleOrgSelection = (value: string) => {
if (value === 'corporate') {
onSelectCorporate?.()
return
}
if (value.startsWith('org:')) {
const orgName = value.slice(4)
if (orgName) onSavedOrgLoad?.(orgName)
}
}
if (!data) {
return <div className="org-tab"><div className="org-loading">Loading organization data...</div></div>
}
const installedPackages = data.installed_packages ?? []
const savedOrgOptions = savedOrgsList ?? []
const selectedOrgValue = isCustomMode && activeSavedOrg ? `org:${activeSavedOrg}` : 'corporate'
return (
<div className="org-tab">
<div className={`org-header${isCustomMode ? ' org-header--custom' : ' org-header--corporate'}`}>
<div className="org-header-main">
<div className="org-eyebrow">
<span>Company</span>
<span className="org-eyebrow-separator">/</span>
<span>{architectureKindLabel}</span>
</div>
<div className="org-title-row">
<h2 className="org-title">{activeOrgLabel}</h2>
<span className="org-version-badge">v{data.org_version}</span>
<span className={`org-state-badge${isCustomMode ? ' org-state-badge--editable' : ' org-state-badge--readonly'}`}>
{architectureStateLabel}
</span>
</div>
<div className="org-header-meta">
<span className="org-meta-pill">{data.company_profile || (isCustomMode ? 'custom' : 'corporate')}</span>
{activeOrgId && <code className="org-meta-code">{activeOrgId}</code>}
<span className="org-meta-pill org-meta-pill--runtime">{runtimeStateLabel}</span>
<span className="org-meta-pill org-meta-pill--saved">Auto-saved</span>
</div>
</div>
<div className="org-control-panel">
<label className="org-switcher">
<span className="org-switcher-label">Organization</span>
<span className="org-switcher-select-wrap">
<select
className="org-switcher-select"
value={selectedOrgValue}
onChange={e => handleOrgSelection(e.target.value)}
onFocus={() => onSavedOrgsList?.()}
onPointerDown={() => onSavedOrgsList?.()}
aria-label="Organization"
>
<option value="corporate">Corporate</option>
{savedOrgOptions.map(org => (
<option key={org.name} value={`org:${org.name}`}>
{(org.organization_name || org.name).trim() || org.name}
</option>
))}
</select>
</span>
</label>
<button type="button" className="org-create-trigger" onClick={() => setCreateOpen(true)}>
<span className="org-create-trigger-icon" aria-hidden>+</span>
New organization
</button>
</div>
<div className="org-stats-strip">
<span className="org-stat">
<img src={STAT_ICON.agents} alt="" className="org-stat-icon" />
<b>{displayRoles.length}</b> roles
</span>
<span className="org-stat">
<img src={TAB_ICON.employees} alt="" className="org-stat-icon" />
<b>{displayEmployees.length}</b> employees
</span>
<span className="org-stat">
<img src={TAB_ICON.runtime} alt="" className="org-stat-icon" />
<b>{runtimeView.runtimeTeams.length}</b> runtime teams
</span>
<span className="org-stat">
<img src={STAT_ICON.active} alt="" className="org-stat-icon" />
<b>{activeAgents.length}</b> active
</span>
</div>
</div>
<div className="org-subtabs">
{([
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: 'Team', count: displayRoles.length },
{ id: 'runtime' as SubTab, icon: TAB_ICON.runtime, label: 'Runtime', count: runtimeView.runtimeTeams.length },
{ id: 'architecture' as SubTab, icon: TAB_ICON.architecture, label: 'Architecture', count: marketPresets?.length ?? 0 },
{ id: 'employees' as SubTab, icon: TAB_ICON.employees, label: 'Employees', count: talents.length },
]).map(tab => (
<button key={tab.id}
className={`org-subtab${activeTab === tab.id ? ' org-subtab--active' : ''}`}
onClick={() => switchTab(tab.id)}>
<img src={tab.icon} alt="" className="org-subtab-icon" />
<span className="org-subtab-label">{tab.label}</span>
<span className="org-subtab-count">{tab.count}</span>
</button>
))}
</div>
{/* ── Tab content ─────────────────────────────────── */}
{/* Toast notification */}
{toast && (
<div className={`org-toast org-toast--${toast.type}`} onClick={() => setToast(null)}>
{toast.msg}
</div>
)}
<div className="org-tab-content">
{/* Team tab */}
{activeTab === 'team' && (
<TeamView
roles={displayRoles}
employees={displayEmployees}
sessionRecruitmentByRole={sessionRecruitmentByRole}
isCustomMode={isCustomMode}
onAddRole={onAddRole ?? (() => {})}
onBulkAddRoles={onBulkAddRoles}
onUpdateRole={onUpdateRole ?? (() => {})}
onDeleteRole={onDeleteRole ?? (() => {})}
onExport={onMarketExport ?? (() => {})}
onImportEmployee={onImportEmployee}
onResetArchitecture={onResetArchitecture}
onSwitchToTab={(target) => setActiveTab(target)}
savedOrgsList={savedOrgsList}
activeSavedOrg={activeSavedOrg ?? null}
currentOrgVersion={data?.org_version ?? 0}
versionAtLoad={activeSavedOrgVersionAtLoad ?? null}
onSavedOrgsList={onSavedOrgsList}
onSavedOrgSaveAs={onSavedOrgSaveAs}
onSavedOrgLoad={onSavedOrgLoad}
onSavedOrgDelete={onSavedOrgDelete}
/>
)}
{/* Runtime tab */}
{activeTab === 'runtime' && (
<DelegationStrategyPanel
roles={displayRoles}
runtimeTeams={runtimeView.runtimeTeams}
runtimeSeats={runtimeView.runtimeSeats}
workItems={runtimeView.workItems}
frontier={runtimeView.frontier}
companyProfile={data.company_profile}
runtimePolicy={data.runtime_policy}
finalDeciderRoleId={data.final_decider_role_id}
topLevelRoleIds={data.top_level_role_ids}
readOnly={!isCustomMode}
onUpdateOrgStrategy={onUpdateOrgStrategy}
onUpdateRuntimePolicy={onUpdateRuntimePolicy}
/>
)}
{/* Architecture tab */}
{activeTab === 'architecture' && (
<>
<ArchitectureMarketplace
presets={marketPresets ?? []}
installedIds={installedIds}
previewData={marketPreviewData ?? null}
applyingPresetId={applyingPresetId}
readOnly={!isCustomMode}
onPreview={onMarketPreview ?? (() => {})}
onApplyPreset={handleApplyPreset}
onClearPreview={onMarketClearPreview ?? (() => {})}
installedPackages={installedPackages}
channels={data.channels}
reorgProposals={reorgProposals}
isCustomMode={!!isCustomMode}
onReorgDecide={onReorgDecide}
onMarketInstall={(p, s) => onMarketInstall?.(p, s)}
onMarketUninstall={(id) => onMarketUninstall?.(id)}
/>
{isCustomMode && (
<ConfigImportExportPanel
onExport={onConfigExport ?? (() => {})}
onImport={onConfigImport ?? (() => {})}
configExportYaml={configExportYaml ?? null}
importPreview={configImportPreview ?? null}
importError={configImportError ?? null}
/>
)}
</>
)}
{/* Employees tab */}
{activeTab === 'employees' && (
<EmployeesMarketplace
templates={talents}
vacantRoles={vacantRoles}
hiringTemplateId={hiringTemplateId ?? null}
readOnly={!isCustomMode}
onHireTalent={onHireTalent}
/>
)}
</div>
<OrgCreateModal
open={createOpen}
pending={orgCreatePending}
result={orgCreateResult ?? null}
onClose={() => setCreateOpen(false)}
onCreate={(organizationName, members) => onSavedOrgCreate?.(organizationName, members)}
/>
</div>
)
}
@@ -0,0 +1,378 @@
/**
* OrgVersionSwitcher — editor-toolbar "version picker" for the org.
*
* Conceptually equivalent to Figma's page switcher or VS Code's git-branch
* indicator: a compact pill showing the active saved-org name (+ modified
* indicator when the editor has changed since the last loaded snapshot),
* plus a glass popover command-menu for search / load / save-as-copy
* / delete.
*
* All visual tokens match the house dialect (refined-technical, dark, no
* emoji, no purple gradients). Styles live in structure.css under `.sos-*`.
*
* Lives in the StructureEditor toolbar (Team tab, org mode only).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
interface SavedOrg {
name: string
organization_name?: string
saved_at: number
roles_count: number
employees_count: number
}
export interface OrgVersionSwitcherProps {
savedOrgs: SavedOrg[] | null
activeName: string | null
isDirty: boolean
onRefresh: () => void
onSaveAs: (name: string, overwrite: boolean) => void
onLoad: (name: string) => void
onDelete: (name: string) => void
}
const MAX_DISPLAY_NAME = 80
function formatRelativeTime(epochSeconds: number): string {
const now = Date.now() / 1000
const diff = Math.max(0, now - epochSeconds)
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
try {
return new Date(epochSeconds * 1000).toLocaleDateString()
} catch {
return ''
}
}
function displayOrgName(org: SavedOrg): string {
return (org.organization_name || org.name).trim() || org.name
}
function isValidDisplayName(value: string): boolean {
const trimmed = value.trim()
return trimmed.length > 0
&& trimmed.length <= MAX_DISPLAY_NAME
&& !/[\\/]/.test(trimmed)
&& !/[\u0000-\u001f]/.test(trimmed)
}
function slugifyOrgDisplayName(value: string): string {
const ascii = value.normalize('NFKD').replace(/[^\x00-\x7F]/g, '')
const slug = ascii
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^[_-]+|[_-]+$/g, '')
return slug.slice(0, 64) || 'org'
}
/* Inline SVG glyphs — match the house convention (no emoji, no font icons). */
function LayersGlyph({ className }: { className?: string }) {
return (
<svg className={className} width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M8 1.5L1.5 4.5L8 7.5L14.5 4.5L8 1.5Z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<path d="M2 8L8 10.8L14 8" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" opacity="0.6" />
<path d="M2 11.5L8 14.2L14 11.5" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" opacity="0.35" />
</svg>
)
}
function Caret({ className }: { className?: string }) {
return (
<svg className={className} width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function SearchGlyph({ className }: { className?: string }) {
return (
<svg className={className} width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
<circle cx="5" cy="5" r="3.2" stroke="currentColor" strokeWidth="1.3" />
<path d="M7.5 7.5L10 10" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
)
}
export function OrgVersionSwitcher({
savedOrgs, activeName, isDirty, onRefresh, onSaveAs, onLoad, onDelete,
}: OrgVersionSwitcherProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [highlighted, setHighlighted] = useState(0)
const [saveAsMode, setSaveAsMode] = useState(false)
const [saveAsName, setSaveAsName] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [loadingName, setLoadingName] = useState<string | null>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const saveInputRef = useRef<HTMLInputElement>(null)
// Clear loading state once the list or activeName updates (proxy for ack).
useEffect(() => { setLoadingName(null) }, [activeName, savedOrgs])
// Filter list by search.
const filtered = (savedOrgs ?? []).filter(o =>
!search.trim() || o.name.toLowerCase().includes(search.trim().toLowerCase()),
)
// Refresh once on first open.
const firstOpenRef = useRef(false)
useEffect(() => {
if (open && !firstOpenRef.current) {
firstOpenRef.current = true
onRefresh()
}
}, [open, onRefresh])
// Auto-focus save input when entering save-as mode.
useEffect(() => {
if (saveAsMode) saveInputRef.current?.focus()
}, [saveAsMode])
// Clamp highlight when filtered list shrinks.
useEffect(() => {
setHighlighted(h => Math.min(h, Math.max(0, filtered.length - 1)))
}, [filtered.length])
// Close on outside click.
useEffect(() => {
if (!open) return
const onDocClick = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
closePopover()
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const closePopover = useCallback(() => {
setOpen(false)
setSearch('')
setHighlighted(0)
setSaveAsMode(false)
setSaveAsName('')
setConfirmDelete(null)
}, [])
const handleLoad = useCallback((name: string) => {
if (name === activeName) {
// Already active — no need to round-trip; just close the popover.
closePopover()
return
}
setLoadingName(name)
onLoad(name)
closePopover()
}, [onLoad, closePopover, activeName])
const handleDelete = useCallback((name: string) => {
onDelete(name)
setConfirmDelete(null)
}, [onDelete])
const saveAsTrimmed = saveAsName.trim()
const saveAsValid = isValidDisplayName(saveAsTrimmed)
const saveAsSlug = slugifyOrgDisplayName(saveAsTrimmed)
const saveAsExists = (savedOrgs ?? []).some(o =>
o.name === saveAsSlug || displayOrgName(o).toLowerCase() === saveAsTrimmed.toLowerCase(),
)
const handleSaveAs = useCallback(() => {
if (!saveAsValid) return
onSaveAs(saveAsTrimmed, saveAsExists)
setSaveAsMode(false)
setSaveAsName('')
closePopover()
}, [saveAsValid, saveAsTrimmed, saveAsExists, onSaveAs, closePopover])
// Keyboard navigation inside the popover.
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
closePopover()
return
}
if (saveAsMode) {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveAs()
}
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlighted(h => Math.min(h + 1, Math.max(0, filtered.length - 1)))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlighted(h => Math.max(0, h - 1))
} else if (e.key === 'Enter') {
const target = filtered[highlighted]
if (target) handleLoad(target.name)
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
const target = filtered[highlighted]
if (target) {
e.preventDefault()
setConfirmDelete(target.name)
}
}
}
return (
<div className="sos-wrap" ref={wrapperRef}>
<button
type="button"
className="sos-pill"
data-open={open ? 'true' : 'false'}
onClick={() => setOpen(o => !o)}
aria-haspopup="listbox"
aria-expanded={open}
title={activeName ? `Active saved architecture: ${activeName}` : 'No saved architecture loaded'}
>
<LayersGlyph className="sos-pill-glyph" />
{activeName ? (
<span className="sos-pill-name">{activeName}</span>
) : (
<span className="sos-pill-name sos-pill-name--placeholder">draft</span>
)}
{isDirty && <span className="sos-pill-dirty" aria-label="Modified since opened" />}
<Caret className="sos-pill-caret" />
</button>
{open && (
<div className="sos-popover" role="listbox" onKeyDown={onKeyDown} tabIndex={-1}>
<div className="sos-search-row">
<SearchGlyph className="sos-search-glyph" />
<input
type="text"
className="sos-search-input"
placeholder="Search architectures…"
value={search}
onChange={e => { setSearch(e.target.value); setHighlighted(0) }}
autoFocus
onKeyDown={onKeyDown}
/>
<kbd className="sos-search-hint"> </kbd>
</div>
<div className="sos-list">
{savedOrgs === null ? (
<div className="sos-empty">Loading</div>
) : filtered.length === 0 ? (
<div className="sos-empty">
{(savedOrgs ?? []).length === 0 ? 'No saved architectures.' : 'No matches.'}
</div>
) : (
filtered.map((org, idx) => {
const isActive = activeName === org.name
const isHighlighted = highlighted === idx
const title = displayOrgName(org)
return (
<div
key={org.name}
className={`sos-row${isActive ? ' sos-row--active' : ''}`}
data-highlighted={isHighlighted ? 'true' : 'false'}
onMouseEnter={() => setHighlighted(idx)}
onClick={() => handleLoad(org.name)}
role="option"
aria-selected={isActive}
>
<div className="sos-row-meta">
<span className="sos-row-name">{title}</span>
<span className="sos-row-stats">
{title !== org.name && `${org.name} · `}
{org.roles_count} {org.roles_count === 1 ? 'role' : 'roles'}
{' · '}
{org.employees_count} {org.employees_count === 1 ? 'employee' : 'employees'}
{' · '}
{formatRelativeTime(org.saved_at)}
</span>
</div>
{loadingName === org.name ? (
<span className="sos-row-loading">loading</span>
) : isActive ? (
<span className="sos-row-active-chip">active</span>
) : confirmDelete === org.name ? (
<button
type="button"
className="sos-row-delete sos-row-delete--confirm"
onClick={e => { e.stopPropagation(); handleDelete(org.name) }}
>
confirm
</button>
) : (
<button
type="button"
className="sos-row-delete"
onClick={e => { e.stopPropagation(); setConfirmDelete(org.name) }}
title="Delete"
>
delete
</button>
)}
</div>
)
})
)}
</div>
<div className="sos-save-as">
{saveAsMode ? (
<div className="sos-save-as-form">
<input
ref={saveInputRef}
type="text"
className="sos-save-as-input"
placeholder="Organization name"
value={saveAsName}
onChange={e => setSaveAsName(e.target.value)}
onKeyDown={onKeyDown}
/>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSaveAs}
disabled={!saveAsValid}
>
{saveAsExists ? 'Overwrite copy' : 'Save as copy'}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => { setSaveAsMode(false); setSaveAsName('') }}
>
Cancel
</button>
</div>
) : (
<button
type="button"
className="sos-save-as-trigger"
onClick={() => setSaveAsMode(true)}
>
+ Save as copy...
</button>
)}
{saveAsMode && saveAsName && !saveAsValid && (
<div className="sos-save-as-hint sos-save-as-hint--warn">
Use up to 80 characters. Slashes are not allowed.
</div>
)}
{saveAsMode && saveAsValid && saveAsExists && (
<div className="sos-save-as-hint sos-save-as-hint--warn">
Name exists - this will overwrite that copy.
</div>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,56 @@
import type { InstalledPackageInfo } from '../types/visual'
interface PackageCardProps {
pkg: InstalledPackageInfo
onUninstall?: (packageId: string) => void
uninstallingId?: string | null
}
export function PackageCard({ pkg, onUninstall, uninstallingId }: PackageCardProps) {
const isUninstalling = uninstallingId === pkg.package_id
return (
<div className="pkg-card">
<div className="pkg-card-header">
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z'/%3E%3C/svg%3E"
alt="package"
className="pkg-card-icon"
/>
<div className="pkg-card-title-wrap">
<span className="pkg-card-name">{pkg.name || pkg.package_id}</span>
<span className="pkg-card-version">v{pkg.version}</span>
</div>
</div>
<div className="pkg-card-stats">
<span className="pkg-card-stat">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E" alt="roles" className="pkg-stat-icon" />
{pkg.role_ids.length} roles
</span>
<span className="pkg-card-stat">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.11-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E" alt="templates" className="pkg-stat-icon" />
{pkg.template_ids.length} templates
</span>
</div>
{pkg.installed_at && (
<div className="pkg-card-date">
Installed {new Date(pkg.installed_at).toLocaleDateString()}
</div>
)}
{onUninstall && (
<div className="pkg-card-actions">
<button
className="pkg-btn pkg-btn-danger"
disabled={isUninstalling}
onClick={(e) => { e.stopPropagation(); onUninstall(pkg.package_id) }}
>
{isUninstalling ? 'Removing...' : 'Uninstall'}
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,468 @@
/**
* RoleInspector — Figma-style floating property panel (D4).
*
* Renders on the right of the StructureEditor when a role is selected.
* Six collapsible groups:
* 1. Identity (expanded by default): name, responsibility, icon
* 2. Hierarchy (expanded by default): reports_to, can_spawn
* 3. Tools (collapsed): 22-item checklist grouped by prefix
* 4. Prompts (collapsed): textarea (one prompt_ref per line)
* 5. Runtime (collapsed): execution_strategy, preferred_external_agent
* 6. Advanced (collapsed): role_type, skill_refs, artifact_contract_ref
*
* Edits are debounced 500ms and batched into a single onUpdateRole call per
* quiescence window.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { OrgRole, OrgEmployee } from '../types/visual'
import { ROLE_ICON_KEYS, ROLE_ICONS, resolveRoleIcon, type RoleIconKey } from './roleIcons'
/** Tool union derived from company_runtime_profiles.py _CORPORATE_*_TOOLS. */
const AVAILABLE_TOOLS = [
'file_read', 'file_write', 'file_edit', 'file_search', 'list_dir',
'shell_exec',
'web_search', 'web_fetch',
'todo_read', 'todo_write',
'browser_navigate', 'browser_navigate_back', 'browser_snapshot',
'browser_wait_for', 'browser_scroll', 'browser_click', 'browser_type',
'browser_select_option', 'browser_take_screenshot',
'browser_evaluate', 'browser_close',
] as const
const TOOL_GROUPS: { label: string; prefix: string; tools: readonly string[] }[] = [
{ label: 'Files', prefix: 'file_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('file_')) },
{ label: 'Shell', prefix: 'shell_', tools: AVAILABLE_TOOLS.filter(t => t === 'shell_exec') },
{ label: 'Web', prefix: 'web_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('web_')) },
{ label: 'TODOs', prefix: 'todo_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('todo_')) },
{ label: 'Browser', prefix: 'browser_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('browser_')) },
]
const EXTERNAL_AGENTS = ['codex', 'cursor', 'claude_code', 'opencode'] as const
const EXECUTION_STRATEGIES = [
{ value: 'auto', label: 'Auto', hint: 'System picks native or external based on role config' },
{ value: 'native', label: 'Native', hint: 'Run directly in-process via LLM' },
{ value: 'external', label: 'External', hint: 'Delegate to an external agent (codex, cursor, etc.)' },
] as const
/* ── Props ─────────────────────────────────────────────────────── */
interface RoleInspectorProps {
role: OrgRole
allRoles: OrgRole[]
employees: OrgEmployee[]
readOnly?: boolean
onUpdateRole: (roleId: string, updates: RoleUpdatePatch) => void
onDeleteRole: (roleId: string) => void
onClose: () => void
}
/** Matches the shape that App.tsx's onUpdateRole accepts; `tools` is forwarded
* via the same path (backend RoleConfig Pydantic model accepts it). */
export interface RoleUpdatePatch {
name?: string
responsibility?: string
reports_to?: string
can_spawn?: string[]
icon?: string | null
execution_strategy?: string
preferred_external_agent?: string | null
prompt_refs?: string[]
tools?: string[]
}
/* ── RoleInspector ─────────────────────────────────────────────── */
export function RoleInspector({
role, allRoles, employees, readOnly,
onUpdateRole, onDeleteRole, onClose,
}: RoleInspectorProps) {
const [name, setName] = useState(role.name)
const [responsibility, setResponsibility] = useState(role.responsibility)
const [reportsTo, setReportsTo] = useState(role.reports_to)
const [iconKey, setIconKey] = useState<string | null>(role.icon ?? null)
const [canSpawn, setCanSpawn] = useState<Set<string>>(() => new Set(role.can_spawn))
const [tools, setTools] = useState<Set<string>>(() => new Set(role.tools))
const [execStrategy, setExecStrategy] = useState<string>(
role.runtime_policy?.execution_strategy ?? 'auto',
)
const [extAgent, setExtAgent] = useState<string | null>(role.preferred_external_agent ?? null)
const [promptRefs, setPromptRefs] = useState<string>((role.prompt_refs ?? []).join('\n'))
const [confirmDelete, setConfirmDelete] = useState(false)
// Reset local state when selected role changes
const lastRoleIdRef = useRef(role.role_id)
useEffect(() => {
if (lastRoleIdRef.current === role.role_id) return
lastRoleIdRef.current = role.role_id
setName(role.name)
setResponsibility(role.responsibility)
setReportsTo(role.reports_to)
setIconKey(role.icon ?? null)
setCanSpawn(new Set(role.can_spawn))
setTools(new Set(role.tools))
setExecStrategy(role.runtime_policy?.execution_strategy ?? 'auto')
setExtAgent(role.preferred_external_agent ?? null)
setPromptRefs((role.prompt_refs ?? []).join('\n'))
setConfirmDelete(false)
}, [role])
/* ── Debounced save: batch fragments, fire once after 500ms quiescence ── */
const dirtyRef = useRef<RoleUpdatePatch>({})
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const roleIdRef = useRef(role.role_id)
useEffect(() => { roleIdRef.current = role.role_id }, [role.role_id])
const flush = useCallback(() => {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
const patch = dirtyRef.current
dirtyRef.current = {}
if (Object.keys(patch).length === 0) return
onUpdateRole(roleIdRef.current, patch)
}, [onUpdateRole])
const scheduleSave = useCallback((fragment: RoleUpdatePatch) => {
if (readOnly) return
dirtyRef.current = { ...dirtyRef.current, ...fragment }
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(flush, 500)
}, [flush, readOnly])
// Unmount flush — capture any pending edits so they are not lost
useEffect(() => {
return () => {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
const patch = dirtyRef.current
if (Object.keys(patch).length > 0) {
dirtyRef.current = {}
onUpdateRole(roleIdRef.current, patch)
}
}
}, [onUpdateRole])
/* ── Setters ───────────────────────────────────────────────── */
const handleNameChange = (v: string) => { setName(v); scheduleSave({ name: v }) }
const handleResponsibilityChange = (v: string) => { setResponsibility(v); scheduleSave({ responsibility: v }) }
const handleReportsToChange = (v: string) => { setReportsTo(v); scheduleSave({ reports_to: v }) }
const handleIconChange = (v: string | null) => { setIconKey(v); scheduleSave({ icon: v }) }
const handleExecStrategyChange = (v: string) => { setExecStrategy(v); scheduleSave({ execution_strategy: v }) }
const handleExtAgentChange = (v: string | null) => { setExtAgent(v); scheduleSave({ preferred_external_agent: v }) }
const handlePromptRefsChange = (v: string) => {
setPromptRefs(v)
const lines = v.split('\n').map(s => s.trim()).filter(Boolean)
scheduleSave({ prompt_refs: lines })
}
const toggleCanSpawn = (id: string) => {
const next = new Set(canSpawn)
if (next.has(id)) next.delete(id); else next.add(id)
setCanSpawn(next)
scheduleSave({ can_spawn: Array.from(next) })
}
const toggleTool = (toolName: string) => {
const next = new Set(tools)
if (next.has(toolName)) next.delete(toolName); else next.add(toolName)
setTools(next)
scheduleSave({ tools: Array.from(next) })
}
/* ── Derived ───────────────────────────────────────────────── */
const otherRoles = useMemo(
() => allRoles.filter(r => r.role_id !== role.role_id),
[allRoles, role.role_id],
)
const employeeCount = useMemo(
() => employees.filter(e => (e.role_ids?.length ? e.role_ids : [e.role_id]).includes(role.role_id)).length,
[employees, role.role_id],
)
const promptRefCount = useMemo(
() => promptRefs.split('\n').filter(s => s.trim()).length,
[promptRefs],
)
/* ── Delete (2-step confirm) ──────────────────────────────── */
const handleDelete = () => {
if (!confirmDelete) { setConfirmDelete(true); return }
flush()
onDeleteRole(role.role_id)
}
return (
<aside className="ri-panel" aria-label={`Inspector for role ${role.name}`}>
<header className="ri-panel-header">
<img src={resolveRoleIcon(iconKey)} alt="" className="ri-panel-icon" />
<div className="ri-panel-title-wrap">
<h3 className="ri-panel-title">{name || '(unnamed)'}</h3>
<code className="ri-panel-id">{role.role_id}</code>
</div>
<button className="btn btn-ghost btn-sm ri-panel-close" onClick={onClose} title="Close (Esc)"></button>
</header>
<div className="ri-panel-body">
<InspectorGroup title="Identity" defaultExpanded>
<InspectorField label="Name">
<input
className="ri-text-input"
value={name}
onChange={e => handleNameChange(e.target.value)}
disabled={readOnly}
/>
</InspectorField>
<InspectorField label="Responsibility">
<textarea
className="ri-textarea"
rows={3}
value={responsibility}
onChange={e => handleResponsibilityChange(e.target.value)}
disabled={readOnly}
/>
</InspectorField>
<InspectorField label="Icon">
<IconPicker value={iconKey} onChange={handleIconChange} readOnly={readOnly} />
</InspectorField>
</InspectorGroup>
<InspectorGroup title="Hierarchy" defaultExpanded>
<InspectorField label="Reports to">
<select
className="ri-select"
value={reportsTo}
onChange={e => handleReportsToChange(e.target.value)}
disabled={readOnly}
>
<option value="owner">You (Owner)</option>
{otherRoles.map(r => (
<option key={r.role_id} value={r.role_id}>{r.name}</option>
))}
</select>
</InspectorField>
<InspectorField label="Can delegate to">
<MultiSelect
allIds={otherRoles.map(r => r.role_id)}
labelFor={(id) => otherRoles.find(r => r.role_id === id)?.name ?? id}
selected={canSpawn}
onToggle={toggleCanSpawn}
readOnly={readOnly}
/>
</InspectorField>
<InspectorField label="Employees assigned">
<span className="ri-meta-value">{employeeCount}</span>
</InspectorField>
</InspectorGroup>
<InspectorGroup title={`Tools (${tools.size})`}>
<ToolChecklist tools={tools} onToggle={toggleTool} readOnly={readOnly} />
</InspectorGroup>
<InspectorGroup title={`Prompts (${promptRefCount})`}>
<InspectorField label="Prompt refs / inline">
<textarea
className="ri-textarea ri-textarea-mono"
rows={5}
placeholder="One prompt ref or inline instruction per line"
value={promptRefs}
onChange={e => handlePromptRefsChange(e.target.value)}
disabled={readOnly}
/>
</InspectorField>
</InspectorGroup>
<InspectorGroup title="Runtime policy">
<InspectorField label="Execution strategy">
<div className="ri-radio-group">
{EXECUTION_STRATEGIES.map(opt => (
<label key={opt.value} className="ri-radio" title={opt.hint}>
<input
type="radio"
name={`exec-strategy-${role.role_id}`}
value={opt.value}
checked={execStrategy === opt.value}
onChange={() => handleExecStrategyChange(opt.value)}
disabled={readOnly}
/>
{opt.label}
</label>
))}
</div>
</InspectorField>
{execStrategy === 'external' && (
<InspectorField label="Preferred external agent">
<select
className="ri-select"
value={extAgent ?? ''}
onChange={e => handleExtAgentChange(e.target.value || null)}
disabled={readOnly}
>
<option value="">(any)</option>
{EXTERNAL_AGENTS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
</InspectorField>
)}
</InspectorGroup>
<InspectorGroup title="Advanced">
<InspectorField label="Role type">
<span className="ri-meta-value">{role.role_type ?? 'worker'}</span>
</InspectorField>
<InspectorField label="Skills">
<span className="ri-meta-value">
{role.skill_refs && role.skill_refs.length > 0 ? role.skill_refs.join(', ') : '(none)'}
</span>
</InspectorField>
<InspectorField label="Artifact contract">
<span className="ri-meta-value">{role.artifact_contract_ref ?? '(none)'}</span>
</InspectorField>
</InspectorGroup>
</div>
{!readOnly && (
<footer className="ri-panel-footer">
<button
className={`btn btn-sm ${confirmDelete ? 'btn-danger' : 'btn-ghost'}`}
onClick={handleDelete}
onBlur={() => setConfirmDelete(false)}
>
{confirmDelete ? 'Confirm delete?' : 'Delete role'}
</button>
</footer>
)}
</aside>
)
}
/* ── Sub-components ────────────────────────────────────────────── */
function InspectorGroup({ title, defaultExpanded = false, children }: {
title: string
defaultExpanded?: boolean
children: ReactNode
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
return (
<section className={`ri-group${expanded ? ' is-expanded' : ''}`}>
<button className="ri-group-header" onClick={() => setExpanded(e => !e)}>
<span className="ri-group-caret">{expanded ? '▾' : '▸'}</span>
<span className="ri-group-title">{title}</span>
</button>
{expanded && <div className="ri-group-body">{children}</div>}
</section>
)
}
function InspectorField({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="ri-field">
<label className="ri-field-label">{label}</label>
<div className="ri-field-control">{children}</div>
</div>
)
}
function IconPicker({ value, onChange, readOnly }: {
value: string | null
onChange: (v: string | null) => void
readOnly?: boolean
}) {
const [open, setOpen] = useState(false)
return (
<div className="ri-icon-picker">
<button
className="ri-icon-picker-trigger"
onClick={() => !readOnly && setOpen(o => !o)}
disabled={readOnly}
>
<img src={resolveRoleIcon(value)} alt="" className="ri-icon-picker-current" />
<span className="ri-icon-picker-label">{value ?? 'generic'}</span>
</button>
{open && (
<div className="ri-icon-picker-popover">
<button
className={`ri-icon-option${value === null ? ' is-active' : ''}`}
onClick={() => { onChange(null); setOpen(false) }}
title="Default icon"
>
<img src={ROLE_ICONS.generic} alt="" />
</button>
{(ROLE_ICON_KEYS as readonly RoleIconKey[]).filter(k => k !== 'generic').map(key => (
<button
key={key}
className={`ri-icon-option${value === key ? ' is-active' : ''}`}
onClick={() => { onChange(key); setOpen(false) }}
title={key}
>
<img src={ROLE_ICONS[key]} alt={key} />
</button>
))}
</div>
)}
</div>
)
}
function MultiSelect({ allIds, labelFor, selected, onToggle, readOnly }: {
allIds: string[]
labelFor: (id: string) => string
selected: Set<string>
onToggle: (id: string) => void
readOnly?: boolean
}) {
const [open, setOpen] = useState(false)
const available = allIds.filter(id => !selected.has(id))
return (
<div className="ri-multiselect">
<div className="ri-chips">
{Array.from(selected).map(id => (
<span key={id} className="ri-chip">
{labelFor(id)}
{!readOnly && (
<button className="ri-chip-x" onClick={() => onToggle(id)} title="Remove">×</button>
)}
</span>
))}
{!readOnly && available.length > 0 && (
<button className="ri-chip-add" onClick={() => setOpen(o => !o)}>+ Add</button>
)}
{selected.size === 0 && readOnly && <span className="ri-meta-value">(none)</span>}
</div>
{open && !readOnly && (
<div className="ri-multiselect-popover">
{available.map(id => (
<button
key={id}
className="ri-multiselect-option"
onClick={() => { onToggle(id); setOpen(false) }}
>{labelFor(id)}</button>
))}
</div>
)}
</div>
)
}
function ToolChecklist({ tools, onToggle, readOnly }: {
tools: Set<string>
onToggle: (name: string) => void
readOnly?: boolean
}) {
return (
<div className="ri-toolcheck">
{TOOL_GROUPS.map(g => (
<fieldset key={g.prefix} className="ri-toolcheck-group">
<legend className="ri-toolcheck-legend">{g.label}</legend>
<div className="ri-toolcheck-items">
{g.tools.map(t => (
<label key={t} className="ri-toolcheck-item">
<input
type="checkbox"
checked={tools.has(t)}
onChange={() => onToggle(t)}
disabled={readOnly}
/>
<span className="ri-toolcheck-name">{t.replace(g.prefix, '') || t}</span>
</label>
))}
</div>
</fieldset>
))}
</div>
)
}
@@ -0,0 +1,345 @@
/**
* RoleTable — Tanstack-table bulk editor for roles.
*
* Columns:
* - checkbox (multi-select)
* - icon (click to open IconPicker — deferred; currently read-only)
* - name (inline editable via double-click)
* - role_id (monospace, immutable)
* - reports_to (dropdown)
* - tools (count, clickable to open popover)
* - agent (select)
* - employees (count)
* - actions (⋯ menu: Delete)
*
* Row click → selects the row in StructureEditor (opens Inspector).
* Multi-select → bulk-edit bar at top ("Change agent for N roles", etc.)
*/
import { useMemo, useState, type ChangeEvent } from 'react'
import {
useReactTable, getCoreRowModel, getSortedRowModel, flexRender,
type ColumnDef, type SortingState,
} from '@tanstack/react-table'
import type { OrgRole, OrgEmployee } from '../types/visual'
import { resolveRoleIcon } from './roleIcons'
const EXTERNAL_AGENTS = ['codex', 'cursor', 'claude_code', 'opencode'] as const
interface RoleTableProps {
roles: OrgRole[]
employees: OrgEmployee[]
selectedIds: string[]
onSelectRow: (id: string) => void
onUpdateRole: (roleId: string, updates: {
name?: string
reports_to?: string
preferred_external_agent?: string | null
}) => void
onDeleteRole: (roleId: string) => void
readOnly?: boolean
}
interface TableRow {
role_id: string
name: string
icon: string | null
reports_to: string
toolCount: number
agent: string | null
employeeCount: number
}
/* ── RoleTable ───────────────────────────────────────────────── */
export function RoleTable({
roles, employees, selectedIds, onSelectRow,
onUpdateRole, onDeleteRole, readOnly,
}: RoleTableProps) {
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
const [editingCell, setEditingCell] = useState<{ rowId: string; col: 'name' } | null>(null)
const [nameBuffer, setNameBuffer] = useState('')
const data: TableRow[] = useMemo(() => {
const countByRole = new Map<string, number>()
for (const e of employees) {
const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id]
for (const roleId of roleIds) {
if (roleId) countByRole.set(roleId, (countByRole.get(roleId) ?? 0) + 1)
}
}
return roles.map(r => ({
role_id: r.role_id,
name: r.name,
icon: r.icon ?? null,
reports_to: r.reports_to,
toolCount: r.tools?.length ?? 0,
agent: r.preferred_external_agent ?? null,
employeeCount: countByRole.get(r.role_id) ?? 0,
}))
}, [roles, employees])
const reportsToOptions = useMemo(
() => [{ id: 'owner', name: 'Owner' }, ...roles.map(r => ({ id: r.role_id, name: r.name }))],
[roles],
)
const commitName = (rowId: string) => {
const trimmed = nameBuffer.trim()
if (trimmed && trimmed !== roles.find(r => r.role_id === rowId)?.name) {
onUpdateRole(rowId, { name: trimmed })
}
setEditingCell(null)
setNameBuffer('')
}
const columns: ColumnDef<TableRow>[] = useMemo(() => [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
ref={el => { if (el) el.indeterminate = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() }}
onChange={table.getToggleAllRowsSelectedHandler()}
disabled={readOnly}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
disabled={readOnly}
aria-label={`Select ${row.original.role_id}`}
onClick={e => e.stopPropagation()}
/>
),
size: 32,
enableSorting: false,
},
{
id: 'icon',
header: '',
cell: ({ row }) => (
<img src={resolveRoleIcon(row.original.icon)} alt="" className="rt-cell-icon" />
),
size: 32,
enableSorting: false,
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ row }) => {
const r = row.original
const isEditing = editingCell?.rowId === r.role_id && editingCell.col === 'name'
if (isEditing) {
return (
<input
autoFocus
className="rt-inline-input"
value={nameBuffer}
onChange={e => setNameBuffer(e.target.value)}
onBlur={() => commitName(r.role_id)}
onKeyDown={e => {
if (e.key === 'Enter') commitName(r.role_id)
else if (e.key === 'Escape') { setEditingCell(null); setNameBuffer('') }
}}
onClick={e => e.stopPropagation()}
/>
)
}
return (
<span
className="rt-cell-name"
onDoubleClick={e => {
if (readOnly) return
e.stopPropagation()
setNameBuffer(r.name)
setEditingCell({ rowId: r.role_id, col: 'name' })
}}
title="Double-click to edit"
>
{r.name}
</span>
)
},
},
{
id: 'role_id',
header: 'ID',
accessorKey: 'role_id',
cell: ({ row }) => <code className="rt-cell-id">{row.original.role_id}</code>,
},
{
id: 'reports_to',
header: 'Reports to',
accessorKey: 'reports_to',
cell: ({ row }) => {
const r = row.original
return (
<select
className="rt-cell-select"
value={r.reports_to}
onChange={(e: ChangeEvent<HTMLSelectElement>) => onUpdateRole(r.role_id, { reports_to: e.target.value })}
disabled={readOnly}
onClick={e => e.stopPropagation()}
>
{reportsToOptions.filter(o => o.id !== r.role_id).map(o => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
)
},
},
{
id: 'toolCount',
header: 'Tools',
accessorKey: 'toolCount',
cell: ({ row }) => (
<span className="rt-cell-count">{row.original.toolCount}</span>
),
size: 64,
},
{
id: 'agent',
header: 'Agent',
accessorKey: 'agent',
cell: ({ row }) => {
const r = row.original
return (
<select
className="rt-cell-select"
value={r.agent ?? ''}
onChange={(e: ChangeEvent<HTMLSelectElement>) => onUpdateRole(r.role_id, { preferred_external_agent: e.target.value || null })}
disabled={readOnly}
onClick={e => e.stopPropagation()}
>
<option value="">(auto)</option>
{EXTERNAL_AGENTS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
)
},
},
{
id: 'employeeCount',
header: 'People',
accessorKey: 'employeeCount',
cell: ({ row }) => <span className="rt-cell-count">{row.original.employeeCount}</span>,
size: 64,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<button
className="rt-cell-action"
onClick={e => {
e.stopPropagation()
if (readOnly) return
if (confirm(`Delete role "${row.original.name}"?`)) onDeleteRole(row.original.role_id)
}}
disabled={readOnly}
title="Delete"
></button>
),
size: 40,
enableSorting: false,
},
], [editingCell, nameBuffer, readOnly, reportsToOptions, onUpdateRole, onDeleteRole])
const table = useReactTable({
data,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
getRowId: (row) => row.role_id,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const selectedCount = Object.values(rowSelection).filter(Boolean).length
const selectedRoleIds = Object.keys(rowSelection).filter(id => rowSelection[id])
/* ── Bulk actions ────────────────────────────────────────── */
const bulkSetAgent = (agent: string | null) => {
if (readOnly) return
selectedRoleIds.forEach(id => onUpdateRole(id, { preferred_external_agent: agent }))
setRowSelection({})
}
const bulkDelete = () => {
if (readOnly) return
if (!confirm(`Delete ${selectedCount} roles? This cannot be undone.`)) return
selectedRoleIds.forEach(id => onDeleteRole(id))
setRowSelection({})
}
return (
<div className="rt-container">
{selectedCount > 0 && !readOnly && (
<div className="rt-bulk-bar">
<span className="rt-bulk-count">{selectedCount} selected</span>
<select
className="rt-bulk-select"
defaultValue=""
onChange={e => { const v = e.target.value; if (v) bulkSetAgent(v === '__auto__' ? null : v) }}
>
<option value="" disabled>Change agent</option>
<option value="__auto__">(auto)</option>
{EXTERNAL_AGENTS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<button className="btn btn-danger btn-sm" onClick={bulkDelete}>Delete selected</button>
<button className="btn btn-ghost btn-sm" onClick={() => setRowSelection({})}>Clear</button>
</div>
)}
<div className="rt-table-wrap">
<table className="rt-table">
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th
key={h.id}
style={{ width: h.getSize() }}
className={h.column.getCanSort() ? 'rt-th-sortable' : ''}
onClick={h.column.getToggleSortingHandler()}
>
{flexRender(h.column.columnDef.header, h.getContext())}
{h.column.getIsSorted() === 'asc' && <span className="rt-sort-caret"> </span>}
{h.column.getIsSorted() === 'desc' && <span className="rt-sort-caret"> </span>}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.length === 0 && (
<tr><td colSpan={9} className="rt-empty">No roles. Add one via the "+ Add role" button.</td></tr>
)}
{table.getRowModel().rows.map(row => {
const isSelected = selectedIds.includes(row.original.role_id)
return (
<tr
key={row.id}
className={`rt-row${row.getIsSelected() ? ' rt-row-checked' : ''}${isSelected ? ' rt-row-active' : ''}`}
onClick={() => onSelectRow(row.original.role_id)}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id} style={{ width: cell.column.getSize() }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,250 @@
import { useMemo, useCallback, useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react'
import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, applyNodeChanges } from '@xyflow/react'
import type { Node, Edge, NodeChange } from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import type { OrgRole, OrgEmployee } from '../types/visual'
import { StructureCanvasNode, type StructureCanvasNodeData } from './StructureCanvasNode'
import { computeDagreLayout } from './dagreLayout'
const nodeTypes = { roleNode: StructureCanvasNode }
export interface StructureCanvasHandle {
/** Re-run dagre and animate nodes to tidy positions. */
autoLayout: () => void
}
interface StructureCanvasProps {
roles: OrgRole[]
employees: OrgEmployee[]
/**
* role_id -> recruited names for the currently selected session. When
* provided it takes precedence over the global `employees` for the node
* subtitle, so the canvas reflects the selected session's hires. Null/absent
* -> fall back to the global org employees.
*/
sessionRecruitmentByRole?: Record<string, string[]> | null
selectedRoleId: string | null
onSelectRole: (roleId: string | null) => void
onReparent: (roleId: string, newParentId: string) => void
readOnly?: boolean
}
/**
* Constrained canvas for org structure.
* Positions are managed internally. See D1 + D3 in the plan doc:
* - dagre runs on mount, on role add/delete, and on explicit autoLayout() calls.
* - Role-field updates do NOT reflow the graph.
* - Reparenting reflows (a new parent->child edge would leave the graph stale).
*/
export const StructureCanvas = forwardRef<StructureCanvasHandle, StructureCanvasProps>(function StructureCanvas(props, ref) {
return (
<ReactFlowProvider>
<StructureCanvasInner {...props} forwardedRef={ref} />
</ReactFlowProvider>
)
})
function StructureCanvasInner({ roles, employees, sessionRecruitmentByRole, selectedRoleId, onSelectRole, onReparent, readOnly, forwardedRef }: StructureCanvasProps & { forwardedRef: React.ForwardedRef<StructureCanvasHandle> }) {
const employeesByRole = useMemo(() => {
const m = new Map<string, OrgEmployee[]>()
for (const e of employees) {
const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id]
for (const roleId of roleIds) {
if (!roleId) continue
const arr = m.get(roleId) ?? []
arr.push(e)
m.set(roleId, arr)
}
}
return m
}, [employees])
// Names of the actually recruited people per role.
// - When the selected session carries a recruitment map, it is authoritative
// (a role absent from it is unstaffed *for that session*).
// - Otherwise fall back to the global org employees, excluding placeholder/
// default employees (which carry the role name itself, not a real hire).
const recruitedNamesByRole = useCallback(
(roleId: string): string[] => {
if (sessionRecruitmentByRole) return sessionRecruitmentByRole[roleId] ?? []
return (employeesByRole.get(roleId) ?? [])
.filter(e => !e.is_default_employee)
.map(e => e.name)
.filter(Boolean)
},
[employeesByRole, sessionRecruitmentByRole],
)
// Layout invalidation key. Incrementing -> dagre re-runs.
const [layoutVersion, setLayoutVersion] = useState(0)
// Re-layout automatically when the set of role IDs changes (add/delete).
const roleIdsKey = useMemo(() => roles.map(r => r.role_id).sort().join('|'), [roles])
const prevRoleIdsKeyRef = useRef(roleIdsKey)
useEffect(() => {
if (prevRoleIdsKeyRef.current !== roleIdsKey) {
prevRoleIdsKeyRef.current = roleIdsKey
setLayoutVersion(v => v + 1)
}
}, [roleIdsKey])
// Expose imperative autoLayout() to parent so the "Auto-layout" button can trigger it.
useImperativeHandle(forwardedRef, () => ({
autoLayout: () => setLayoutVersion(v => v + 1),
}), [])
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Free-form drag positions: a node the user has dragged sticks where it was
// dropped (purely visual — never changes the org structure). Cleared whenever
// dagre re-runs (autoLayout / role add-delete / reparent) so an explicit
// "Auto-layout" tidies everything back into the hierarchy.
const [manualPositions, setManualPositions] = useState<Record<string, { x: number; y: number }>>({})
useEffect(() => { setManualPositions({}) }, [layoutVersion])
// Compute layout once per layoutVersion bump (NOT on every roles update).
const laidOut = useMemo(() => {
const ownerNode: Node<StructureCanvasNodeData> = {
id: 'owner',
type: 'roleNode',
position: { x: 0, y: 0 },
draggable: false,
data: {
roleId: 'owner', name: 'You (Owner)', responsibility: '',
icon: null, employeeCount: 0, employeeNames: [],
isOwner: true, isSelected: false, isDropTarget: false,
},
}
const roleNodes: Node<StructureCanvasNodeData>[] = roles.map(r => ({
id: r.role_id,
type: 'roleNode',
position: { x: 0, y: 0 },
// Always draggable: in editable mode a drop onto another node reparents;
// otherwise the drag is a free visual reposition (no structural change).
draggable: true,
data: {
roleId: r.role_id, name: r.name, responsibility: r.responsibility,
icon: r.icon ?? null, employeeCount: recruitedNamesByRole(r.role_id).length,
employeeNames: recruitedNamesByRole(r.role_id),
isOwner: false, isSelected: false, isDropTarget: false,
},
}))
const all = [ownerNode, ...roleNodes]
const edges: Edge[] = roles.map(r => ({
id: `e-${r.reports_to}-${r.role_id}`,
source: r.reports_to,
target: r.role_id,
type: 'smoothstep',
}))
const positioned = computeDagreLayout(all, edges)
return { nodes: positioned, edges }
// eslint-disable-next-line react-hooks/exhaustive-deps -- layoutVersion is the explicit reflow trigger
}, [layoutVersion, readOnly])
// Live nodes: start from laid-out positions; overlay current role data
// (name/icon/etc.) without re-running dagre.
const liveNodes = useMemo(() => {
return laidOut.nodes.map(n => {
if (n.id === 'owner') return n
const role = roles.find(r => r.role_id === n.id)
if (!role) return n // node for a deleted role -- invariant: layoutVersion will bump and this clears
const manual = manualPositions[n.id]
return {
...n,
position: manual ?? n.position,
draggable: true,
data: {
...n.data,
name: role.name,
responsibility: role.responsibility,
icon: role.icon ?? null,
employeeCount: recruitedNamesByRole(role.role_id).length,
employeeNames: recruitedNamesByRole(role.role_id),
isSelected: role.role_id === selectedRoleId,
isDropTarget: role.role_id === dropTargetId,
},
}
})
}, [laidOut.nodes, roles, employeesByRole, recruitedNamesByRole, selectedRoleId, dropTargetId, readOnly, manualPositions])
const [stateNodes, setStateNodes] = useState(liveNodes)
useEffect(() => { setStateNodes(liveNodes) }, [liveNodes])
const handleNodesChange = useCallback((changes: NodeChange[]) => {
setStateNodes(curr => applyNodeChanges(changes, curr))
}, [])
const handleNodeDrag = useCallback((_evt: any, node: Node) => {
// Reparent drop-target highlighting only applies in editable mode. In
// read-only mode the drag is a pure visual reposition -- no target.
if (readOnly) return
// Bounding-box hit test for drop target
const W = 220, H = 80
const pt = { x: node.position.x + W / 2, y: node.position.y + H / 2 }
const hit = stateNodes.find(n => {
if (n.id === node.id) return false
return pt.x >= n.position.x && pt.x <= n.position.x + W &&
pt.y >= n.position.y && pt.y <= n.position.y + H
})
setDropTargetId(hit?.id ?? null)
}, [stateNodes, readOnly])
const handleNodeDragStop = useCallback((_evt: any, node: Node) => {
const target = dropTargetId
setDropTargetId(null)
// Remember the dropped position so the node stays put (visual only).
const keepPosition = () =>
setManualPositions(prev => ({ ...prev, [node.id]: { x: node.position.x, y: node.position.y } }))
// No valid reparent (read-only, no/own target, or a cycle) -> free reposition.
if (readOnly || !target || target === node.id || isDescendant(roles, node.id, target)) {
keepPosition()
return
}
onReparent(node.id, target)
// A reparent changes the edge structure -- reflow (also clears manual positions).
setLayoutVersion(v => v + 1)
}, [dropTargetId, roles, readOnly, onReparent])
const handleNodeClick = useCallback((_evt: any, node: Node) => {
onSelectRole(node.id === 'owner' ? null : node.id)
}, [onSelectRole])
// @xyflow/react v12's `.react-flow` CSS class does NOT set height/width.
// Wrap in a sized div so the canvas has a definite box to render into —
// this is the library's documented integration pattern for v12.
return (
<div style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={stateNodes}
edges={laidOut.edges}
nodeTypes={nodeTypes}
onNodesChange={handleNodesChange}
onNodeDrag={handleNodeDrag}
onNodeDragStop={handleNodeDragStop}
onNodeClick={handleNodeClick}
fitView
fitViewOptions={{ padding: 0.32, minZoom: 0.45, maxZoom: 1.2 }}
nodesConnectable={false}
edgesFocusable={false}
style={{ width: '100%', height: '100%' }}
>
<Background gap={28} size={1} color="rgba(240, 237, 232, 0.10)" />
<Controls showInteractive={false} />
<MiniMap pannable nodeStrokeWidth={0} maskColor="rgba(12, 17, 27, 0.6)" />
</ReactFlow>
</div>
)
}
// Note: no ReactFlow `proOptions.hideAttribution` used -- @xyflow/react v12 is MIT
// with the attribution fully removed at the library level, so no workaround needed.
/** Helper: is `candidateDescendantId` a descendant of `rootId`? */
function isDescendant(roles: OrgRole[], rootId: string, candidateDescendantId: string): boolean {
const children = roles.filter(r => r.reports_to === rootId).map(r => r.role_id)
for (const c of children) {
if (c === candidateDescendantId) return true
if (isDescendant(roles, c, candidateDescendantId)) return true
}
return false
}
@@ -0,0 +1,73 @@
import { memo } from 'react'
import { Handle, Position } from '@xyflow/react'
import { resolveRoleIcon } from './roleIcons'
export interface StructureCanvasNodeData {
[key: string]: unknown
roleId: string
name: string
responsibility: string
icon: string | null
employeeCount: number
/** Names of the actual recruited (non-placeholder) people staffed on this role. */
employeeNames: string[]
isOwner: boolean
isSelected: boolean
isDropTarget: boolean
}
/**
* Canvas node — single-accent refined card.
* Outer <div> always carries .oc-canvas-node (plus optional state
* modifiers) — this is the E2E anchor. Icon is rendered via CSS
* `mask-image` so its tint can track the active theme's --accent;
* the card reads consistently under Paper, OpenOPC, etc.
*/
export const StructureCanvasNode = memo(function StructureCanvasNode({ data }: { data: StructureCanvasNodeData }) {
const stateClass = [
'oc-canvas-node',
data.isOwner && 'is-owner',
data.isSelected && 'is-selected',
data.isDropTarget && 'is-drop-target',
].filter(Boolean).join(' ')
const iconSrc = resolveRoleIcon(data.icon)
return (
<div className={stateClass}>
<Handle type="target" position={Position.Top} className="oc-canvas-handle" />
<div className="oc-canvas-node-row">
<div className="oc-canvas-node-chip">
<span
className="oc-canvas-node-chip-icon"
style={{
WebkitMaskImage: `url("${iconSrc}")`,
maskImage: `url("${iconSrc}")`,
}}
aria-hidden
/>
</div>
<div className="oc-canvas-node-text">
<span className="oc-canvas-node-name">{data.name}</span>
<div className="oc-canvas-node-meta">
{data.employeeNames.length > 0 ? (
<span
className="oc-canvas-node-person"
title={data.employeeNames.join(', ')}
>
{data.employeeNames[0]}
{data.employeeNames.length > 1 ? ` +${data.employeeNames.length - 1}` : ''}
</span>
) : (
<span className="oc-canvas-node-id">{data.roleId}</span>
)}
{data.employeeCount > 0 && (
<span className="oc-canvas-node-badge">{data.employeeCount}</span>
)}
</div>
</div>
</div>
<Handle type="source" position={Position.Bottom} className="oc-canvas-handle" />
</div>
)
})
@@ -0,0 +1,223 @@
/**
* StructureEditor — top-level wrapper for Team sub-tab's role editor.
*
* Owns:
* - view mode (canvas | table)
* - selection state (which role is open in Inspector)
* - reparent handler (proxies to onUpdateRole)
* - keyboard shortcuts: Escape (close Inspector), Delete (delete selected
* role in Canvas mode), F (fit view to graph), ⌘D (duplicate selected)
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { OrgRole, OrgEmployee, SavedOrgSummary } from '../types/visual'
import { OrgVersionSwitcher } from './OrgVersionSwitcher'
import { RoleInspector, type RoleUpdatePatch } from './RoleInspector'
import { RoleTable } from './RoleTable'
import { StructureCanvas, type StructureCanvasHandle } from './StructureCanvas'
interface StructureEditorProps {
roles: OrgRole[]
employees: OrgEmployee[]
/** role_id -> recruited names for the selected session (canvas display only). */
sessionRecruitmentByRole?: Record<string, string[]> | null
isCustomMode?: boolean
onAddRole: (
roleId: string,
name: string,
responsibility: string,
reportsTo: string,
icon?: string | null,
) => void
onUpdateRole: (roleId: string, updates: RoleUpdatePatch) => void
onDeleteRole: (roleId: string) => void
// Saved org architectures — render a version-switcher pill in the toolbar
savedOrgsList?: SavedOrgSummary[] | null
activeSavedOrg?: string | null
currentOrgVersion?: number
versionAtLoad?: number | null
onSavedOrgsList?: () => void
onSavedOrgSaveAs?: (name: string, overwrite: boolean) => void
onSavedOrgLoad?: (name: string) => void
onSavedOrgDelete?: (name: string) => void
}
type EditorView = 'canvas' | 'table'
export function StructureEditor({
roles, employees, sessionRecruitmentByRole, isCustomMode,
onAddRole, onUpdateRole, onDeleteRole,
savedOrgsList, activeSavedOrg, currentOrgVersion, versionAtLoad,
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgLoad, onSavedOrgDelete,
}: StructureEditorProps) {
const [view, setView] = useState<EditorView>('canvas')
const [selectedId, setSelectedId] = useState<string | null>(null)
const canvasRef = useRef<StructureCanvasHandle>(null)
const selectedRole = useMemo(
() => roles.find(r => r.role_id === selectedId) ?? null,
[roles, selectedId],
)
/** Drag-to-reparent comes from StructureCanvas and turns into a normal role update. */
const handleReparent = useCallback((roleId: string, newParentId: string) => {
if (!isCustomMode) return
onUpdateRole(roleId, { reports_to: newParentId })
}, [onUpdateRole, isCustomMode])
/** Toolbar "Auto-layout" — triggers dagre reflow via forwardRef on Canvas. */
const handleAutoLayout = useCallback(() => {
canvasRef.current?.autoLayout()
}, [])
/**
* Toolbar "+ Add role" — generates a unique placeholder ID + default label,
* then selects the new role so Inspector opens ready-to-edit.
*/
const handleAddRoleQuick = useCallback(() => {
if (!isCustomMode) return
const existingIds = new Set(roles.map(r => r.role_id))
let id = 'new_role'
let suffix = 1
while (existingIds.has(id)) { suffix += 1; id = `new_role_${suffix}` }
onAddRole(id, 'New Role', '', 'owner', null)
setSelectedId(id)
}, [roles, onAddRole, isCustomMode])
/** Duplicate the currently-selected role (⌘D). */
const handleDuplicateSelected = useCallback(() => {
if (!isCustomMode || !selectedRole) return
const existingIds = new Set(roles.map(r => r.role_id))
let id = `${selectedRole.role_id}_copy`
let suffix = 1
while (existingIds.has(id)) { suffix += 1; id = `${selectedRole.role_id}_copy_${suffix}` }
onAddRole(id, `${selectedRole.name} (copy)`, selectedRole.responsibility, selectedRole.reports_to, selectedRole.icon)
setSelectedId(id)
}, [roles, onAddRole, selectedRole, isCustomMode])
/**
* Keyboard shortcuts (scoped to StructureEditor via a wrapper ref +
* document-level listener that first checks whether the event originated
* from inside the editor). Skips when user is typing in a form field.
*/
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Don't swallow shortcuts while user types in form fields.
const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return
// Only react if the editor is on screen (event path touches our root).
if (rootRef.current && !rootRef.current.contains(t)) return
if (e.key === 'Escape' && selectedId) {
setSelectedId(null)
e.preventDefault()
return
}
if (e.key === 'Delete' && selectedId && isCustomMode) {
onDeleteRole(selectedId)
setSelectedId(null)
e.preventDefault()
return
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'd' && selectedId && isCustomMode) {
handleDuplicateSelected()
e.preventDefault()
return
}
if (e.key.toLowerCase() === 'f' && view === 'canvas') {
handleAutoLayout()
e.preventDefault()
return
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [selectedId, isCustomMode, view, onDeleteRole, handleAutoLayout, handleDuplicateSelected])
return (
<div className="se-container" ref={rootRef}>
<div className="se-toolbar">
<div className="se-view-switcher" role="tablist" aria-label="Editor view">
<button
role="tab"
aria-selected={view === 'canvas'}
className={`se-view-btn${view === 'canvas' ? ' is-active' : ''}`}
onClick={() => setView('canvas')}
>Canvas</button>
<button
role="tab"
aria-selected={view === 'table'}
className={`se-view-btn${view === 'table' ? ' is-active' : ''}`}
onClick={() => setView('table')}
>Table</button>
</div>
<div className={`se-toolbar-actions${isCustomMode ? '' : ' se-toolbar-actions--readonly'}`}>
{isCustomMode ? (
<div className="se-saved-org-control">
<span className="se-toolbar-label">Saved org</span>
<OrgVersionSwitcher
savedOrgs={savedOrgsList ?? null}
activeName={activeSavedOrg ?? null}
isDirty={versionAtLoad != null && (currentOrgVersion ?? 0) !== versionAtLoad}
onRefresh={onSavedOrgsList ?? (() => {})}
onSaveAs={onSavedOrgSaveAs ?? (() => {})}
onLoad={onSavedOrgLoad ?? (() => {})}
onDelete={onSavedOrgDelete ?? (() => {})}
/>
</div>
) : (
<span className="se-readonly-pill">Read-only corporate</span>
)}
<div className="se-toolbar-divider" aria-hidden />
{view === 'canvas' && (
<button className="btn btn-ghost btn-sm" onClick={handleAutoLayout}>
Auto-layout
</button>
)}
{isCustomMode && (
<button className="btn btn-primary btn-sm" onClick={handleAddRoleQuick}>
+ Add role
</button>
)}
</div>
</div>
<div className="se-body">
{view === 'canvas' ? (
<StructureCanvas
ref={canvasRef}
roles={roles}
employees={employees}
sessionRecruitmentByRole={sessionRecruitmentByRole}
selectedRoleId={selectedId}
onSelectRole={setSelectedId}
onReparent={handleReparent}
readOnly={!isCustomMode}
/>
) : (
<RoleTable
roles={roles}
employees={employees}
selectedIds={selectedId ? [selectedId] : []}
onSelectRow={(id) => setSelectedId(id)}
onUpdateRole={onUpdateRole}
onDeleteRole={onDeleteRole}
readOnly={!isCustomMode}
/>
)}
{selectedRole && (
<RoleInspector
role={selectedRole}
allRoles={roles}
employees={employees}
readOnly={!isCustomMode}
onUpdateRole={onUpdateRole}
onDeleteRole={(id) => { onDeleteRole(id); setSelectedId(null) }}
onClose={() => setSelectedId(null)}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,89 @@
import type { TalentTemplate } from '../types/visual'
interface TalentCardProps {
template: TalentTemplate
hiringId?: string | null
onHire: (templateId: string) => void
onClick: (template: TalentTemplate) => void
}
/** Derive a 2-letter monogram from a name. "Creative Director" → "CD". */
function monogram(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return '?'
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[words.length - 1][0]).toUpperCase()
}
export function TalentCard({ template: t, hiringId, onHire, onClick }: TalentCardProps) {
const isHiring = hiringId === t.template_id
const avatarStyle = t.color
? {
background: `color-mix(in srgb, ${t.color} 18%, transparent)`,
color: t.color,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${t.color} 28%, transparent)`,
}
: undefined
const chipPool: string[] = []
const seen = new Set<string>()
for (const c of [...t.tags, ...t.domains]) {
if (!seen.has(c)) { seen.add(c); chipPool.push(c) }
}
const visibleChips = chipPool.slice(0, 3)
const overflow = chipPool.length - visibleChips.length
return (
<div className="tm-card" onClick={() => onClick(t)}>
<div className="tm-card-head">
<div className="tm-card-avatar" style={avatarStyle} aria-hidden>
{t.emoji ? (
<span className="tm-card-avatar-emoji">{t.emoji}</span>
) : (
<span className="tm-card-avatar-mono">{monogram(t.name)}</span>
)}
</div>
<div className="tm-card-head-text">
<div className="tm-card-name-row">
<span className="tm-card-name" title={t.name}>{t.name}</span>
{t.preferred_external_agent && (
<span
className="tm-card-agent-badge"
title={`Agent: ${t.preferred_external_agent}`}
>
{t.preferred_external_agent}
</span>
)}
</div>
{t.category && <span className="tm-card-category">{t.category}</span>}
</div>
</div>
<div className="tm-card-body">
{t.vibe && <div className="tm-card-vibe">"{t.vibe}"</div>}
{t.description && <div className="tm-card-desc">{t.description}</div>}
</div>
{visibleChips.length > 0 && (
<div className="tm-card-chips">
{visibleChips.map(c => (
<span key={c} className="tm-card-chip">{c}</span>
))}
{overflow > 0 && (
<span className="tm-card-chip tm-card-chip-more">+{overflow}</span>
)}
</div>
)}
<div className="tm-card-footer">
<button
className="tm-card-hire-btn"
disabled={isHiring}
onClick={(e) => { e.stopPropagation(); onHire(t.template_id) }}
>
{isHiring ? <><span className="spinner-inline" /> Hiring</> : 'Hire →'}
</button>
</div>
</div>
)
}
@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import type { OrgRole, TalentTemplate, HireTalentHandler } from '../types/visual'
import { asRoleId, asTemplateId } from '../types/visual'
interface TalentDetailModalProps {
template: TalentTemplate
vacantRoles: OrgRole[]
hiringId?: string | null
readOnly?: boolean
onHire: HireTalentHandler
onClose: () => void
}
export function TalentDetailModal({
template: t, vacantRoles, hiringId, readOnly, onHire, onClose,
}: TalentDetailModalProps) {
const [selectedRoleId, setSelectedRoleId] = useState<string>('')
const isHiring = hiringId === t.template_id
const noVacancies = vacantRoles.length === 0
const canHire = !readOnly && !isHiring && !noVacancies && selectedRoleId !== ''
useEffect(() => {
setSelectedRoleId('')
}, [t.template_id])
const handleHire = () => {
if (!canHire) return
onHire(asTemplateId(t.template_id), asRoleId(selectedRoleId))
}
return (
<div className="tm-detail-overlay" onClick={onClose}>
<div className="tm-detail-modal" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="tm-detail-header" style={{ borderBottomColor: t.color || 'var(--border)' }}>
{t.emoji && <span className="tm-detail-emoji">{t.emoji}</span>}
<div>
<h2 className="tm-detail-name">{t.name}</h2>
<span className="tm-detail-category">{t.category}</span>
</div>
<button className="tm-detail-close" onClick={onClose}>&times;</button>
</div>
{/* Body */}
<div className="tm-detail-body">
{t.vibe && (
<blockquote className="tm-detail-vibe">"{t.vibe}"</blockquote>
)}
{t.description && (
<p className="tm-detail-desc">{t.description}</p>
)}
{t.domains.length > 0 && (
<div className="tm-detail-section">
<div className="tm-detail-label">Domains</div>
<div className="tm-detail-tags">
{t.domains.map(d => <span key={d} className="org-domain-tag">{d}</span>)}
</div>
</div>
)}
{t.tags.length > 0 && (
<div className="tm-detail-section">
<div className="tm-detail-label">Tags</div>
<div className="tm-detail-tags">
{t.tags.map(tag => <span key={tag} className="org-tool-tag">{tag}</span>)}
</div>
</div>
)}
{t.preferred_external_agent && (
<div className="tm-detail-section">
<div className="tm-detail-label">Recommended Agent</div>
<span className="tm-card-agent-badge">{t.preferred_external_agent}</span>
</div>
)}
{!readOnly && (
<div className="tm-detail-section">
<div className="tm-detail-label">Hire into role</div>
{noVacancies ? (
<p className="tm-detail-vacancy-empty">
No vacant roles. Create a role in the Team tab first.
</p>
) : (
<div className="tm-detail-role-list" role="listbox" aria-label="Vacant roles">
{vacantRoles.map(role => {
const selected = role.role_id === selectedRoleId
return (
<button
key={role.role_id}
type="button"
role="option"
aria-selected={selected}
className={`tm-detail-role-row${selected ? ' is-selected' : ''}`}
onClick={() => setSelectedRoleId(role.role_id)}
>
<span className="tm-detail-role-name">{role.name}</span>
{role.responsibility && (
<span className="tm-detail-role-resp">{role.responsibility}</span>
)}
</button>
)
})}
</div>
)}
</div>
)}
</div>
{/* Hire footer */}
{!readOnly && (
<div className="tm-detail-footer">
<button
className="tm-detail-hire-btn"
disabled={!canHire}
onClick={handleHire}
>
{isHiring ? <><span className="spinner-inline" /> Hiring...</> : 'Hire to selected role'}
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,402 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { OrgRole, OrgEmployee, SavedOrgSummary } from '../types/visual'
import { StructureEditor } from './StructureEditor'
import { resolveRoleIcon } from './roleIcons'
/* ── Inline SVG icon data-URIs (no external CDN — see P4.5 Phase 5) ─── */
const ICON = {
rocket: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M13.13 22.19L11.5 18.36c3.07-1.39 5.51-3.94 6.69-7.07L22 13l-8.87 9.19zM5.64 12.5L2 10.87l9.19-8.87 1.63 3.81c-3.13 1.18-5.68 3.62-7.07 6.69zM14.54 9.46c-.78-.78-.78-2.05 0-2.83s2.05-.78 2.83 0 .78 2.05 0 2.83c-.79.78-2.05.78-2.83 0zM8 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z'/%3E%3C/svg%3E",
people: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z'/%3E%3C/svg%3E",
check: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E",
addPerson: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
trash: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z'/%3E%3C/svg%3E",
team: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
deploy: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z'/%3E%3C/svg%3E",
person: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
}
/* ── Quick Start Wizard ────────────────────────────────────────── */
interface QuickStartProps {
onComplete: (roles: Array<{ name: string; responsibility: string; reportsTo: string }>) => void
onSwitchToTab: (target: 'employees' | 'architecture') => void
}
function QuickStartWizard({ onComplete, onSwitchToTab }: QuickStartProps) {
const [step, setStep] = useState(1)
const [members, setMembers] = useState<Array<{ name: string; resp: string; parent: string }>>([
{ name: '', resp: '', parent: 'owner' },
])
const addMember = () => setMembers([...members, { name: '', resp: '', parent: 'owner' }])
const updateMember = (i: number, field: string, val: string) => {
const next = [...members]; (next[i] as any)[field] = val; setMembers(next)
}
const removeMember = (i: number) => {
if (members.length <= 1) return
setMembers(members.filter((_, idx) => idx !== i))
}
const validMembers = members.filter(m => m.name.trim())
const memberNames = validMembers.map(m => m.name.trim()).filter(Boolean)
const handleFinish = () => {
onComplete(
validMembers.map(m => ({ name: m.name.trim(), responsibility: m.resp.trim(), reportsTo: m.parent })),
)
}
return (
<div className="qs-wizard">
<div className="qs-header">
<img src={ICON.rocket} alt="" className="qs-header-icon" />
<div>
<h3 className="qs-header-title">Build Your Team</h3>
<p className="qs-header-sub">Create an org team in a few steps, or <button className="qs-link-btn" onClick={() => onSwitchToTab('architecture')}>use a template</button></p>
</div>
</div>
<div className="qs-progress">
{[
{ n: 1, icon: ICON.people, label: 'Team Members' },
{ n: 2, icon: ICON.check, label: 'Preview' },
].map(s => (
<div key={s.n} className={`qs-step${step === s.n ? ' qs-step-active' : step > s.n ? ' qs-step-done' : ''}`}>
<img src={s.icon} alt="" className="qs-step-icon" />
<span>{s.label}</span>
</div>
))}
</div>
{step === 1 && (
<div className="qs-panel">
<h4>Who's on your team?</h4>
<p className="qs-hint">Add each team member with their name and what they do.</p>
<div className="qs-members">
{members.map((m, i) => (
<div key={i} className="qs-member-row">
<input className="qs-member-name" value={m.name} placeholder="Role name (e.g. Engineer)"
onChange={e => updateMember(i, 'name', e.target.value)} />
<input className="qs-member-resp" value={m.resp} placeholder="What do they do?"
onChange={e => updateMember(i, 'resp', e.target.value)} />
<select className="qs-member-parent" value={m.parent}
onChange={e => updateMember(i, 'parent', e.target.value)}>
<option value="owner">Reports to you</option>
{memberNames.filter(n => n !== m.name.trim()).map(n => (
<option key={n} value={n}>{`Managed by ${n}`}</option>
))}
</select>
{members.length > 1 && (
<button className="qs-remove-btn" onClick={() => removeMember(i)} title="Remove">
<img src={ICON.trash} alt="" className="qs-remove-icon" />
</button>
)}
</div>
))}
</div>
<button className="qs-add-member" onClick={addMember}>
<img src={ICON.addPerson} alt="" className="qs-add-icon" /> Add another member
</button>
<div className="qs-nav">
<span />
<button className="oc-btn-primary" onClick={() => setStep(2)} disabled={validMembers.length === 0}>
Next: Preview →
</button>
</div>
</div>
)}
{step === 2 && (
<div className="qs-panel">
<h4>Your team at a glance</h4>
<div className="qs-preview">
<div className="qs-preview-section">
<h5>Team ({validMembers.length} members)</h5>
{validMembers.map((m, i) => (
<div key={i} className="qs-preview-member">
<strong>{m.name}</strong>
{m.resp && <span className="qs-preview-resp"> — {m.resp}</span>}
<span className="qs-preview-parent">
{m.parent === 'owner' ? ' (reports to you)' : ` (managed by ${m.parent})`}
</span>
</div>
))}
</div>
<div className="qs-preview-section">
<h5>Actor Runtime</h5>
<p>Seat routing and delegation will be derived from your reporting structure and any team seats you configure later.</p>
</div>
</div>
<div className="qs-nav">
<button className="oc-btn-ghost" onClick={() => setStep(1)}>← Back</button>
<button className="oc-btn-primary qs-create-btn" onClick={handleFinish}>Create Team</button>
</div>
</div>
)}
</div>
)
}
/* ── TeamView ──────────────────────────────────────────────────── */
interface TeamViewProps {
roles: OrgRole[]
employees: OrgEmployee[]
/** role_id -> recruited names for the selected session (canvas display only). */
sessionRecruitmentByRole?: Record<string, string[]> | null
isCustomMode?: boolean
onAddRole: (roleId: string, name: string, responsibility: string, reportsTo: string, icon?: string | null) => void
onBulkAddRoles?: (roles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string }>) => void
onUpdateRole: (roleId: string, updates: { name?: string; responsibility?: string; reports_to?: string; can_spawn?: string[]; icon?: string | null; execution_strategy?: string; preferred_external_agent?: string | null; prompt_refs?: string[] }) => void
onDeleteRole: (roleId: string) => void
onExport: (data: { package_id: string; name: string; description: string; version: string }) => void
onImportEmployee?: (employeeId: string) => void
onResetArchitecture?: () => void
onSwitchToTab: (target: 'employees' | 'architecture') => void
// Saved org architectures — passed to StructureEditor for toolbar pill
savedOrgsList?: SavedOrgSummary[] | null
activeSavedOrg?: string | null
currentOrgVersion?: number
versionAtLoad?: number | null
onSavedOrgsList?: () => void
onSavedOrgSaveAs?: (name: string, overwrite: boolean) => void
onSavedOrgLoad?: (name: string) => void
onSavedOrgDelete?: (name: string) => void
}
export function TeamView({
roles, employees, sessionRecruitmentByRole, isCustomMode,
onAddRole, onBulkAddRoles, onUpdateRole, onDeleteRole, onExport,
onImportEmployee,
onResetArchitecture, onSwitchToTab,
savedOrgsList, activeSavedOrg, currentOrgVersion, versionAtLoad,
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgLoad, onSavedOrgDelete,
}: TeamViewProps) {
const [quickStartPending, setQuickStartPending] = useState(false)
const [showExportForm, setShowExportForm] = useState(false)
const [exportId, setExportId] = useState('')
const [exportName, setExportName] = useState('')
const [exportDesc, setExportDesc] = useState('')
const [exportVersion, setExportVersion] = useState('1.0.0')
const empByRole = useMemo(() => {
const m = new Map<string, OrgEmployee[]>()
for (const e of employees) {
const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id]
for (const roleId of roleIds) {
if (!roleId) continue
const list = m.get(roleId) || []; list.push(e); m.set(roleId, list)
}
}
return m
}, [employees])
const handleExport = () => {
if (!exportId.trim() || !exportName.trim()) return
onExport({ package_id: exportId.trim(), name: exportName.trim(), description: exportDesc, version: exportVersion })
setShowExportForm(false); setExportId(''); setExportName(''); setExportDesc(''); setExportVersion('1.0.0')
}
// When roles arrive after bulk add, clear the quick-start loading state.
const quickStartTimer = useRef<ReturnType<typeof setTimeout>>(null)
useEffect(() => {
if (quickStartPending && roles.length > 0) {
setQuickStartPending(false)
if (quickStartTimer.current) { clearTimeout(quickStartTimer.current); quickStartTimer.current = null }
}
}, [roles.length, quickStartPending])
// Timeout: if roles never arrive within 10s, reset to wizard
useEffect(() => {
if (quickStartPending) {
quickStartTimer.current = setTimeout(() => setQuickStartPending(false), 10000)
return () => { if (quickStartTimer.current) clearTimeout(quickStartTimer.current) }
}
}, [quickStartPending])
const handleQuickStart = (
newRoles: Array<{ name: string; responsibility: string; reportsTo: string }>,
) => {
const nameToId = new Map<string, string>()
const usedIds = new Set<string>()
const bulkRoles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string }> = []
for (const r of newRoles) {
let id = r.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/^-+|-+$/g, '')
if (!id) continue // skip roles with empty ID (e.g. name was "!!!")
// Deduplicate: append suffix if ID already used
let finalId = id
let suffix = 2
while (usedIds.has(finalId)) { finalId = `${id}-${suffix++}` }
usedIds.add(finalId)
nameToId.set(r.name, finalId)
const parentId = r.reportsTo === 'owner' ? 'owner' : (nameToId.get(r.reportsTo) || 'owner')
bulkRoles.push({ role_id: finalId, name: r.name, responsibility: r.responsibility, reports_to: parentId })
}
if (bulkRoles.length === 0) return // all roles had invalid names
if (onBulkAddRoles) {
setQuickStartPending(true)
onBulkAddRoles(bulkRoles)
} else {
for (const r of bulkRoles) onAddRole(r.role_id, r.name, r.responsibility, r.reports_to)
setQuickStartPending(true)
}
}
const handleReset = () => {
if (!confirm('This will remove all org roles, employees, and work-item templates. Continue?')) return
onResetArchitecture?.()
}
const assignedRoleCount = useMemo(() => {
let count = 0
for (const role of roles) {
if ((empByRole.get(role.role_id) ?? []).length > 0) count += 1
}
return count
}, [roles, empByRole])
const linkedEmployeeCount = useMemo(
() => employees.filter(e => e.linked_agent_id).length,
[employees],
)
const vacantRoleCount = Math.max(0, roles.length - assignedRoleCount)
// Show wizard only in org mode when no roles exist
if (isCustomMode && roles.length === 0) {
return (
<div className="team-view">
{quickStartPending ? (
<div className="qs-wizard">
<div className="qs-loading">
<span className="spinner-inline" /> Setting up your team...
</div>
</div>
) : (
<QuickStartWizard onComplete={handleQuickStart} onSwitchToTab={onSwitchToTab} />
)}
</div>
)
}
return (
<div className="team-view">
<div className={`team-command-bar${isCustomMode ? ' team-command-bar--editable' : ' team-command-bar--readonly'}`}>
<div className="team-command-copy">
<span className="team-command-eyebrow">{isCustomMode ? 'Saved org workspace' : 'Corporate baseline'}</span>
<span className="team-command-title">{isCustomMode ? 'Editable company architecture' : 'Built-in company architecture'}</span>
</div>
<div className="team-command-metrics">
<span><b>{assignedRoleCount}</b> staffed roles</span>
<span><b>{vacantRoleCount}</b> vacant</span>
<span><b>{linkedEmployeeCount}</b> in office</span>
</div>
{isCustomMode && (
<div className="team-command-actions">
<button className="myorg-inline-btn" onClick={() => onSwitchToTab('employees')}>
<img src={ICON.addPerson} alt="" className="myorg-inline-icon" /> Hire Talent
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowExportForm(true)}>
Export Package
</button>
{onResetArchitecture && (
<button className="myorg-reset-btn" onClick={handleReset}>
<img src={ICON.trash} alt="" className="myorg-reset-icon" /> Reset
</button>
)}
</div>
)}
</div>
{!isCustomMode && (
<div className="team-readonly-strip">
Corporate is fixed and read-only; saved company architectures are edited separately.
</div>
)}
{/* Structure Editor (Canvas + Table + Inspector) */}
<StructureEditor
roles={roles}
employees={employees}
sessionRecruitmentByRole={sessionRecruitmentByRole}
isCustomMode={isCustomMode}
onAddRole={onAddRole}
onUpdateRole={onUpdateRole}
onDeleteRole={onDeleteRole}
savedOrgsList={savedOrgsList ?? null}
activeSavedOrg={activeSavedOrg ?? null}
currentOrgVersion={currentOrgVersion ?? 0}
versionAtLoad={versionAtLoad ?? null}
onSavedOrgsList={onSavedOrgsList}
onSavedOrgSaveAs={onSavedOrgSaveAs}
onSavedOrgLoad={onSavedOrgLoad}
onSavedOrgDelete={onSavedOrgDelete}
/>
{/* Export form */}
{showExportForm && (
<div className="myorg-form">
<h4 className="myorg-form-title">Export as .opcpkg</h4>
<div className="oc-form-row"><label>Package ID</label>
<input value={exportId} onChange={e => setExportId(e.target.value)} placeholder="my-architecture" /></div>
<div className="oc-form-row"><label>Name</label>
<input value={exportName} onChange={e => setExportName(e.target.value)} placeholder="My Architecture" /></div>
<div className="oc-form-row"><label>Description</label>
<input value={exportDesc} onChange={e => setExportDesc(e.target.value)} placeholder="An org team structure" /></div>
<div className="oc-form-row"><label>Version</label>
<input value={exportVersion} onChange={e => setExportVersion(e.target.value)} /></div>
<div className="oc-form-actions">
<button className="oc-btn-primary" onClick={handleExport} disabled={!exportId.trim() || !exportName.trim()}>Export</button>
<button className="oc-btn-ghost" onClick={() => setShowExportForm(false)}>Cancel</button>
</div>
</div>
)}
{/* Team Roster — enhanced with actions */}
<div className="myorg-section">
<div className="myorg-section-header">
<img src={ICON.team} alt="" className="myorg-section-icon" />
<h3 className="myorg-section-title">Team Roster</h3>
<span className="myorg-section-count">{employees.length} members</span>
<span className="myorg-section-spacer" />
<span className="myorg-section-note">{assignedRoleCount}/{roles.length} staffed</span>
</div>
<div className="team-roster-grid">
{roles.map(r => {
const emps = empByRole.get(r.role_id) || []
return (
<div key={r.role_id} className="team-roster-card">
<div className="team-roster-card-header">
<img src={resolveRoleIcon(r.icon)} alt="" className="team-roster-card-icon" />
<span className="team-roster-role-name">{r.name}</span>
<span className="team-roster-count">{emps.length || 'vacant'}</span>
</div>
{emps.length > 0 ? emps.map(e => (
<div key={e.employee_id} className="team-roster-emp">
<img src={ICON.person} alt="" className="team-roster-emp-avatar" />
<div className="team-roster-emp-info">
<span className="team-roster-emp-name">{e.name}</span>
<span className={`team-roster-seniority team-roster-seniority--${e.seniority}`}>{e.seniority}</span>
</div>
{e.linked_agent_id ? (
<span className="team-roster-badge team-roster-badge--active">In Office</span>
) : onImportEmployee && e.role_id ? (
<button className="team-roster-deploy-btn" onClick={() => onImportEmployee(e.employee_id)}
title="Add this employee to the office workspace">
<img src={ICON.deploy} alt="" className="team-roster-deploy-icon" /> Deploy
</button>
) : null}
</div>
)) : (
<div className="team-roster-vacant">
<span className="team-roster-vacant-text">No members yet</span>
{isCustomMode && <button className="team-roster-hire-btn" onClick={() => onSwitchToTab('employees')}>Hire</button>}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,226 @@
/* Config import/export panel styles. */
/* ── Config Import/Export Panel ──────────────────────────────────── */
.cfg-io-panel {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-top: 16px;
display: flex;
flex-direction: column;
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--text) 5%, transparent),
0 1px 2px rgba(0, 0, 0, 0.3),
0 8px 24px rgba(0, 0, 0, 0.12);
}
.cfg-io-header {
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.cfg-io-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.cfg-io-subtitle {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 0;
line-height: 1.5;
}
.cfg-io-section {
padding: 14px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.cfg-io-section + .cfg-io-section {
border-top: 1px solid var(--border);
}
.cfg-io-section-header {
display: flex;
align-items: center;
gap: 8px;
}
.cfg-io-section-icon {
width: 16px;
height: 16px;
opacity: 0.6;
}
.cfg-io-section-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.4px;
}
/* Upload row: hidden file input + styled label */
.cfg-io-upload-row {
display: flex;
align-items: center;
}
.cfg-io-file-label {
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.cfg-io-file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
border: 0;
}
.cfg-io-file-btn {
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-xs);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text);
transition: border-color 0.15s, color 0.15s;
}
.cfg-io-file-label:hover .cfg-io-file-btn {
border-color: var(--accent);
color: var(--accent);
}
.cfg-io-file-input:focus-visible + .cfg-io-file-btn {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.cfg-io-file-name {
font-size: 12px;
color: var(--text-secondary);
font-style: italic;
}
/* Textarea — monospace for YAML */
.cfg-io-textarea {
width: 100%;
padding: 10px 12px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
line-height: 1.5;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text);
box-sizing: border-box;
resize: vertical;
min-height: 140px;
}
.cfg-io-textarea:focus {
border-color: var(--accent);
outline: none;
}
.cfg-io-actions {
display: flex;
gap: 8px;
margin-top: 2px;
flex-wrap: wrap;
}
/* Preview box (success / ready to apply) */
.cfg-io-preview {
display: flex;
gap: 12px;
padding: 12px 14px;
background: color-mix(in srgb, var(--green) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--green) 28%, transparent);
border-radius: var(--radius-xs);
margin-top: 4px;
}
.cfg-io-preview-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
margin-top: 1px;
}
.cfg-io-preview-body {
flex: 1;
min-width: 0;
}
.cfg-io-preview-title {
font-size: 12px;
font-weight: 600;
color: var(--green);
margin-bottom: 8px;
}
.cfg-io-preview-stats {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.cfg-io-preview-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.cfg-io-stat-label {
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.4px;
font-weight: 500;
}
.cfg-io-stat-value {
font-size: 16px;
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
}
/* Error box */
.cfg-io-error {
display: flex;
gap: 12px;
padding: 12px 14px;
background: color-mix(in srgb, var(--red) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--red) 28%, transparent);
border-radius: var(--radius-xs);
margin-top: 4px;
}
.cfg-io-error-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
margin-top: 1px;
}
.cfg-io-error-body {
flex: 1;
min-width: 0;
}
.cfg-io-error-title {
font-size: 12px;
font-weight: 600;
color: var(--red);
margin-bottom: 6px;
}
.cfg-io-error-text {
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--text);
background: var(--bg-secondary);
padding: 8px 10px;
border-radius: 4px;
margin: 0;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
}

Some files were not shown because too many files have changed in this diff Show More