Initial commit
This commit is contained in:
@@ -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">→</span>
|
||||
<span className="ckpt-change-id">{rc.replacement_role_id}</span>
|
||||
</>
|
||||
)}
|
||||
{rc.reason && <span className="ckpt-change-reason">{rc.reason}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectionChanges.length > 0 && (
|
||||
<div className="ckpt-changes-section">
|
||||
<div className="ckpt-changes-title">Work Item Projection Changes</div>
|
||||
{projectionChanges.map((change, i) => (
|
||||
<div key={i} className="ckpt-change-row">
|
||||
<span className={`ckpt-change-action ckpt-action-${change.action}`}>{change.action}</span>
|
||||
<span className="ckpt-change-id">{change.work_item_projection_id}</span>
|
||||
{change.replacement_work_item_projection_id && (
|
||||
<>
|
||||
<span className="ckpt-change-arrow">→</span>
|
||||
<span className="ckpt-change-id">{change.replacement_work_item_projection_id}</span>
|
||||
</>
|
||||
)}
|
||||
{change.reason && <span className="ckpt-change-reason">{change.reason}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(impact).length > 0 && (
|
||||
<div className="ckpt-impact">
|
||||
{impact.affected_tasks != null && <span>Tasks affected: {impact.affected_tasks}</span>}
|
||||
{impact.affected_roles != null && <span>Roles affected: {impact.affected_roles}</span>}
|
||||
{impact.migration_count != null && <span>Migrations: {impact.migration_count}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isResponded && (
|
||||
<div className="ckpt-actions">
|
||||
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove}>Approve Reorg</button>
|
||||
<button className="ckpt-btn ckpt-btn-deny" onClick={handleDeny}>Deny</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,443 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import type { Session } from '../types/kanban'
|
||||
import { IconPlus, IconSearch, IconActivity, IconShield, IconTrash, IconWorkItem } from './SvgIcons'
|
||||
import { getSessionRuntimeStatus } from '../lib/sessionRuntime'
|
||||
import { deriveCompanyRuntimeDisplayStatus } from '../lib/workItemSessions'
|
||||
|
||||
interface SessionSidebarProps {
|
||||
sessions: Session[]
|
||||
activeSessionId: string | null
|
||||
activeChannel?: string | null
|
||||
secretaryChannelId?: string
|
||||
unreadCounts?: Record<string, number>
|
||||
onSelect: (taskId: string | null) => void
|
||||
onCreateSession: () => void
|
||||
onDeleteSession: (taskId: string) => void
|
||||
onSelectSecretary?: () => void
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
idle: 'status-idle',
|
||||
done: 'status-done',
|
||||
pending: 'status-pending',
|
||||
failed: 'status-failed',
|
||||
cancelled: 'status-cancelled',
|
||||
blocked: 'status-blocked',
|
||||
}
|
||||
|
||||
function sessionDotClass(session: Session): string {
|
||||
const displayStatus = deriveCompanyRuntimeDisplayStatus(session) ?? session.status
|
||||
const runtimeStatus = getSessionRuntimeStatus({ ...session, status: displayStatus })
|
||||
if (runtimeStatus === 'tool_active') return 'status-tool-active'
|
||||
if (runtimeStatus === 'reflecting') return 'status-reflecting'
|
||||
if (displayStatus === 'running') return 'status-running-idle'
|
||||
return STATUS_DOT[displayStatus] ?? 'status-pending'
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 60_000) return 'just now'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
|
||||
return `${Math.floor(diff / 86_400_000)}d`
|
||||
}
|
||||
|
||||
function dateGroup(ts: number): string {
|
||||
const now = new Date()
|
||||
const d = new Date(ts)
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const yesterday = new Date(today.getTime() - 86_400_000)
|
||||
if (d >= today) return 'Today'
|
||||
if (d >= yesterday) return 'Yesterday'
|
||||
return 'Earlier'
|
||||
}
|
||||
|
||||
interface SessionTree {
|
||||
session: Session
|
||||
children: Session[]
|
||||
}
|
||||
|
||||
type SidebarRow =
|
||||
| { kind: 'group'; group: 'Today' | 'Yesterday' | 'Earlier' }
|
||||
| { kind: 'primary'; node: SessionTree }
|
||||
| { kind: 'child'; parentTaskId: string; child: Session; childIndex: number; childCount: number }
|
||||
| { kind: 'child-count'; node: SessionTree }
|
||||
|
||||
function buildSessionTree(sessions: Session[]): SessionTree[] {
|
||||
const childMap = new Map<string, Session[]>()
|
||||
const primarySessions: Session[] = []
|
||||
|
||||
for (const s of sessions) {
|
||||
if (s.mode === 'child' && s.parentSessionId) {
|
||||
const siblings = childMap.get(s.parentSessionId) ?? []
|
||||
siblings.push(s)
|
||||
childMap.set(s.parentSessionId, siblings)
|
||||
} else {
|
||||
primarySessions.push(s)
|
||||
}
|
||||
}
|
||||
|
||||
return primarySessions.map(p => ({
|
||||
session: p,
|
||||
children: Array.from(new Map(
|
||||
[...(p.sessionId ? (childMap.get(p.sessionId) ?? []) : []), ...(childMap.get(p.taskId) ?? [])]
|
||||
.map(child => [child.taskId, child]),
|
||||
).values()),
|
||||
}))
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
isActive,
|
||||
isChild,
|
||||
isLast,
|
||||
unreadCount,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
session: Session
|
||||
isActive: boolean
|
||||
isChild?: boolean
|
||||
isLast?: boolean
|
||||
unreadCount?: number
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const agentLabel = session.assigneeIds.length > 0
|
||||
? session.assigneeIds[0].replace(/^agent-/, '')
|
||||
: null
|
||||
const displayStatus = deriveCompanyRuntimeDisplayStatus(session) ?? session.status
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirming) return
|
||||
const timer = setTimeout(() => setConfirming(false), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [confirming])
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`session-item${isActive ? ' active' : ''}${isChild ? ' session-item-child' : ''}`}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => setShowDelete(true)}
|
||||
onMouseLeave={() => setShowDelete(false)}
|
||||
>
|
||||
{isChild && (
|
||||
<span className={`session-tree-line${isLast ? ' last' : ''}`} />
|
||||
)}
|
||||
<span className={`session-dot ${sessionDotClass(session)}`} />
|
||||
<div className="session-item-content">
|
||||
<span className="session-item-title">
|
||||
{isChild && agentLabel && (
|
||||
<span className="session-agent-tag">{agentLabel}</span>
|
||||
)}
|
||||
{!isChild && session.isCompanyRuntime && (
|
||||
<span className="session-runtime-badge" title="Company runtime"><IconWorkItem /></span>
|
||||
)}
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="session-item-meta">
|
||||
{displayStatus} · {relativeTime(session.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{!!unreadCount && unreadCount > 0 && (
|
||||
<span className="session-unread-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
{showDelete && !isChild && !confirming && (
|
||||
<button
|
||||
className="session-delete-btn"
|
||||
onClick={e => { e.stopPropagation(); setConfirming(true) }}
|
||||
title="Delete"
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
)}
|
||||
{confirming && (
|
||||
<span className="session-confirm-delete" onClick={e => e.stopPropagation()}>
|
||||
<button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>Delete</button>
|
||||
<button className="session-confirm-no" onClick={() => setConfirming(false)}>Cancel</button>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionSidebar({ sessions, activeSessionId, activeChannel, secretaryChannelId, unreadCounts, onSelect, onCreateSession, onDeleteSession, onSelectSecretary }: SessionSidebarProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||
const hasRuntimeSessions = sessions.some(session =>
|
||||
session.isCompanyRuntime
|
||||
|| session.mode === 'child'
|
||||
|| session.execMode === 'company'
|
||||
|| session.execMode === 'org'
|
||||
|| session.execMode === 'custom'
|
||||
)
|
||||
|
||||
const toggleCollapse = useCallback((taskId: string) => {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(taskId)) next.delete(taskId)
|
||||
else next.add(taskId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return sessions
|
||||
const q = search.toLowerCase()
|
||||
return sessions.filter(s => s.title.toLowerCase().includes(q))
|
||||
}, [sessions, search])
|
||||
|
||||
const tree = useMemo(() => buildSessionTree(filtered), [filtered])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, SessionTree[]> = { Today: [], Yesterday: [], Earlier: [] }
|
||||
for (const node of tree) {
|
||||
const g = dateGroup(node.session.createdAt)
|
||||
groups[g]?.push(node)
|
||||
}
|
||||
return groups
|
||||
}, [tree])
|
||||
|
||||
const rows = useMemo<SidebarRow[]>(() => {
|
||||
const nextRows: SidebarRow[] = []
|
||||
for (const group of ['Today', 'Yesterday', 'Earlier'] as const) {
|
||||
const items = grouped[group]
|
||||
if (!items || items.length === 0) continue
|
||||
nextRows.push({ kind: 'group', group })
|
||||
for (const node of items) {
|
||||
nextRows.push({ kind: 'primary', node })
|
||||
const hasChildren = node.children.length > 0
|
||||
const isCollapsed = collapsed.has(node.session.taskId)
|
||||
if (hasChildren && isCollapsed) {
|
||||
nextRows.push({ kind: 'child-count', node })
|
||||
} else if (hasChildren) {
|
||||
node.children.forEach((child, idx) => {
|
||||
nextRows.push({
|
||||
kind: 'child',
|
||||
parentTaskId: node.session.taskId,
|
||||
child,
|
||||
childIndex: idx,
|
||||
childCount: node.children.length,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nextRows
|
||||
}, [collapsed, grouped])
|
||||
|
||||
const useVirtualRows = rows.length > 120
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => listRef.current,
|
||||
estimateSize: (index) => {
|
||||
const row = rows[index]
|
||||
if (row?.kind === 'group') return 26
|
||||
if (row?.kind === 'child-count') return 30
|
||||
return 54
|
||||
},
|
||||
overscan: 8,
|
||||
})
|
||||
|
||||
const renderPrimaryRow = useCallback((node: SessionTree) => {
|
||||
const hasChildren = node.children.length > 0
|
||||
const isCollapsed = collapsed.has(node.session.taskId)
|
||||
return (
|
||||
<div className="session-tree-node">
|
||||
<div className="session-tree-primary">
|
||||
{hasChildren && (
|
||||
<button
|
||||
className={`session-expand-btn${isCollapsed ? ' collapsed' : ''}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
toggleCollapse(node.session.taskId)
|
||||
}}
|
||||
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
/>
|
||||
)}
|
||||
<SessionItem
|
||||
session={node.session}
|
||||
isActive={node.session.taskId === activeSessionId}
|
||||
unreadCount={unreadCounts?.[node.session.channelId]}
|
||||
onSelect={() => onSelect(node.session.taskId)}
|
||||
onDelete={() => onDeleteSession(node.session.taskId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts])
|
||||
|
||||
const renderVirtualRow = useCallback((row: SidebarRow) => {
|
||||
if (row.kind === 'group') {
|
||||
return (
|
||||
<div className="session-group-label">
|
||||
{hasRuntimeSessions ? `${row.group} Runtime Sessions` : row.group}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'primary') {
|
||||
return renderPrimaryRow(row.node)
|
||||
}
|
||||
if (row.kind === 'child-count') {
|
||||
return (
|
||||
<button
|
||||
className="session-child-count"
|
||||
onClick={() => toggleCollapse(row.node.session.taskId)}
|
||||
>
|
||||
{row.node.children.length} sub-task{row.node.children.length > 1 ? 's' : ''}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="session-children">
|
||||
<SessionItem
|
||||
session={row.child}
|
||||
isActive={row.child.taskId === activeSessionId}
|
||||
isChild
|
||||
isLast={row.childIndex === row.childCount - 1}
|
||||
unreadCount={unreadCounts?.[row.child.channelId]}
|
||||
onSelect={() => onSelect(row.child.taskId)}
|
||||
onDelete={() => onDeleteSession(row.child.taskId)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts])
|
||||
|
||||
return (
|
||||
<div className="session-sidebar">
|
||||
<button className="session-new-btn" onClick={onCreateSession}>
|
||||
<IconPlus />
|
||||
<span>New Chat</span>
|
||||
</button>
|
||||
|
||||
{onSelectSecretary && (
|
||||
<button
|
||||
className={`session-nav-btn${activeChannel === secretaryChannelId ? ' active' : ''}`}
|
||||
onClick={onSelectSecretary}
|
||||
>
|
||||
<IconShield />
|
||||
<span>Secretary</span>
|
||||
{!!(secretaryChannelId && unreadCounts?.[secretaryChannelId]) && unreadCounts![secretaryChannelId!] > 0 && (
|
||||
<span className="session-unread-badge">{unreadCounts![secretaryChannelId!] > 99 ? '99+' : unreadCounts![secretaryChannelId!]}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="session-search-wrap">
|
||||
<IconSearch />
|
||||
<input
|
||||
className="session-search"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className={`session-list${useVirtualRows ? ' session-list-virtualized' : ''}`}>
|
||||
{useVirtualRows ? (
|
||||
<div
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map(virtualRow => (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderVirtualRow(rows[virtualRow.index])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (['Today', 'Yesterday', 'Earlier'] as const).map(group => {
|
||||
const items = grouped[group]
|
||||
if (!items || items.length === 0) return null
|
||||
return (
|
||||
<div key={group} className="session-group">
|
||||
<div className="session-group-label">{hasRuntimeSessions ? `${group} Runtime Sessions` : group}</div>
|
||||
{items.map(node => {
|
||||
const hasChildren = node.children.length > 0
|
||||
const isCollapsed = collapsed.has(node.session.taskId)
|
||||
return (
|
||||
<div key={node.session.taskId} className="session-tree-node">
|
||||
<div className="session-tree-primary">
|
||||
{hasChildren && (
|
||||
<button
|
||||
className={`session-expand-btn${isCollapsed ? ' collapsed' : ''}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
toggleCollapse(node.session.taskId)
|
||||
}}
|
||||
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
/>
|
||||
)}
|
||||
<SessionItem
|
||||
session={node.session}
|
||||
isActive={node.session.taskId === activeSessionId}
|
||||
unreadCount={unreadCounts?.[node.session.channelId]}
|
||||
onSelect={() => onSelect(node.session.taskId)}
|
||||
onDelete={() => onDeleteSession(node.session.taskId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasChildren && !isCollapsed && (
|
||||
<div className="session-children">
|
||||
{node.children.map((child, idx) => (
|
||||
<SessionItem
|
||||
key={child.taskId}
|
||||
session={child}
|
||||
isActive={child.taskId === activeSessionId}
|
||||
isChild
|
||||
isLast={idx === node.children.length - 1}
|
||||
unreadCount={unreadCounts?.[child.channelId]}
|
||||
onSelect={() => onSelect(child.taskId)}
|
||||
onDelete={() => onDeleteSession(child.taskId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChildren && isCollapsed && (
|
||||
<button
|
||||
className="session-child-count"
|
||||
onClick={() => toggleCollapse(node.session.taskId)}
|
||||
>
|
||||
{node.children.length} sub-task{node.children.length > 1 ? 's' : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="session-empty">No sessions yet</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`session-nav-btn session-activity-btn${activeSessionId === null && activeChannel !== secretaryChannelId ? ' active' : ''}`}
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<IconActivity />
|
||||
<span>Activity</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
|
||||
import { StaffingSelectionPanel } from './StaffingSelectionPanel'
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(StaffingSelectionPanel, {
|
||||
meta: {
|
||||
checkpoint_type: 'company_staffing_selection',
|
||||
checkpoint_id: 'cp-staffing',
|
||||
company_profile: 'corporate',
|
||||
summary: 'Select staff manually, or run automatic recruitment.',
|
||||
staffing_roles: [
|
||||
{
|
||||
role_id: 'senior_engineer',
|
||||
role_label: 'Senior Engineer',
|
||||
default_selection: { kind: 'employee', id: 'senior-existing' },
|
||||
default_agent: 'codex',
|
||||
selected_agent: 'codex',
|
||||
},
|
||||
],
|
||||
staffing_pool: {
|
||||
employees: [
|
||||
{
|
||||
employee_id: 'senior-existing',
|
||||
employee_name: 'Existing Engineer',
|
||||
role_id: 'senior_engineer',
|
||||
category: 'engineering',
|
||||
},
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
template_id: 'engineering-frontend-developer',
|
||||
template_name: 'Frontend Developer',
|
||||
category: 'engineering',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
onReply: () => undefined,
|
||||
responded: false,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.match(markup, /Manual Staffing/)
|
||||
assert.match(markup, /Existing Engineer/)
|
||||
assert.match(markup, /Frontend Developer/)
|
||||
assert.match(markup, /Approve/)
|
||||
assert.match(markup, /Auto Recruit/)
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const src = readFileSync(join(here, 'StaffingSelectionPanel.tsx'), 'utf8')
|
||||
assert.match(src, /staffing_action: action/, 'panel replies must send structured staffing_action metadata')
|
||||
assert.match(src, /staffing_selections: selections/, 'panel replies must send structured staffing selections')
|
||||
assert.match(src, /recruitment_agent: recruitmentAgent/, 'panel replies must send the selected recruiter agent')
|
||||
assert.match(src, /hasSubmittedCheckpointMetadata/, 'responded staffing cards must detect persisted reply metadata')
|
||||
assert.match(src, /setRoleAgents\(buildRoleAgentsFromMeta\(meta, roles\)\)/, 'responded staffing cards must sync displayed agent choices from reply metadata')
|
||||
|
||||
console.log('StaffingSelectionPanel.test.tsx: OK (manual staffing panel renders structured choices)')
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type {
|
||||
CheckpointReplyMetadata,
|
||||
ChatMessageMeta,
|
||||
StaffingEmployeeOption,
|
||||
StaffingRoleEntry,
|
||||
StaffingSelectionValue,
|
||||
StaffingTemplateOption,
|
||||
} from '../types/chat'
|
||||
import type { TaskPreferredAgent } from '../types/kanban'
|
||||
|
||||
const TASK_AGENT_LABELS: Record<TaskPreferredAgent, string> = {
|
||||
native: 'OpenOPC Native',
|
||||
codex: 'Codex',
|
||||
claude_code: 'Claude Code',
|
||||
cursor: 'Cursor',
|
||||
opencode: 'OpenCode',
|
||||
}
|
||||
|
||||
const DEFAULT_ROLE_AGENT: TaskPreferredAgent = 'codex'
|
||||
const DEFAULT_RECRUITMENT_AGENT: TaskPreferredAgent = 'native'
|
||||
const TASK_AGENT_OPTIONS: TaskPreferredAgent[] = ['codex', 'native', 'claude_code', 'cursor', 'opencode']
|
||||
const RECRUITMENT_AGENT_OPTIONS: TaskPreferredAgent[] = ['native', 'codex', 'claude_code', 'cursor', 'opencode']
|
||||
|
||||
type StaffingOption =
|
||||
| { kind: 'employee'; id: string; name: string; subtitle: string; category: string; searchText: string }
|
||||
| { kind: 'template'; id: string; name: string; subtitle: string; category: string; searchText: string }
|
||||
| { kind: 'fallback'; id: ''; name: string; subtitle: string; category: string; searchText: string }
|
||||
|
||||
interface StaffingSelectionPanelProps {
|
||||
meta: ChatMessageMeta
|
||||
onReply: (text: string, metadata?: CheckpointReplyMetadata) => void
|
||||
responded: boolean
|
||||
}
|
||||
|
||||
function normalizeSelection(value: StaffingSelectionValue | undefined): StaffingSelectionValue {
|
||||
if (!value) return { kind: 'fallback' }
|
||||
if (value.kind === 'employee') {
|
||||
const id = String(value.id ?? value.employee_id ?? '').trim()
|
||||
return id ? { kind: 'employee', id } : { kind: 'fallback' }
|
||||
}
|
||||
if (value.kind === 'template') {
|
||||
const id = String(value.id ?? value.template_id ?? '').trim()
|
||||
return id ? { kind: 'template', id } : { kind: 'fallback' }
|
||||
}
|
||||
return { kind: 'fallback' }
|
||||
}
|
||||
|
||||
function selectionKey(value: StaffingSelectionValue | undefined): string {
|
||||
const normalized = normalizeSelection(value)
|
||||
return normalized.kind === 'fallback' ? 'fallback:' : `${normalized.kind}:${normalized.id ?? ''}`
|
||||
}
|
||||
|
||||
function buildOptions(
|
||||
role: StaffingRoleEntry,
|
||||
employees: StaffingEmployeeOption[],
|
||||
templates: StaffingTemplateOption[],
|
||||
): StaffingOption[] {
|
||||
const roleId = String(role.role_id ?? '').trim()
|
||||
const roleLabel = String(role.role_label ?? '').trim()
|
||||
const roleText = `${roleId} ${roleLabel} ${role.role_responsibility ?? ''}`.toLowerCase()
|
||||
const sameRoleIds = new Set((role.same_role_employee_ids ?? []).map(item => String(item ?? '').trim()).filter(Boolean))
|
||||
const templateScore = (template: StaffingTemplateOption): number => {
|
||||
const templateText = `${template.template_id ?? ''} ${template.template_name ?? ''} ${template.category ?? ''} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase()
|
||||
const categoryTerm = String(template.category ?? '').toLowerCase()
|
||||
let score = 0
|
||||
for (const token of roleText.split(/[^a-z0-9]+/).filter(token => token.length >= 3)) {
|
||||
if (templateText.includes(token) || (categoryTerm && token.includes(categoryTerm))) score += 1
|
||||
}
|
||||
if (templateText.includes(roleId.replace(/_/g, '-')) || templateText.includes(roleId.replace(/_/g, ' '))) score += 2
|
||||
return score
|
||||
}
|
||||
const employeeOptions = employees.map((employee): StaffingOption & { rank: number } => {
|
||||
const id = String(employee.employee_id ?? '').trim()
|
||||
const name = String(employee.employee_name ?? id).trim() || id
|
||||
const employeeRole = String(employee.role_id ?? '').trim()
|
||||
const category = String(employee.category ?? '').trim()
|
||||
const subtitle = [employeeRole, category].filter(Boolean).join(' · ') || id
|
||||
return {
|
||||
kind: 'employee',
|
||||
id,
|
||||
name,
|
||||
subtitle,
|
||||
category,
|
||||
searchText: `${id} ${name} ${employeeRole} ${category} ${(employee.domains ?? []).join(' ')} ${(employee.tags ?? []).join(' ')}`.toLowerCase(),
|
||||
rank: sameRoleIds.has(id) || employeeRole === roleId ? 0 : 2,
|
||||
}
|
||||
}).filter(option => option.id).sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
|
||||
const templateOptions = templates.map((template): StaffingOption & { rank: number } => {
|
||||
const id = String(template.template_id ?? '').trim()
|
||||
const name = String(template.template_name ?? id).trim() || id
|
||||
const category = String(template.category ?? '').trim()
|
||||
const subtitle = [category, id].filter(Boolean).join(' · ') || id
|
||||
return {
|
||||
kind: 'template',
|
||||
id,
|
||||
name,
|
||||
subtitle,
|
||||
category,
|
||||
searchText: `${id} ${name} ${category} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase(),
|
||||
rank: templateScore(template),
|
||||
}
|
||||
}).filter(option => option.id).sort((a, b) => b.rank - a.rank || a.name.localeCompare(b.name))
|
||||
return [
|
||||
...employeeOptions,
|
||||
...templateOptions,
|
||||
{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' },
|
||||
]
|
||||
}
|
||||
|
||||
function optionForSelection(options: StaffingOption[], selection: StaffingSelectionValue | undefined): StaffingOption {
|
||||
const key = selectionKey(selection)
|
||||
return options.find(option => `${option.kind}:${option.id}` === key) ?? options[0]
|
||||
}
|
||||
|
||||
function optionMatches(option: StaffingOption, query: string): boolean {
|
||||
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (terms.length === 0) return true
|
||||
return terms.every(term => option.searchText.includes(term))
|
||||
}
|
||||
|
||||
function buildSelectionsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, StaffingSelectionValue> {
|
||||
const initial: Record<string, StaffingSelectionValue> = {}
|
||||
const persisted = meta.staffing_selections ?? {}
|
||||
for (const role of roles) {
|
||||
const roleId = String(role.role_id ?? '').trim()
|
||||
if (!roleId) continue
|
||||
initial[roleId] = normalizeSelection(persisted[roleId] ?? role.default_selection)
|
||||
}
|
||||
return initial
|
||||
}
|
||||
|
||||
function buildRoleAgentsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, TaskPreferredAgent> {
|
||||
const persisted = meta.recruitment_role_agents ?? {}
|
||||
const initial: Record<string, TaskPreferredAgent> = {}
|
||||
for (const role of roles) {
|
||||
const roleId = String(role.role_id ?? '').trim()
|
||||
if (!roleId) continue
|
||||
initial[roleId] = persisted[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
|
||||
}
|
||||
return initial
|
||||
}
|
||||
|
||||
function hasSubmittedCheckpointMetadata(meta: ChatMessageMeta): boolean {
|
||||
return Boolean(
|
||||
String(meta.checkpoint_response_message_id ?? '').trim()
|
||||
|| String(meta.checkpoint_responded_at ?? '').trim()
|
||||
|| String(meta.staffing_action ?? '').trim()
|
||||
)
|
||||
}
|
||||
|
||||
export const StaffingSelectionPanel = React.memo(function StaffingSelectionPanel({
|
||||
meta, onReply, responded,
|
||||
}: StaffingSelectionPanelProps) {
|
||||
const roles = useMemo(() => meta.staffing_roles ?? [], [meta.staffing_roles])
|
||||
const employees = meta.staffing_pool?.employees ?? []
|
||||
const templates = meta.staffing_pool?.templates ?? []
|
||||
const optionsByRole = useMemo(() => {
|
||||
const next: Record<string, StaffingOption[]> = {}
|
||||
for (const role of roles) {
|
||||
const roleId = String(role.role_id ?? '').trim()
|
||||
if (roleId) next[roleId] = buildOptions(role, employees, templates)
|
||||
}
|
||||
return next
|
||||
}, [employees, roles, templates])
|
||||
const [queries, setQueries] = useState<Record<string, string>>({})
|
||||
const [selections, setSelections] = useState<Record<string, StaffingSelectionValue>>(() => buildSelectionsFromMeta(meta, roles))
|
||||
const [roleAgents, setRoleAgents] = useState<Record<string, TaskPreferredAgent>>(() => buildRoleAgentsFromMeta(meta, roles))
|
||||
const [recruitmentAgent, setRecruitmentAgent] = useState<TaskPreferredAgent>(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
|
||||
const isResponded = responded
|
||||
const recommendAutoRecruit = meta.recommended_action === 'auto_recruit' && templates.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResponded || !hasSubmittedCheckpointMetadata(meta)) return
|
||||
setSelections(buildSelectionsFromMeta(meta, roles))
|
||||
setRoleAgents(buildRoleAgentsFromMeta(meta, roles))
|
||||
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
|
||||
}, [isResponded, meta, roles])
|
||||
|
||||
useEffect(() => {
|
||||
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
|
||||
}, [meta.recruitment_agent])
|
||||
|
||||
const buildReplyMetadata = useCallback((action: 'manual_approve' | 'auto_recruit'): CheckpointReplyMetadata => {
|
||||
const checkpointId = String(meta.checkpoint_id ?? '').trim()
|
||||
if (!checkpointId) {
|
||||
throw new Error('Staffing checkpoint reply requires checkpoint_id metadata.')
|
||||
}
|
||||
return {
|
||||
response_to_checkpoint_id: checkpointId,
|
||||
response_to_checkpoint_type: 'company_staffing_selection',
|
||||
staffing_action: action,
|
||||
staffing_selections: selections,
|
||||
recruitment_agent: recruitmentAgent,
|
||||
recruitment_role_agents: roles.reduce<Record<string, TaskPreferredAgent>>((acc, role) => {
|
||||
const roleId = String(role.role_id ?? '').trim()
|
||||
if (!roleId) return acc
|
||||
acc[roleId] = roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
|
||||
return acc
|
||||
}, {}),
|
||||
}
|
||||
}, [meta.checkpoint_id, recruitmentAgent, roleAgents, roles, selections])
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (isResponded) return
|
||||
onReply('approve', buildReplyMetadata('manual_approve'))
|
||||
}, [buildReplyMetadata, isResponded, onReply])
|
||||
|
||||
const handleAutoRecruit = useCallback(() => {
|
||||
if (isResponded) return
|
||||
onReply('auto recruit', buildReplyMetadata('auto_recruit'))
|
||||
}, [buildReplyMetadata, isResponded, onReply])
|
||||
|
||||
return (
|
||||
<div className="ckpt-panel ckpt-staffing">
|
||||
<div className="ckpt-header">
|
||||
<div className="ckpt-icon ckpt-icon-staffing">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
||||
<path d="M1.5 14c.6-2.5 2.2-4 4.5-4s3.9 1.5 4.5 4" />
|
||||
<path d="M12.5 3.5v5M10 6h5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ckpt-title">Manual Staffing</div>
|
||||
<span className="ckpt-badge ckpt-badge-profile">{meta.company_profile || 'corporate'}</span>
|
||||
{recommendAutoRecruit && !isResponded && <span className="ckpt-badge ckpt-badge-scope">Recruit recommended</span>}
|
||||
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
|
||||
</div>
|
||||
|
||||
{meta.summary && <div className="ckpt-summary">{meta.summary}</div>}
|
||||
|
||||
<div className="ckpt-recruiter-agent">
|
||||
<label className="ckpt-agent-label" htmlFor={`staffing-recruiter-agent-${meta.checkpoint_id}`}>
|
||||
Recruiter Agent
|
||||
</label>
|
||||
<select
|
||||
id={`staffing-recruiter-agent-${meta.checkpoint_id}`}
|
||||
className="ckpt-agent-select"
|
||||
value={recruitmentAgent}
|
||||
onChange={event => setRecruitmentAgent(event.target.value as TaskPreferredAgent)}
|
||||
disabled={isResponded}
|
||||
>
|
||||
{RECRUITMENT_AGENT_OPTIONS.map(agent => (
|
||||
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ckpt-staffing-grid">
|
||||
{roles.map(role => {
|
||||
const roleId = String(role.role_id ?? '').trim()
|
||||
const options = optionsByRole[roleId] ?? [{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' }]
|
||||
const selected = optionForSelection(options, selections[roleId])
|
||||
const query = queries[roleId] ?? ''
|
||||
const visibleOptions = options.filter(option => optionMatches(option, query)).slice(0, 8)
|
||||
return (
|
||||
<div key={roleId} className="ckpt-staffing-card">
|
||||
<div className="ckpt-proposal-header">
|
||||
<span className="ckpt-role-name">{roleId}</span>
|
||||
<span className={`ckpt-badge ckpt-badge-${selected.kind}`}>{selected.kind}</span>
|
||||
</div>
|
||||
{role.role_label && role.role_label !== roleId && (
|
||||
<div className="ckpt-role-labels">
|
||||
<span className="ckpt-field-tag">{role.role_label}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ckpt-staffing-selected">
|
||||
<div className="ckpt-cand-name">{selected.name}</div>
|
||||
<div className="ckpt-cand-meta">
|
||||
<span className="ckpt-cand-category">{selected.category}</span>
|
||||
<span className="ckpt-domain-tag">{selected.subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className="ckpt-staffing-search"
|
||||
value={query}
|
||||
onChange={event => setQueries(current => ({ ...current, [roleId]: event.target.value }))}
|
||||
placeholder="Search employees or templates..."
|
||||
disabled={isResponded}
|
||||
/>
|
||||
<div className="ckpt-staffing-options">
|
||||
{visibleOptions.map(option => {
|
||||
const active = `${option.kind}:${option.id}` === selectionKey(selections[roleId])
|
||||
return (
|
||||
<button
|
||||
key={`${option.kind}:${option.id}`}
|
||||
className={`ckpt-staffing-option${active ? ' active' : ''}`}
|
||||
onClick={() => setSelections(current => ({ ...current, [roleId]: { kind: option.kind, id: option.id } }))}
|
||||
disabled={isResponded}
|
||||
title={option.subtitle}
|
||||
>
|
||||
<span className="ckpt-staffing-option-kind">{option.kind}</span>
|
||||
<span className="ckpt-staffing-option-name">{option.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="ckpt-agent-picker">
|
||||
<label className="ckpt-agent-label" htmlFor={`staffing-agent-${meta.checkpoint_id}-${roleId}`}>
|
||||
Execution Agent
|
||||
</label>
|
||||
<select
|
||||
id={`staffing-agent-${meta.checkpoint_id}-${roleId}`}
|
||||
className="ckpt-agent-select"
|
||||
value={roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT}
|
||||
onChange={event => setRoleAgents(current => ({ ...current, [roleId]: event.target.value as TaskPreferredAgent }))}
|
||||
disabled={isResponded}
|
||||
>
|
||||
{TASK_AGENT_OPTIONS.map(agent => (
|
||||
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isResponded && (
|
||||
<div className="ckpt-actions">
|
||||
{recommendAutoRecruit ? (
|
||||
<>
|
||||
<button className="ckpt-btn ckpt-btn-approve" onClick={handleAutoRecruit}>Auto Recruit</button>
|
||||
<button className="ckpt-btn ckpt-btn-feedback" onClick={handleApprove}>Approve Selections</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove}>Approve Selections</button>
|
||||
{templates.length > 0 && <button className="ckpt-btn ckpt-btn-feedback" onClick={handleAutoRecruit}>Auto Recruit</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* SVG icon set — geometry derived from Lucide (https://lucide.dev), MIT.
|
||||
*
|
||||
* We inline the paths instead of pulling in `lucide-react` to keep the
|
||||
* production bundle tight. The icons follow Lucide conventions:
|
||||
* - 24×24 design grid
|
||||
* - 2px stroke (1.5 for very small chrome icons)
|
||||
* - round caps & joins
|
||||
* - currentColor everywhere so CSS controls tone
|
||||
*
|
||||
* When adding a new icon, copy the geometry from the official Lucide
|
||||
* source (https://github.com/lucide-icons/lucide/tree/main/icons) so the
|
||||
* whole set stays visually consistent.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export function IconBrain() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2C9.5 2 7.5 3.5 7 5.5C5 5.5 3 7.5 3 10C3 12 4.5 13.5 6 14V20C6 21.1 6.9 22 8 22H16C17.1 22 18 21.1 18 20V14C19.5 13.5 21 12 21 10C21 7.5 19 5.5 17 5.5C16.5 3.5 14.5 2 12 2Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M12 2V8M8 6H16M12 14V18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconTool() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94L6.73 20.15a2.12 2.12 0 0 1-3-3l6.72-6.72a6 6 0 0 1 7.94-7.94L14.7 6.3z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconStop() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="4" y="4" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="8" y="8" width="8" height="8" rx="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconSend() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 12L3 3L21 12L3 21L5 12ZM5 12H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconCopy() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconCheck() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcome-state chat icon. Lucide `message-square-text`: a squared bubble
|
||||
* with two transcript lines, which reads as "a real conversation" much
|
||||
* better than a generic empty bubble at large sizes.
|
||||
*/
|
||||
export function IconChat() {
|
||||
return (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M13 8H7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M17 12H7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconPaperclip() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.49" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconChevron({ down }: { down?: boolean }) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ transition: 'transform 200ms', transform: down ? 'rotate(90deg)' : 'none' }}>
|
||||
<path d="M9 18L15 12L9 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconPlus() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 5V19M5 12H19" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconSearch() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M21 21L16.65 16.65" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconTrash() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 6H5H21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6L18.13 20.11A2 2 0 0 1 16.14 22H7.86a2 2 0 0 1-2-1.89L5 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconBoard() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="3" y="16" width="7" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconActivity() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22 12H18L15 21L9 3L6 12H2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconShield() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-avatar sparkle. Lucide `sparkles`: one large 4-point burst plus
|
||||
* two small accent sparks — reads as "AI/magic" without the generic
|
||||
* single-star look.
|
||||
*/
|
||||
export function IconSparkle() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M20 3v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M22 5h-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M4 17v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M5 18H3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Lucide `arrow-right` — two crisp strokes, no diagonal artifacts. */
|
||||
export function IconArrowRight() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 12h14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="m12 5 7 7-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconGate() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 11L12 14L22 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M21 12V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconZap() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Work Item / Runtime Panel icons ──────────────────────────────────────
|
||||
|
||||
export function IconWorkItem() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4 18H10V14H4V18Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8 14H14V10H8V14Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M12 10H18V6H12V10Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M18 8L21 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconGatePass() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M9 12L11 14L15 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconGateReject() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M15 9L9 15M9 9L15 15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconClock() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M12 6V12L16 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconHandoff() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M7 17L2 12L7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M17 7L22 12L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2 12H22" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconTimeline() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2V22" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="12" cy="6" r="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="12" cy="12" r="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="12" cy="18" r="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M14 6H20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M14 12H18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M14 18H20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconClose() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Mode / lock icons (shared by the composer mode chip) ─────────────────
|
||||
|
||||
/**
|
||||
* Lucide `lock` — the classic shackle + body. Size defaults to 12px so it
|
||||
* tucks nicely inside a chip; pass a size prop to scale.
|
||||
*/
|
||||
export function IconLock({ size = 12 }: { size?: number } = {}) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 11V7a5 5 0 0 1 10 0v4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Lucide `user-round` — proportional head + shoulders, perfect for Task mode. */
|
||||
export function IconUserRound({ size = 14 }: { size?: number } = {}) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="8"
|
||||
r="5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 21a8 8 0 0 0-16 0"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Lucide `building-2` — multi-story office building, reads as "company". */
|
||||
export function IconBuilding({ size = 14 }: { size?: number } = {}) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M10 6h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M10 10h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M10 14h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M10 18h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact 3-spark "Sparkles" for the Org mode tile. We reuse the full
|
||||
* `sparkles` geometry but at a smaller default size — same family, less
|
||||
* visual weight than the agent avatar usage.
|
||||
*/
|
||||
export function IconSparkles({ size = 14 }: { size?: number } = {}) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M20 3v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M22 5h-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M4 17v2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M5 18H3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
|
||||
import { TaskHeaderBar } from './TaskHeaderBar'
|
||||
import type { Session } from '../types/kanban'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const nexusAgent: AgentInfo = {
|
||||
agent_id: 'nexus',
|
||||
name: 'NEXUS Executive Brief',
|
||||
description: 'Executive briefing agent',
|
||||
specialties: [],
|
||||
status: 'idle',
|
||||
appearance: { palette: 0, hue_shift: 0, seat_zone: 'north' },
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<Session>): Session {
|
||||
return {
|
||||
projectId: 'project-a',
|
||||
taskId: 'task-a',
|
||||
channelId: 'channel-a',
|
||||
execMode: 'task',
|
||||
title: 'NEXUS Executive Brief',
|
||||
status: 'running',
|
||||
columnId: 'in-progress',
|
||||
assigneeIds: ['nexus'],
|
||||
priority: null,
|
||||
tags: [],
|
||||
progressLog: [],
|
||||
createdAt: now - 60_000,
|
||||
updatedAt: now,
|
||||
messageCount: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const companyMarkup = renderToStaticMarkup(
|
||||
React.createElement(TaskHeaderBar, {
|
||||
session: makeSession({
|
||||
execMode: 'org',
|
||||
isCompanyRuntime: true,
|
||||
workItemRoleName: 'Chief Analyst',
|
||||
employeeAssignment: { name: 'NEXUS Executive Brief', employeeId: 'employee-nexus' },
|
||||
selectedExecutionAgent: 'codex',
|
||||
displayTool: 'opc-collab delegate_work',
|
||||
currentTool: undefined,
|
||||
}),
|
||||
agents: [nexusAgent],
|
||||
onTitleChange: () => undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.match(companyMarkup, /Chief Analyst/, 'company header should keep the role pill')
|
||||
assert.match(companyMarkup, /NEXUS Executive Brief/, 'company header should keep the employee label')
|
||||
assert.match(companyMarkup, /Codex/, 'company header should keep the execution agent label')
|
||||
assert.match(companyMarkup, /opc-collab delegate_work/, 'company header should show stable displayTool while still running')
|
||||
assert.doesNotMatch(
|
||||
companyMarkup,
|
||||
/task-header-avatar/,
|
||||
'company header must not render a duplicate assignee initial avatar',
|
||||
)
|
||||
|
||||
const taskMarkup = renderToStaticMarkup(
|
||||
React.createElement(TaskHeaderBar, {
|
||||
session: makeSession({ execMode: 'task' }),
|
||||
agents: [nexusAgent],
|
||||
onTitleChange: () => undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.match(
|
||||
taskMarkup,
|
||||
/task-header-avatar/,
|
||||
'plain task headers should keep assignee initial avatars',
|
||||
)
|
||||
|
||||
console.log('TaskHeaderBar.test.tsx: OK (company header hides duplicate avatar and keeps stable tool label)')
|
||||
@@ -0,0 +1,265 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Session } from '../types/kanban'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import { IconStop, IconBoard, IconTool, IconCheck } from './SvgIcons'
|
||||
import { getSessionRuntimeStatus, isSessionWorking } from '../lib/sessionRuntime'
|
||||
import { getWorkItemRoleLabel } from '../lib/workItemIdentity'
|
||||
|
||||
interface TaskHeaderBarProps {
|
||||
session: Session
|
||||
agents: AgentInfo[]
|
||||
onTitleChange: (taskId: string, title: string) => void
|
||||
onViewOnBoard?: () => void
|
||||
onStop?: () => void
|
||||
onComplete?: () => void
|
||||
onResume?: () => void
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 60_000) return 'just now'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`
|
||||
}
|
||||
|
||||
const STATUS_META: Record<string, { color: string; label: string }> = {
|
||||
running: { color: 'var(--green)', label: 'Running' },
|
||||
idle: { color: 'var(--accent)', label: 'Idle' },
|
||||
done: { color: 'var(--green)', label: 'Done' },
|
||||
pending: { color: 'var(--text-secondary)', label: 'Pending' },
|
||||
failed: { color: 'var(--red)', label: 'Failed' },
|
||||
cancelled: { color: 'var(--text-secondary)', label: 'Cancelled' },
|
||||
blocked: { color: 'var(--yellow)', label: 'Blocked' },
|
||||
awaiting_human: { color: 'var(--yellow)', label: 'Waiting for review' },
|
||||
awaiting_manager_review: { color: 'var(--yellow)', label: 'Manager review' },
|
||||
awaiting_review: { color: 'var(--yellow)', label: 'Waiting for review' },
|
||||
awaiting_peer: { color: 'var(--yellow)', label: 'Waiting for peer' },
|
||||
}
|
||||
|
||||
const HUMAN_REVIEW_STATUSES = new Set([
|
||||
'awaiting_human',
|
||||
'awaiting_manager_review',
|
||||
'awaiting_review',
|
||||
'awaiting_peer',
|
||||
])
|
||||
|
||||
const EXECUTION_AGENT_LABELS: Record<string, string> = {
|
||||
native: 'Native',
|
||||
codex: 'Codex',
|
||||
claude_code: 'Claude Code',
|
||||
cursor: 'Cursor',
|
||||
opencode: 'OpenCode',
|
||||
}
|
||||
|
||||
export function TaskHeaderBar({ session, agents, onTitleChange, onViewOnBoard, onStop, onComplete, onResume }: TaskHeaderBarProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(session.title)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(session.title)
|
||||
}, [session.title, editing])
|
||||
|
||||
const commitTitle = useCallback(() => {
|
||||
const trimmed = draft.trim()
|
||||
if (trimmed && trimmed !== session.title) {
|
||||
onTitleChange(session.taskId, trimmed)
|
||||
} else {
|
||||
setDraft(session.title)
|
||||
}
|
||||
setEditing(false)
|
||||
}, [draft, session.title, session.taskId, onTitleChange])
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
setDraft(session.title)
|
||||
setEditing(true)
|
||||
setTimeout(() => inputRef.current?.select(), 0)
|
||||
}, [session.title])
|
||||
|
||||
const assignees = session.assigneeIds
|
||||
.map(id => agents.find(a => a.agent_id === id))
|
||||
.filter(Boolean) as AgentInfo[]
|
||||
const execMode = String(session.execMode ?? '').trim().toLowerCase()
|
||||
const isCompanyHeaderSession = execMode === 'company'
|
||||
|| execMode === 'org'
|
||||
|| execMode === 'custom'
|
||||
|| !!session.isCompanyRuntime
|
||||
|| !!session.workItemProjectionId
|
||||
|| !!session.workItemRoleId
|
||||
|| !!session.workItemRoleName
|
||||
const showAssigneeAvatars = assignees.length > 0 && !isCompanyHeaderSession
|
||||
|
||||
const runtimeControlState = session.runtimeControlState ?? (session.status === 'running' ? 'running' : 'idle')
|
||||
const isSuspending = runtimeControlState === 'suspending'
|
||||
const isResuming = runtimeControlState === 'resuming'
|
||||
const isSuspended = runtimeControlState === 'suspended'
|
||||
const isRunning = session.status === 'running' && !isSuspending && !isSuspended && !isResuming
|
||||
const isAwaitingReview = HUMAN_REVIEW_STATUSES.has(session.status)
|
||||
const canStop = (session.canStop ?? session.status === 'running') && !isSuspending && !isSuspended && !isResuming
|
||||
const canResume = (
|
||||
session.canResume
|
||||
?? (isSuspended || (!isAwaitingReview && !isRunning && session.status !== 'done' && session.status !== 'pending'))
|
||||
) && !isSuspending && !isResuming
|
||||
const meta = STATUS_META[session.status] ?? STATUS_META.pending
|
||||
const statusLabel = isSuspending ? 'Stopping' : isSuspended ? 'Suspended' : isResuming ? 'Resuming' : meta.label
|
||||
const roleLabel = getWorkItemRoleLabel(session)
|
||||
const runtimeStatus = getSessionRuntimeStatus(session)
|
||||
const isWorking = isSessionWorking(session)
|
||||
const liveTool = session.displayTool || session.currentTool
|
||||
// Sticky tool label tied to the RUN lifecycle (not the transient agentStatus).
|
||||
// The native runtime reports an 'idle'/'reflecting' state with no current_tool
|
||||
// between consecutive tool calls; reacting to that blanks the pill for a frame
|
||||
// and makes the command flicker once per call. Instead, keep showing the last
|
||||
// non-empty command for as long as the session is running, and drop it only
|
||||
// when the run stops — so the pill holds steady and just swaps to the next tool.
|
||||
const [stickyTool, setStickyTool] = useState<string | undefined>(liveTool || undefined)
|
||||
useEffect(() => {
|
||||
if (!isRunning) { setStickyTool(undefined); return }
|
||||
if (liveTool) setStickyTool(liveTool)
|
||||
}, [isRunning, liveTool])
|
||||
const hasApprovalMetrics = typeof session.pendingPermissionCount === 'number' && session.pendingPermissionCount > 0
|
||||
const showRuntimeMetrics = hasApprovalMetrics
|
||||
const statusDotColor = isSuspending
|
||||
? 'var(--yellow)'
|
||||
: isSuspended
|
||||
? 'var(--text-secondary)'
|
||||
: runtimeStatus === 'tool_active'
|
||||
? 'var(--green)'
|
||||
: runtimeStatus === 'reflecting'
|
||||
? 'var(--yellow)'
|
||||
: meta.color
|
||||
|
||||
return (
|
||||
<div className="task-header-shell">
|
||||
<div className="task-header-bar">
|
||||
<div className="task-header-left">
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="task-title-input"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitTitle()
|
||||
if (e.key === 'Escape') { setDraft(session.title); setEditing(false) }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="task-title" onClick={startEditing} title="Click to edit">
|
||||
{session.title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="task-status-pill" data-status={session.status} data-working={isWorking ? 'true' : 'false'}>
|
||||
<span className="task-status-dot" style={{ background: statusDotColor }} />
|
||||
{statusLabel}
|
||||
</span>
|
||||
|
||||
{isRunning && stickyTool && (
|
||||
<span className="task-tool-pill">
|
||||
<IconTool />
|
||||
<code>{stickyTool}</code>
|
||||
{session.currentTool && typeof session.toolElapsedMs === 'number' && session.toolElapsedMs > 0 && (
|
||||
<span>{session.toolElapsedMs}ms</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{session.lastToolSummary && (
|
||||
<span className="task-projection-pill" title={session.lastToolSummary}>
|
||||
{session.lastToolSummary.slice(0, 48)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Projection id (e.g. "attention::seat::team::cto::cto::review::f42cf81f")
|
||||
is an internal debug identifier — useful in the Info tab but
|
||||
adds noise to the header bar. Surface it via title-tooltip on
|
||||
the role pill rather than as a wide chip. */}
|
||||
|
||||
{roleLabel && (
|
||||
<span className="task-role-pill" title={`Role: ${roleLabel}`}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{session.employeeAssignment?.name && (
|
||||
<span className="task-employee-pill" title={`Employee: ${session.employeeAssignment.name}${session.employeeAssignment.category ? ` (${session.employeeAssignment.category})` : ''}`}>
|
||||
<span className="task-employee-icon">👤</span>
|
||||
{session.employeeAssignment.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{session.selectedExecutionAgent && (
|
||||
<span
|
||||
className="task-agent-pill"
|
||||
title={`Execution Agent: ${EXECUTION_AGENT_LABELS[session.selectedExecutionAgent] ?? session.selectedExecutionAgent}`}
|
||||
>
|
||||
<span className="task-agent-icon">⚙</span>
|
||||
{EXECUTION_AGENT_LABELS[session.selectedExecutionAgent] ?? session.selectedExecutionAgent}
|
||||
</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="task-header-right">
|
||||
{showAssigneeAvatars && (
|
||||
<div className="task-header-avatars">
|
||||
{assignees.slice(0, 3).map(a => (
|
||||
<span key={a.agent_id} className="task-header-avatar" title={a.name}>
|
||||
{a.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="task-header-time" title={new Date(session.createdAt).toLocaleString()}>
|
||||
{relativeTime(session.createdAt)}
|
||||
</span>
|
||||
|
||||
{(canStop || isSuspending) && onStop && (
|
||||
<button className="task-stop-btn" onClick={onStop} title="Stop task" disabled={!canStop}>
|
||||
<IconStop />
|
||||
<span>{isSuspending ? 'Stopping...' : 'Stop'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canResume && onResume && session.status !== 'done' && (
|
||||
<button
|
||||
className="task-resume-btn"
|
||||
onClick={onResume}
|
||||
title="Resume prior runtime (re-awaken original team, no new plan)"
|
||||
>
|
||||
<span>Continue</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onComplete && (
|
||||
<button className="task-done-btn" onClick={onComplete} title="Mark task as done">
|
||||
<IconCheck />
|
||||
<span>Done</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onViewOnBoard && (
|
||||
<button className="task-board-btn" onClick={onViewOnBoard} title="View on Board">
|
||||
<IconBoard />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRuntimeMetrics && (
|
||||
<div className="task-header-metrics">
|
||||
{hasApprovalMetrics && (
|
||||
<div className="task-runtime-metric task-runtime-metric-approval" title="Pending approvals">
|
||||
<span className="task-runtime-metric-label">Approvals</span>
|
||||
<span className="task-runtime-metric-value">{session.pendingPermissionCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
|
||||
import { TaskUserInputPanel } from './TaskUserInputPanel'
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(TaskUserInputPanel, {
|
||||
meta: {
|
||||
checkpoint_type: 'task_user_input',
|
||||
checkpoint_id: 'cp-input',
|
||||
task_id: 'task-1',
|
||||
work_item_projection_title: 'Engineer Input',
|
||||
summary: 'Need one missing decision.',
|
||||
prompt: 'Please answer:\n\n- Which provider?\n- Which tier?\n\n```txt\nstripe\n```',
|
||||
questions: ['Which provider should be used?'],
|
||||
required_fields: ['provider'],
|
||||
context_note: 'Known context:\n\n- User wants checkout',
|
||||
requesting_role_id: 'engineer',
|
||||
requesting_task_id: 'task-1',
|
||||
requesting_work_item_id: 'work-item-1',
|
||||
seat_id: 'seat::team::engineering::engineer',
|
||||
active_subagents: [{ id: 'sub-1' }],
|
||||
permission_requests: [{ id: 'perm-1' }],
|
||||
worktree_path: '/tmp/work',
|
||||
},
|
||||
onReply: () => undefined,
|
||||
responded: false,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.match(markup, /Engineer Input/)
|
||||
assert.match(markup, /<li>Which provider\?<\/li>/)
|
||||
assert.match(markup, /<code class="language-txt">/)
|
||||
assert.match(markup, /<summary>Runtime State<\/summary>/)
|
||||
assert.match(markup, /Requester: <code>engineer<\/code>/)
|
||||
assert.match(markup, /Work item: <code>work-item-1<\/code>/)
|
||||
assert.match(markup, /Active subagents: 1/)
|
||||
|
||||
const choiceMarkup = renderToStaticMarkup(
|
||||
React.createElement(TaskUserInputPanel, {
|
||||
meta: {
|
||||
checkpoint_type: 'task_user_input',
|
||||
checkpoint_id: 'cp-choice',
|
||||
task_id: 'task-2',
|
||||
work_item_projection_title: 'Deployment Input',
|
||||
summary: 'Need a deployment decision.',
|
||||
prompt: 'Choose a region before continuing.',
|
||||
questions: ['Which deployment region should be used?'],
|
||||
input_questions: [
|
||||
{
|
||||
id: 'deployment_region',
|
||||
header: 'Deployment region',
|
||||
question: 'Which deployment region should I target?\n\n- Pick one if there is a clear preference.',
|
||||
options: [
|
||||
{ id: 'a', label: 'US East', description: 'Use us-east-1' },
|
||||
{ id: 'b', label: 'EU West', description: 'Use eu-west-1' },
|
||||
{ id: 'c', label: 'Asia', description: 'Use ap-east-1' },
|
||||
],
|
||||
allow_freeform: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
required_fields: ['deployment_region'],
|
||||
},
|
||||
onReply: () => undefined,
|
||||
responded: false,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.match(choiceMarkup, /Deployment region/)
|
||||
assert.match(choiceMarkup, /ckpt-choice-option/)
|
||||
assert.match(choiceMarkup, /US East/)
|
||||
assert.match(choiceMarkup, /EU West/)
|
||||
assert.match(choiceMarkup, /Asia/)
|
||||
assert.match(choiceMarkup, /Other/)
|
||||
assert.match(choiceMarkup, /disabled=""/)
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const src = readFileSync(join(here, 'TaskUserInputPanel.tsx'), 'utf8')
|
||||
assert.doesNotMatch(src, /localResponded|setLocalResponded/, 'panel must wait for server checkpoint metadata before showing responded state')
|
||||
assert.match(src, /user_input_answers/, 'structured answers must be forwarded to the backend')
|
||||
|
||||
const messageListSrc = readFileSync(join(here, 'MessageList.tsx'), 'utf8')
|
||||
const progressIndex = messageListSrc.indexOf("items.push({ kind: 'progress-block' })")
|
||||
const pendingIndex = messageListSrc.indexOf("items.push({ kind: 'pending-section' })")
|
||||
const endIndex = messageListSrc.indexOf("items.push({ kind: 'end-anchor' })")
|
||||
assert.ok(progressIndex !== -1 && pendingIndex !== -1 && endIndex !== -1)
|
||||
assert.ok(progressIndex < pendingIndex, 'pending checkpoint cards should render after the progress block')
|
||||
assert.ok(pendingIndex < endIndex, 'pending checkpoint cards should render before the end anchor')
|
||||
|
||||
console.log('TaskUserInputPanel.test.tsx: OK (markdown and choice checkpoint panel)')
|
||||
@@ -0,0 +1,308 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import type {
|
||||
ChatMessageMeta,
|
||||
CheckpointReplyMetadata,
|
||||
TaskUserInputAnswer,
|
||||
TaskUserInputQuestion,
|
||||
} from '../types/chat'
|
||||
import { MarkdownBody } from './MarkdownBody'
|
||||
|
||||
interface TaskUserInputPanelProps {
|
||||
meta: ChatMessageMeta
|
||||
onReply: (text: string, metadata?: Partial<CheckpointReplyMetadata>) => void
|
||||
responded: boolean
|
||||
}
|
||||
|
||||
interface QuestionState {
|
||||
selectedOptionId?: string
|
||||
freeformText: string
|
||||
}
|
||||
|
||||
const OTHER_OPTION_ID = '__other__'
|
||||
const OPTION_LETTERS = ['A', 'B', 'C']
|
||||
|
||||
function cleanText(value: unknown): string {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function normalizeQuestion(raw: TaskUserInputQuestion, index: number): TaskUserInputQuestion | null {
|
||||
const question = cleanText(raw.question)
|
||||
const header = cleanText(raw.header)
|
||||
if (!question && !header) return null
|
||||
const options = (raw.options ?? [])
|
||||
.slice(0, 3)
|
||||
.map((option, optionIndex) => ({
|
||||
id: cleanText(option.id) || String.fromCharCode(97 + optionIndex),
|
||||
label: cleanText(option.label),
|
||||
description: cleanText(option.description),
|
||||
}))
|
||||
.filter((option) => option.label)
|
||||
return {
|
||||
id: cleanText(raw.id) || `question_${index + 1}`,
|
||||
header,
|
||||
question: question || header,
|
||||
options,
|
||||
allow_freeform: raw.allow_freeform !== false,
|
||||
required: raw.required !== false,
|
||||
}
|
||||
}
|
||||
|
||||
export const TaskUserInputPanel = React.memo(function TaskUserInputPanel({
|
||||
meta, onReply, responded,
|
||||
}: TaskUserInputPanelProps) {
|
||||
const [reply, setReply] = useState('')
|
||||
const [answers, setAnswers] = useState<Record<string, QuestionState>>({})
|
||||
const isResponded = responded
|
||||
|
||||
const title = String(meta.work_item_projection_title ?? meta.work_item_projection_id ?? 'Input Needed').trim() || 'Input Needed'
|
||||
const summary = String(meta.summary ?? '').trim()
|
||||
const prompt = String(meta.prompt ?? '').trim()
|
||||
const contextNote = String(meta.context_note ?? '').trim()
|
||||
const resumeHint = String(meta.resume_hint ?? '').trim()
|
||||
const questions = useMemo(
|
||||
() => (meta.questions ?? []).map((item) => String(item).trim()).filter(Boolean),
|
||||
[meta.questions],
|
||||
)
|
||||
const inputQuestions = useMemo(
|
||||
() => (meta.input_questions ?? [])
|
||||
.map((item, index) => normalizeQuestion(item, index))
|
||||
.filter((item): item is TaskUserInputQuestion => item !== null),
|
||||
[meta.input_questions],
|
||||
)
|
||||
const usesChoiceMode = inputQuestions.some((question) => (question.options ?? []).length > 0)
|
||||
const requiredFields = useMemo(
|
||||
() => (meta.required_fields ?? []).map((item) => String(item).trim()).filter(Boolean),
|
||||
[meta.required_fields],
|
||||
)
|
||||
const activeSubagents = useMemo(
|
||||
() => (meta.active_subagents ?? []).filter((item) => !!item && typeof item === 'object'),
|
||||
[meta.active_subagents],
|
||||
)
|
||||
const permissionRequests = useMemo(
|
||||
() => (meta.permission_requests ?? []).filter((item) => !!item && typeof item === 'object'),
|
||||
[meta.permission_requests],
|
||||
)
|
||||
const worktreePath = String(meta.worktree_path ?? '').trim()
|
||||
const requestingRoleId = String(meta.requesting_role_id ?? '').trim()
|
||||
const requestingTaskId = String(meta.requesting_task_id ?? '').trim()
|
||||
const requestingWorkItemId = String(meta.requesting_work_item_id ?? '').trim()
|
||||
const seatId = String(meta.seat_id ?? '').trim()
|
||||
const hasRequesterState = !!requestingRoleId || !!requestingTaskId || !!requestingWorkItemId || !!seatId
|
||||
const hasRuntimeState = hasRequesterState || activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath
|
||||
|
||||
const setSelectedOption = useCallback((questionId: string, optionId: string) => {
|
||||
setAnswers((current) => ({
|
||||
...current,
|
||||
[questionId]: {
|
||||
freeformText: current[questionId]?.freeformText ?? '',
|
||||
selectedOptionId: optionId,
|
||||
},
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const setFreeformAnswer = useCallback((questionId: string, value: string) => {
|
||||
setAnswers((current) => ({
|
||||
...current,
|
||||
[questionId]: {
|
||||
selectedOptionId: current[questionId]?.selectedOptionId,
|
||||
freeformText: value,
|
||||
},
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const questionComplete = useCallback((question: TaskUserInputQuestion) => {
|
||||
if (question.required === false) return true
|
||||
const state = answers[question.id]
|
||||
const selected = state?.selectedOptionId
|
||||
if (selected && selected !== OTHER_OPTION_ID) return true
|
||||
if (question.allow_freeform !== false && cleanText(state?.freeformText)) return true
|
||||
return false
|
||||
}, [answers])
|
||||
|
||||
const canSubmitStructured = usesChoiceMode && inputQuestions.every(questionComplete)
|
||||
|
||||
const handleSubmitLegacy = useCallback(() => {
|
||||
const text = reply.trim()
|
||||
if (isResponded || !text) return
|
||||
onReply(text)
|
||||
}, [isResponded, onReply, reply])
|
||||
|
||||
const handleSubmitStructured = useCallback(() => {
|
||||
if (isResponded || !canSubmitStructured) return
|
||||
const answerMetadata: Record<string, TaskUserInputAnswer> = {}
|
||||
const lines: string[] = []
|
||||
inputQuestions.forEach((question) => {
|
||||
const state = answers[question.id] ?? { freeformText: '' }
|
||||
const selectedOption = (question.options ?? []).find((option) => option.id === state.selectedOptionId)
|
||||
const freeformText = cleanText(state.freeformText)
|
||||
const label = selectedOption?.label ?? ''
|
||||
const answerText = [label, freeformText].filter(Boolean).join('; ')
|
||||
answerMetadata[question.id] = {
|
||||
question_id: question.id,
|
||||
question: question.question,
|
||||
...(selectedOption ? {
|
||||
selected_option_id: selectedOption.id,
|
||||
selected_label: selectedOption.label,
|
||||
} : {}),
|
||||
...(freeformText ? { freeform_text: freeformText } : {}),
|
||||
answer_text: answerText,
|
||||
}
|
||||
const displayQuestion = cleanText(question.header) || cleanText(question.question) || question.id
|
||||
lines.push(`- ${displayQuestion}: ${answerText || '(no answer)'}`)
|
||||
})
|
||||
onReply(lines.join('\n'), { user_input_answers: answerMetadata })
|
||||
}, [answers, canSubmitStructured, inputQuestions, isResponded, onReply])
|
||||
|
||||
return (
|
||||
<div className="ckpt-panel ckpt-user-input">
|
||||
<div className="ckpt-header">
|
||||
<div className="ckpt-icon ckpt-icon-user-input">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 3.5h10v6H6.5L3 13V3.5Z" />
|
||||
<path d="M5.5 6h5" />
|
||||
<path d="M5.5 8h3.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ckpt-title">{title}</div>
|
||||
<span className="ckpt-badge ckpt-badge-scope">awaiting input</span>
|
||||
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="ckpt-section">
|
||||
<div className="ckpt-section-title">Summary</div>
|
||||
<MarkdownBody content={summary} className="ckpt-markdown" />
|
||||
</div>
|
||||
)}
|
||||
{prompt && (
|
||||
<div className="ckpt-section">
|
||||
<div className="ckpt-section-title">Request</div>
|
||||
<MarkdownBody content={prompt} className="ckpt-markdown" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usesChoiceMode ? (
|
||||
<div className="ckpt-section">
|
||||
<div className="ckpt-section-title">Questions</div>
|
||||
<div className="ckpt-choice-list">
|
||||
{inputQuestions.map((question) => {
|
||||
const state = answers[question.id] ?? { freeformText: '' }
|
||||
const options = question.options ?? []
|
||||
const showOther = question.allow_freeform !== false
|
||||
const showOtherInput = showOther && (options.length === 0 || state.selectedOptionId === OTHER_OPTION_ID)
|
||||
return (
|
||||
<div className="ckpt-choice-question" key={question.id}>
|
||||
{question.header && <div className="ckpt-question-header">{question.header}</div>}
|
||||
<MarkdownBody content={question.question} className="ckpt-markdown" />
|
||||
{options.length > 0 && (
|
||||
<div className="ckpt-choice-grid">
|
||||
{options.map((option, optionIndex) => {
|
||||
const selected = state.selectedOptionId === option.id
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
className={`ckpt-choice-option${selected ? ' is-selected' : ''}`}
|
||||
onClick={() => setSelectedOption(question.id, option.id)}
|
||||
>
|
||||
<span className="ckpt-choice-letter">{OPTION_LETTERS[optionIndex] ?? '?'}</span>
|
||||
<span className="ckpt-choice-copy">
|
||||
<span className="ckpt-choice-label">{option.label}</span>
|
||||
{option.description && <span className="ckpt-choice-desc">{option.description}</span>}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{showOther && (
|
||||
<button
|
||||
className={`ckpt-choice-option${state.selectedOptionId === OTHER_OPTION_ID ? ' is-selected' : ''}`}
|
||||
onClick={() => setSelectedOption(question.id, OTHER_OPTION_ID)}
|
||||
>
|
||||
<span className="ckpt-choice-letter">D</span>
|
||||
<span className="ckpt-choice-copy">
|
||||
<span className="ckpt-choice-label">Other</span>
|
||||
<span className="ckpt-choice-desc">Enter a custom answer</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showOtherInput && (
|
||||
<textarea
|
||||
className="ckpt-feedback-input ckpt-other-field"
|
||||
placeholder="Type your answer..."
|
||||
value={state.freeformText}
|
||||
onChange={(event) => setFreeformAnswer(question.id, event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : questions.length > 0 && (
|
||||
<div className="ckpt-section">
|
||||
<div className="ckpt-section-title">Questions</div>
|
||||
<ul className="ckpt-task-list">
|
||||
{questions.map((question) => <li key={question}>{question}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requiredFields.length > 0 && (
|
||||
<div className="ckpt-section">
|
||||
<div className="ckpt-section-title">Required Fields</div>
|
||||
<div className="ckpt-task-tags">
|
||||
{requiredFields.map((field) => <span key={field} className="ckpt-field-tag">{field}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextNote && (
|
||||
<div className="ckpt-section">
|
||||
<div className="ckpt-section-title">Context</div>
|
||||
<MarkdownBody content={contextNote} className="ckpt-markdown ckpt-markdown-muted" />
|
||||
</div>
|
||||
)}
|
||||
{resumeHint && <div className="ckpt-escalation-hint">{resumeHint}</div>}
|
||||
|
||||
{hasRuntimeState && (
|
||||
<details className="ckpt-runtime-details">
|
||||
<summary>Runtime State</summary>
|
||||
<div className="ckpt-runtime-body">
|
||||
{requestingRoleId && <div>Requester: <code>{requestingRoleId}</code></div>}
|
||||
{requestingWorkItemId && <div>Work item: <code>{requestingWorkItemId}</code></div>}
|
||||
{requestingTaskId && <div>Task: <code>{requestingTaskId}</code></div>}
|
||||
{seatId && <div>Seat: <code>{seatId}</code></div>}
|
||||
{worktreePath && <div>Worktree: <code>{worktreePath}</code></div>}
|
||||
{activeSubagents.length > 0 && <div>Active subagents: {activeSubagents.length}</div>}
|
||||
{permissionRequests.length > 0 && <div>Pending permission records: {permissionRequests.length}</div>}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{!isResponded && (
|
||||
<div className="ckpt-feedback-area">
|
||||
{usesChoiceMode ? null : (
|
||||
<textarea
|
||||
className="ckpt-feedback-input"
|
||||
placeholder="Reply with the missing input to continue..."
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
<div className="ckpt-feedback-btns">
|
||||
<button
|
||||
className="ckpt-btn ckpt-btn-approve"
|
||||
onClick={usesChoiceMode ? handleSubmitStructured : handleSubmitLegacy}
|
||||
disabled={usesChoiceMode ? !canSubmitStructured : !reply.trim()}
|
||||
>
|
||||
Send Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
|
||||
import { WorkItemProgressCard } from './WorkItemProgressCard'
|
||||
import type { RoleWorkItemSummary } from '../types/kanban'
|
||||
|
||||
const currentOwnerRoleWorkItems: Record<string, RoleWorkItemSummary> = {
|
||||
cto: {
|
||||
roleKey: 'cto',
|
||||
roleId: 'cto',
|
||||
roleName: 'CTO',
|
||||
runtimeStatus: 'idle',
|
||||
aggregatedStatus: 'waiting',
|
||||
workItems: [
|
||||
{
|
||||
workItemId: 'wi-review',
|
||||
phase: 'awaiting_manager_review',
|
||||
kanbanColumn: 'in-review',
|
||||
title: 'Implement summary',
|
||||
kind: 'execute',
|
||||
isReviewTarget: true,
|
||||
executorRoleId: 'engineer',
|
||||
reviewerRoleId: 'cto',
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
executionTurnId: 'runtime-task-1',
|
||||
progressLog: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const executorRoleWorkItems: Record<string, RoleWorkItemSummary> = {
|
||||
engineer: {
|
||||
roleKey: 'engineer',
|
||||
roleId: 'engineer',
|
||||
roleName: 'Engineer',
|
||||
runtimeStatus: 'idle',
|
||||
aggregatedStatus: 'waiting',
|
||||
workItems: [
|
||||
{
|
||||
workItemId: 'wi-review',
|
||||
phase: 'awaiting_manager_review',
|
||||
kanbanColumn: 'in-review',
|
||||
title: 'Implement summary',
|
||||
kind: 'execute',
|
||||
isReviewTarget: true,
|
||||
executorRoleId: 'engineer',
|
||||
reviewerRoleId: 'cto',
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
executionTurnId: 'runtime-task-1',
|
||||
progressLog: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const executorMarkup = renderToStaticMarkup(
|
||||
React.createElement(WorkItemProgressCard, {
|
||||
workItemLog: [],
|
||||
roleWorkItems: currentOwnerRoleWorkItems,
|
||||
executorRoleWorkItems,
|
||||
isCompanyRuntime: true,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.match(executorMarkup, /Execution Progress/)
|
||||
assert.match(executorMarkup, /Engineer/)
|
||||
assert.doesNotMatch(executorMarkup, /CTO/)
|
||||
|
||||
const fallbackMarkup = renderToStaticMarkup(
|
||||
React.createElement(WorkItemProgressCard, {
|
||||
workItemLog: [],
|
||||
roleWorkItems: currentOwnerRoleWorkItems,
|
||||
isCompanyRuntime: true,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.match(fallbackMarkup, /CTO/)
|
||||
assert.doesNotMatch(fallbackMarkup, /Engineer/)
|
||||
|
||||
console.log('WorkItemProgressCard.test.tsx: OK (executor rollup preferred with current-owner fallback)')
|
||||
@@ -0,0 +1,576 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
AgentAnimStatus,
|
||||
ProgressEntry,
|
||||
RoleAggregatedStatus,
|
||||
RoleWorkItemActivitySection,
|
||||
RoleWorkItemRow,
|
||||
RoleWorkItemSummary,
|
||||
Session,
|
||||
WorkItemProgressEntry,
|
||||
} from '../types/kanban'
|
||||
import { IconWorkItem } from './SvgIcons'
|
||||
import { getWorkItemAssignmentLabel, humanizeWorkItemRoleId } from '../lib/workItemIdentity'
|
||||
import { getExecutionTurnId } from '../lib/workItemRuntimeIds'
|
||||
|
||||
interface WorkItemProgressCardProps {
|
||||
workItemLog: WorkItemProgressEntry[]
|
||||
/**
|
||||
* Per-role DelegationWorkItem rollup. When present, drives the panel
|
||||
* (1 row = 1 work item; review work items appear under reviewer role).
|
||||
* Falls back to ``childSessions`` derivation only when undefined / empty
|
||||
* (legacy non-company-mode runs).
|
||||
* Source: ``snapshot_builder._build_role_work_items_for_session``.
|
||||
*/
|
||||
roleWorkItems?: Record<string, RoleWorkItemSummary>
|
||||
/** Display-only executor-role rollup. Company/org Execution Progress
|
||||
* prefers this so worker chips remain visible while awaiting review. */
|
||||
executorRoleWorkItems?: Record<string, RoleWorkItemSummary>
|
||||
/** Legacy session-based source. Kept for non-company-mode and for the
|
||||
* case where a primary session has no ``roleWorkItems`` payload yet. */
|
||||
childSessions?: Session[]
|
||||
/** Company/org mode uses work-item rollups as the only role source. */
|
||||
isCompanyRuntime?: boolean
|
||||
onWorkItemClick?: (executionTurnId: string) => void
|
||||
}
|
||||
|
||||
type WorkItemStatus = 'active' | 'done' | 'failed' | 'waiting' | 'pending'
|
||||
|
||||
interface WorkItemInfo {
|
||||
projectionId: string
|
||||
title: string
|
||||
roleName?: string
|
||||
status: WorkItemStatus
|
||||
executionTurnId?: string
|
||||
}
|
||||
|
||||
interface RoleTurnInfo {
|
||||
executionTurnId: string
|
||||
title: string
|
||||
status: WorkItemStatus
|
||||
statusLabel: string // kanban column label (To do / In progress / In review / Done)
|
||||
columnId: string // todo | in_progress | in_review | done
|
||||
updatedAt: number
|
||||
/** Set when the row was derived from a DelegationWorkItem rather than
|
||||
* a runtime Session. Used as the React key and for inline activity
|
||||
* expansion. */
|
||||
workItemId?: string
|
||||
/** Per-row activity entries (already filtered by work-item projection
|
||||
* on the backend). Empty for session-derived rows. */
|
||||
progressLog?: ProgressEntry[]
|
||||
activitySections?: RoleWorkItemActivitySection[]
|
||||
/** True when this row sits under the reviewer because the work item is
|
||||
* in an ``in_review`` phase. */
|
||||
isReviewTarget?: boolean
|
||||
}
|
||||
|
||||
interface RoleSummaryInfo {
|
||||
roleKey: string
|
||||
executionTurnId: string // default click target = latest turn
|
||||
title: string // role display name
|
||||
status: WorkItemStatus // aggregated across all turns
|
||||
statusLabel: string // kanban label for aggregated status
|
||||
executionAgent?: string
|
||||
roleName?: string
|
||||
updatedAt: number // most recent turn's updatedAt
|
||||
turns: RoleTurnInfo[] // chronological ASC (oldest first, newest last)
|
||||
/** Live tracker state (only set for work-item-driven summaries). When
|
||||
* ``reflecting`` / ``tool_active`` the chip shows the orange pulse;
|
||||
* otherwise the chip's colour is governed by ``status`` alone. */
|
||||
runtimeStatus?: AgentAnimStatus
|
||||
}
|
||||
|
||||
/** Kanban-column labels used both on the role's aggregate chip and on
|
||||
* each per-turn row. Matches the column headers shown on the kanban
|
||||
* board so the vocabulary stays consistent across views. */
|
||||
const COLUMN_LABELS: Record<string, string> = {
|
||||
todo: 'To do',
|
||||
in_progress: 'In progress',
|
||||
in_review: 'In review',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
/** Map the WorkItemStatus derived from a Session back to the kanban column
|
||||
* it sits in. Keeps the label logic in one place rather than diverging
|
||||
* between the role chip and the per-turn row. */
|
||||
function workItemStatusToColumnId(status: WorkItemStatus, fallbackColumnId?: string): string {
|
||||
if (fallbackColumnId && fallbackColumnId in COLUMN_LABELS) return fallbackColumnId
|
||||
switch (status) {
|
||||
case 'active': return 'in_progress'
|
||||
case 'waiting': return 'in_review'
|
||||
case 'done': return 'done'
|
||||
case 'failed': return 'done'
|
||||
case 'pending': return 'todo'
|
||||
default: return 'todo'
|
||||
}
|
||||
}
|
||||
|
||||
function labelForColumnId(columnId: string): string {
|
||||
return COLUMN_LABELS[columnId] ?? COLUMN_LABELS.todo
|
||||
}
|
||||
|
||||
/** Priority used to aggregate status across a role's turns so the main
|
||||
* chip reflects "any turn still in flight" rather than whatever was
|
||||
* updated last. */
|
||||
const STATUS_AGGREGATE_PRIORITY: Record<WorkItemStatus, number> = {
|
||||
active: 0,
|
||||
waiting: 1,
|
||||
failed: 2,
|
||||
done: 3,
|
||||
pending: 4,
|
||||
}
|
||||
|
||||
function aggregateStatus(statuses: WorkItemStatus[]): WorkItemStatus {
|
||||
if (statuses.length === 0) return 'pending'
|
||||
let best: WorkItemStatus = statuses[0]
|
||||
let bestRank = STATUS_AGGREGATE_PRIORITY[best] ?? 99
|
||||
for (const s of statuses) {
|
||||
const rank = STATUS_AGGREGATE_PRIORITY[s] ?? 99
|
||||
if (rank < bestRank) {
|
||||
best = s
|
||||
bestRank = rank
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
const EXECUTION_AGENT_LABELS: Record<string, string> = {
|
||||
native: 'Native',
|
||||
codex: 'Codex',
|
||||
claude_code: 'Claude Code',
|
||||
cursor: 'Cursor',
|
||||
opencode: 'OpenCode',
|
||||
}
|
||||
|
||||
/** Check if a string looks like a UUID or long hex id */
|
||||
function isUuidLike(s: string): boolean {
|
||||
return s.length > 12 && /^[0-9a-f-]+$/i.test(s.replace(/_/g, ''))
|
||||
}
|
||||
|
||||
function trimString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
const text = trimString(value)
|
||||
if (text) return text
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatExecutionAgent(value?: string): string {
|
||||
const normalized = trimString(value)
|
||||
if (!normalized) return ''
|
||||
return EXECUTION_AGENT_LABELS[normalized] ?? humanizeWorkItemRoleId(normalized)
|
||||
}
|
||||
|
||||
function renderProjectionIcon(status: WorkItemStatus): ReactNode {
|
||||
if (status === 'done') return <span className="wi-projection-icon">✓</span>
|
||||
if (status === 'active') {
|
||||
// Always pulse when the role is "active". Active aggregates two cases
|
||||
// — (a) tracker is reflecting/tool_active right now, or (b) at least
|
||||
// one work item is in an in-progress phase. Both read as "this role
|
||||
// is working" to the user, so both should breathe. The earlier
|
||||
// refactor that gated pulse on runtime tracker only made finished-but-
|
||||
// running phases look static, which the user reported as a regression.
|
||||
return <span className="wi-projection-icon wi-projection-pulse">●</span>
|
||||
}
|
||||
if (status === 'failed') return <span className="wi-projection-icon">✗</span>
|
||||
if (status === 'waiting') return <span className="wi-projection-icon">●</span>
|
||||
return null
|
||||
}
|
||||
|
||||
const AGGREGATED_TO_WORK_ITEM_STATUS: Record<RoleAggregatedStatus, WorkItemStatus> = {
|
||||
active: 'active',
|
||||
waiting: 'waiting',
|
||||
pending: 'pending',
|
||||
done: 'done',
|
||||
failed: 'failed',
|
||||
}
|
||||
|
||||
/** Backend ``kanban_column`` returns hyphenated ids (``in-progress`` /
|
||||
* ``in-review``); this card's CSS classes use underscored forms. The
|
||||
* conversion lives here so the rest of the file keeps speaking one
|
||||
* vocabulary. */
|
||||
function normalizeColumnId(columnId: string): string {
|
||||
if (!columnId) return 'todo'
|
||||
if (columnId === 'in-progress') return 'in_progress'
|
||||
if (columnId === 'in-review') return 'in_review'
|
||||
return columnId
|
||||
}
|
||||
|
||||
/** Per-row status used for icon + chip colour. Mirrors the backend
|
||||
* phase → column mapping (``opc/layer2_organization/phase.py``) but
|
||||
* reduced to the 5 UI states the chip renders. */
|
||||
function phaseAggregateForRow(phase: string): WorkItemStatus {
|
||||
switch (phase) {
|
||||
case 'running':
|
||||
case 'waiting_for_peer':
|
||||
case 'waiting_for_children':
|
||||
case 'paused':
|
||||
case 'needs_attention':
|
||||
return 'active'
|
||||
case 'awaiting_manager_review':
|
||||
case 'awaiting_human':
|
||||
case 'queued':
|
||||
case 'ready':
|
||||
case 'ready_for_rework':
|
||||
case 'waiting_dependencies':
|
||||
return 'waiting'
|
||||
case 'approved':
|
||||
return 'done'
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return 'failed'
|
||||
default:
|
||||
return 'pending'
|
||||
}
|
||||
}
|
||||
|
||||
function statusFromSession(session: Session): WorkItemStatus {
|
||||
const status = trimString(session.status).toLowerCase()
|
||||
if (status === 'done' || status === 'delivered') return 'done'
|
||||
if (status === 'failed' || status === 'cancelled') return 'failed'
|
||||
if (
|
||||
status === 'awaiting_peer'
|
||||
|| status === 'awaiting_manager_review'
|
||||
|| status === 'awaiting_human'
|
||||
|| status === 'awaiting_review'
|
||||
|| status === 'blocked'
|
||||
|| status === 'paused'
|
||||
|| status === 'awaiting_owner'
|
||||
) {
|
||||
return 'waiting'
|
||||
}
|
||||
if (status === 'running' || status === 'deliverable' || status === 'active' || status === 'ready') return 'active'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
export function WorkItemProgressCard({
|
||||
workItemLog,
|
||||
roleWorkItems,
|
||||
executorRoleWorkItems,
|
||||
childSessions,
|
||||
isCompanyRuntime = false,
|
||||
onWorkItemClick,
|
||||
}: WorkItemProgressCardProps) {
|
||||
// Build child session lookup for enriching display names
|
||||
const sessionByTaskId = useMemo(() => {
|
||||
const map = new Map<string, Session>()
|
||||
for (const s of childSessions ?? []) {
|
||||
map.set(s.taskId, s)
|
||||
const executionTurnId = getExecutionTurnId(s)
|
||||
if (executionTurnId) map.set(executionTurnId, s)
|
||||
}
|
||||
return map
|
||||
}, [childSessions])
|
||||
|
||||
// Build ordered work-item list from log entries — used
|
||||
// only as a fallback when no child sessions exist yet, because the
|
||||
// work-item log emits one projection id per runtime turn which means a single
|
||||
// role can appear multiple times (execute + each review + attention).
|
||||
// For kanban-push runs we'd rather derive the pipeline from
|
||||
// roleSummaries below, which already groups by workItemRoleId.
|
||||
const workItemLogWorkItems = useMemo(() => {
|
||||
const map = new Map<string, WorkItemInfo>()
|
||||
const order: string[] = []
|
||||
|
||||
for (const entry of workItemLog) {
|
||||
const entryExecutionTurnId = getExecutionTurnId(entry)
|
||||
const projectionId = entry.workItemProjectionId ?? entryExecutionTurnId ?? ''
|
||||
if (!projectionId) continue
|
||||
|
||||
if (!map.has(projectionId)) {
|
||||
order.push(projectionId)
|
||||
// Determine display title — prefer roleName, avoid UUID fallback
|
||||
const rawTitle = entry.workItemProjectionTitle ?? projectionId
|
||||
const title = entry.roleName || (!isUuidLike(rawTitle) ? rawTitle : 'Agent')
|
||||
map.set(projectionId, {
|
||||
projectionId,
|
||||
title,
|
||||
roleName: entry.roleName,
|
||||
status: 'pending',
|
||||
executionTurnId: entryExecutionTurnId,
|
||||
})
|
||||
}
|
||||
|
||||
const info = map.get(projectionId)!
|
||||
// Update with latest role/title info
|
||||
if (entry.roleName) {
|
||||
info.roleName = entry.roleName
|
||||
info.title = entry.roleName
|
||||
} else if (entry.workItemProjectionTitle && !isUuidLike(entry.workItemProjectionTitle)) {
|
||||
info.title = entry.workItemProjectionTitle
|
||||
}
|
||||
if (entryExecutionTurnId) info.executionTurnId = entryExecutionTurnId
|
||||
|
||||
// Update status based on event type
|
||||
switch (entry.type) {
|
||||
case 'work_item_started': info.status = 'active'; break
|
||||
case 'gate_approved': info.status = 'done'; break
|
||||
case 'gate_rejected': info.status = 'active'; break
|
||||
case 'awaiting_manager_review':
|
||||
case 'awaiting_human':
|
||||
case 'awaiting_review':
|
||||
case 'awaiting_peer': info.status = 'waiting'; break
|
||||
case 'work_item_failed':
|
||||
case 'deadlock': info.status = 'failed'; break
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich from child sessions
|
||||
for (const info of map.values()) {
|
||||
if (!info.executionTurnId) continue
|
||||
const session = sessionByTaskId.get(info.executionTurnId)
|
||||
if (!session) continue
|
||||
const label = getWorkItemAssignmentLabel(session)
|
||||
if (label) info.title = label
|
||||
if (session.workItemRoleName) info.roleName = session.workItemRoleName
|
||||
if (session.status === 'done') info.status = 'done'
|
||||
else if (session.status === 'failed') info.status = 'failed'
|
||||
}
|
||||
|
||||
return order.map(id => map.get(id)!)
|
||||
}, [workItemLog, sessionByTaskId])
|
||||
|
||||
const taskOrder = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const entry of workItemLog) {
|
||||
const taskId = getExecutionTurnId(entry)
|
||||
if (taskId && !map.has(taskId)) map.set(taskId, map.size)
|
||||
}
|
||||
return map
|
||||
}, [workItemLog])
|
||||
|
||||
// Primary path: drive rows directly from the per-role DelegationWorkItem
|
||||
// rollup the backend ships in ``session.role_work_items``. This is the
|
||||
// fix for "1 row should = 1 work item" — the legacy session-driven
|
||||
// derivation below treats every runtime Task as its own row, which
|
||||
// double-counts rework turns and silently drops queued / review-target
|
||||
// work items. See ``plan/bug-breezy-dragonfly.md`` for the full root-cause
|
||||
// breakdown.
|
||||
const displayRoleWorkItems = executorRoleWorkItems ?? roleWorkItems
|
||||
const roleSummariesFromWorkItems = useMemo<RoleSummaryInfo[]>(() => {
|
||||
if (!displayRoleWorkItems) return []
|
||||
const summaries: RoleSummaryInfo[] = []
|
||||
for (const summary of Object.values(displayRoleWorkItems)) {
|
||||
if (!summary || !Array.isArray(summary.workItems) || summary.workItems.length === 0) continue
|
||||
// Backend already sorts ASC by createdAt; defensive copy + re-sort
|
||||
// here means a prop-mutation upstream can't reorder the rows.
|
||||
const ordered = [...summary.workItems].sort((a, b) => a.createdAt - b.createdAt)
|
||||
const turns: RoleTurnInfo[] = ordered.map((row: RoleWorkItemRow) => {
|
||||
const columnId = normalizeColumnId(row.kanbanColumn)
|
||||
const phaseToStatus = phaseAggregateForRow(row.phase)
|
||||
return {
|
||||
executionTurnId: row.executionTurnId ?? '',
|
||||
title: trimString(row.title) || trimString(row.executorRoleName) || trimString(row.executorRoleId) || 'Work item',
|
||||
status: phaseToStatus,
|
||||
statusLabel: labelForColumnId(columnId),
|
||||
columnId,
|
||||
updatedAt: row.updatedAt,
|
||||
workItemId: row.workItemId,
|
||||
progressLog: row.progressLog,
|
||||
activitySections: row.activitySections ?? [],
|
||||
isReviewTarget: row.isReviewTarget,
|
||||
}
|
||||
})
|
||||
const aggregatedStatus = AGGREGATED_TO_WORK_ITEM_STATUS[summary.aggregatedStatus] ?? 'pending'
|
||||
const aggregatedColumnId = workItemStatusToColumnId(aggregatedStatus)
|
||||
const latest = turns[turns.length - 1]
|
||||
// Chip click target: prefer the latest turn's runtime task. When the
|
||||
// last work item has no execution turn yet (queued / never dispatched),
|
||||
// walk back to find the most recent dispatched turn.
|
||||
const fallbackExecutionTurnId = [...turns].reverse().find(t => !!t.executionTurnId)?.executionTurnId ?? ''
|
||||
summaries.push({
|
||||
roleKey: summary.roleKey,
|
||||
executionTurnId: latest.executionTurnId || fallbackExecutionTurnId,
|
||||
title: summary.roleName || summary.roleId,
|
||||
status: aggregatedStatus,
|
||||
statusLabel: labelForColumnId(aggregatedColumnId),
|
||||
roleName: summary.roleName,
|
||||
updatedAt: turns.reduce((max, t) => Math.max(max, t.updatedAt), 0),
|
||||
turns,
|
||||
runtimeStatus: summary.runtimeStatus,
|
||||
})
|
||||
}
|
||||
// Stable order: roles first appearing in time go left.
|
||||
summaries.sort((a, b) => {
|
||||
const aFirst = a.turns[0]?.updatedAt ?? Number.POSITIVE_INFINITY
|
||||
const bFirst = b.turns[0]?.updatedAt ?? Number.POSITIVE_INFINITY
|
||||
return aFirst - bFirst
|
||||
})
|
||||
return summaries
|
||||
}, [displayRoleWorkItems])
|
||||
|
||||
const roleSummariesFromSessions = useMemo<RoleSummaryInfo[]>(() => {
|
||||
const sessions = [...(childSessions ?? [])]
|
||||
if (sessions.length === 0) return []
|
||||
|
||||
// Group by workItemRoleId (fall back to the assignee when missing
|
||||
// so legacy non-company-mode sessions still get a stable key).
|
||||
const groups = new Map<string, Session[]>()
|
||||
const groupOrder: string[] = []
|
||||
for (const session of sessions) {
|
||||
const roleKey = firstNonEmpty(
|
||||
session.workItemRoleId,
|
||||
session.assigneeIds[0],
|
||||
session.taskId, // last-resort singleton
|
||||
)
|
||||
if (!groups.has(roleKey)) {
|
||||
groups.set(roleKey, [])
|
||||
groupOrder.push(roleKey)
|
||||
}
|
||||
groups.get(roleKey)!.push(session)
|
||||
}
|
||||
|
||||
// Sort role groups by the earliest work-item-log/updatedAt position
|
||||
// of any turn in the group so the bar reads left-to-right in the
|
||||
// order roles first appeared.
|
||||
groupOrder.sort((leftKey, rightKey) => {
|
||||
const leftSessions = groups.get(leftKey) ?? []
|
||||
const rightSessions = groups.get(rightKey) ?? []
|
||||
const leftOrder = Math.min(
|
||||
...leftSessions.map(s => taskOrder.get(getExecutionTurnId(s) || s.taskId) ?? Number.POSITIVE_INFINITY),
|
||||
)
|
||||
const rightOrder = Math.min(
|
||||
...rightSessions.map(s => taskOrder.get(getExecutionTurnId(s) || s.taskId) ?? Number.POSITIVE_INFINITY),
|
||||
)
|
||||
if (leftOrder !== rightOrder) return leftOrder - rightOrder
|
||||
const leftUpdated = Math.min(...leftSessions.map(s => s.updatedAt))
|
||||
const rightUpdated = Math.min(...rightSessions.map(s => s.updatedAt))
|
||||
return leftUpdated - rightUpdated
|
||||
})
|
||||
|
||||
const summaries: RoleSummaryInfo[] = []
|
||||
for (const roleKey of groupOrder) {
|
||||
const roleSessions = groups.get(roleKey) ?? []
|
||||
if (roleSessions.length === 0) continue
|
||||
|
||||
// Build one turn entry per session, sorted chronologically (ASC).
|
||||
// Per design: "a 最前面, c 最后面" — first-happened goes first.
|
||||
// Each turn shows only its kanban column (To do / In progress /
|
||||
// In review / Done). No synthetic "kind" classification.
|
||||
const turns: RoleTurnInfo[] = roleSessions
|
||||
.slice()
|
||||
.sort((a, b) => a.updatedAt - b.updatedAt)
|
||||
.map((session) => {
|
||||
const status = statusFromSession(session)
|
||||
const executionTurnId = getExecutionTurnId(session)
|
||||
const columnId = workItemStatusToColumnId(
|
||||
status,
|
||||
String((session as Session & { columnId?: string }).columnId ?? '').trim() || undefined,
|
||||
)
|
||||
return {
|
||||
executionTurnId: executionTurnId || session.taskId,
|
||||
title: firstNonEmpty(
|
||||
getWorkItemAssignmentLabel(session),
|
||||
session.title,
|
||||
session.workItemRoleName,
|
||||
session.workItemRoleId,
|
||||
'Turn',
|
||||
),
|
||||
status,
|
||||
statusLabel: labelForColumnId(columnId),
|
||||
columnId,
|
||||
updatedAt: session.updatedAt,
|
||||
}
|
||||
})
|
||||
|
||||
// The newest turn is the one the user probably wants to open when
|
||||
// they click the main chip — "what is this role doing right now"
|
||||
// beats "what did this role do first".
|
||||
const latest = turns[turns.length - 1]
|
||||
|
||||
const aggregatedStatus = aggregateStatus(turns.map(t => t.status))
|
||||
const aggregatedColumnId = workItemStatusToColumnId(aggregatedStatus)
|
||||
|
||||
// Role name beats task title on the main chip so the chip always
|
||||
// reads as "this role" regardless of which of its turns is loaded
|
||||
// as latest.
|
||||
const representative = roleSessions[0]
|
||||
const title = firstNonEmpty(
|
||||
representative.workItemRoleName,
|
||||
representative.workItemRoleId,
|
||||
representative.assigneeIds[0],
|
||||
getWorkItemAssignmentLabel(representative),
|
||||
representative.title,
|
||||
'Agent',
|
||||
)
|
||||
|
||||
summaries.push({
|
||||
roleKey,
|
||||
executionTurnId: latest.executionTurnId,
|
||||
title,
|
||||
status: aggregatedStatus,
|
||||
statusLabel: labelForColumnId(aggregatedColumnId),
|
||||
executionAgent: formatExecutionAgent(
|
||||
representative.selectedExecutionAgent ?? representative.preferredAgent,
|
||||
),
|
||||
roleName: representative.workItemRoleName,
|
||||
updatedAt: Math.max(...roleSessions.map(s => s.updatedAt)),
|
||||
turns,
|
||||
})
|
||||
}
|
||||
|
||||
return summaries
|
||||
}, [childSessions, taskOrder])
|
||||
|
||||
// Pick the work-item-driven summaries when present. In company/org
|
||||
// mode, runtime sessions are only audit targets; they must not synthesize
|
||||
// role rows because that recreates the role/session mix-up seen in new37.
|
||||
const roleSummaries = useMemo<RoleSummaryInfo[]>(() => (
|
||||
roleSummariesFromWorkItems.length > 0
|
||||
? roleSummariesFromWorkItems
|
||||
: (isCompanyRuntime ? [] : roleSummariesFromSessions)
|
||||
), [isCompanyRuntime, roleSummariesFromWorkItems, roleSummariesFromSessions])
|
||||
|
||||
// Top-level pipeline: one chip per role, derived from roleSummaries
|
||||
// when available (kanban-push runs). Fall back to work-item-log work items
|
||||
// when child sessions haven't been serialized yet.
|
||||
const workItems = useMemo<WorkItemInfo[]>(() => {
|
||||
if (roleSummaries.length > 0) {
|
||||
return roleSummaries.map(role => ({
|
||||
projectionId: role.roleKey,
|
||||
title: role.title,
|
||||
roleName: role.roleName,
|
||||
status: role.status,
|
||||
executionTurnId: role.executionTurnId,
|
||||
}))
|
||||
}
|
||||
return isCompanyRuntime ? [] : workItemLogWorkItems
|
||||
}, [isCompanyRuntime, roleSummaries, workItemLogWorkItems])
|
||||
|
||||
if (isCompanyRuntime && roleSummaries.length === 0) return null
|
||||
if (workItemLog.length === 0 && workItems.length === 0 && roleSummaries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="wi-progress-card">
|
||||
<div className="wi-progress-header">
|
||||
<IconWorkItem />
|
||||
<span>Execution Progress</span>
|
||||
</div>
|
||||
|
||||
{workItems.length > 0 && (
|
||||
<div className="wi-progress-pipeline">
|
||||
{workItems.map((workItem, i) => (
|
||||
<div key={workItem.projectionId} className="wi-projection-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`wi-projection-chip wi-projection-${workItem.status}`}
|
||||
onClick={() => onWorkItemClick?.(workItem.executionTurnId || '')}
|
||||
title={`Open Runtime Session${workItem.roleName ? `: ${workItem.title} (${workItem.roleName})` : `: ${workItem.title}`}`}
|
||||
>
|
||||
<span className="wi-projection-label">{workItem.title}</span>
|
||||
{renderProjectionIcon(workItem.status)}
|
||||
</button>
|
||||
{i < workItems.length - 1 && <span className="wi-projection-connector">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import type { ChatMessage } from '../types/chat'
|
||||
import {
|
||||
analyzeCheckpointMessages,
|
||||
checkpointReplyMetadataForComposer,
|
||||
isCheckpointCardMetadata,
|
||||
isCheckpointType,
|
||||
toCheckpointReplyMetadata,
|
||||
} from './checkpointUtils'
|
||||
|
||||
const checkpoint: ChatMessage = {
|
||||
id: 'msg-checkpoint',
|
||||
channelId: 'session:task-1',
|
||||
sender: 'assistant',
|
||||
senderName: 'OPC',
|
||||
content: 'Please review the delivery.',
|
||||
timestamp: 1,
|
||||
mentions: [],
|
||||
metadata: {
|
||||
checkpoint_type: 'company_delivery_feedback',
|
||||
checkpoint_id: 'cp-delivery',
|
||||
work_item_projection_title: 'CEO Delivery',
|
||||
feedback_scope: 'final',
|
||||
},
|
||||
}
|
||||
|
||||
assert.equal(isCheckpointType('company_delivery_feedback'), true)
|
||||
assert.equal(isCheckpointType('company_staffing_selection'), true)
|
||||
assert.equal(isCheckpointCardMetadata(checkpoint.metadata), true)
|
||||
assert.deepEqual(toCheckpointReplyMetadata(checkpoint.metadata), {
|
||||
response_to_checkpoint_id: 'cp-delivery',
|
||||
response_to_checkpoint_type: 'company_delivery_feedback',
|
||||
response_to_escalation_id: undefined,
|
||||
})
|
||||
|
||||
const pending = analyzeCheckpointMessages([checkpoint])
|
||||
assert.deepEqual([...pending.pendingMessageIds], ['msg-checkpoint'])
|
||||
assert.deepEqual(pending.latestPendingReplyMetadata, {
|
||||
response_to_checkpoint_id: 'cp-delivery',
|
||||
response_to_checkpoint_type: 'company_delivery_feedback',
|
||||
response_to_escalation_id: undefined,
|
||||
})
|
||||
assert.equal(checkpointReplyMetadataForComposer(pending.latestPendingReplyMetadata), undefined)
|
||||
|
||||
const legacySelfEvolutionResult: ChatMessage = {
|
||||
...checkpoint,
|
||||
id: 'msg-self-evolution-result',
|
||||
content: 'Self-evolution finished without writing updates because the agents did not return valid evolution patches.',
|
||||
metadata: {
|
||||
checkpoint_type: 'company_delivery_feedback',
|
||||
checkpoint_id: 'cp-delivery',
|
||||
kind: 'company_self_evolution_result',
|
||||
self_evolution_completed: true,
|
||||
},
|
||||
}
|
||||
assert.equal(isCheckpointCardMetadata(legacySelfEvolutionResult.metadata), false)
|
||||
const ignoredSelfEvolutionResult = analyzeCheckpointMessages([legacySelfEvolutionResult])
|
||||
assert.deepEqual([...ignoredSelfEvolutionResult.pendingMessageIds], [])
|
||||
assert.deepEqual([...ignoredSelfEvolutionResult.respondedMessageIds], [])
|
||||
assert.equal(checkpointReplyMetadataForComposer({
|
||||
response_to_checkpoint_id: 'esc-approval',
|
||||
response_to_checkpoint_type: 'human_escalation',
|
||||
response_to_escalation_id: 'esc-approval',
|
||||
}), undefined)
|
||||
assert.deepEqual(checkpointReplyMetadataForComposer({
|
||||
response_to_checkpoint_id: 'cp-staffing',
|
||||
response_to_checkpoint_type: 'company_staffing_selection',
|
||||
}), {
|
||||
response_to_checkpoint_id: 'cp-staffing',
|
||||
response_to_checkpoint_type: 'company_staffing_selection',
|
||||
})
|
||||
|
||||
const duplicatePending = analyzeCheckpointMessages([
|
||||
checkpoint,
|
||||
{
|
||||
...checkpoint,
|
||||
id: 'msg-checkpoint-duplicate',
|
||||
channelId: 'session:task-2',
|
||||
timestamp: 2,
|
||||
},
|
||||
])
|
||||
assert.deepEqual([...duplicatePending.pendingMessageIds], ['msg-checkpoint'])
|
||||
assert.deepEqual([...duplicatePending.respondedMessageIds], [])
|
||||
assert.deepEqual([...duplicatePending.duplicateMessageIds], ['msg-checkpoint-duplicate'])
|
||||
|
||||
const duplicateResponded = analyzeCheckpointMessages([
|
||||
{
|
||||
...checkpoint,
|
||||
metadata: {
|
||||
...checkpoint.metadata,
|
||||
checkpoint_status: 'responded',
|
||||
},
|
||||
},
|
||||
{
|
||||
...checkpoint,
|
||||
id: 'msg-checkpoint-duplicate',
|
||||
channelId: 'session:task-2',
|
||||
timestamp: 2,
|
||||
metadata: {
|
||||
...checkpoint.metadata,
|
||||
checkpoint_status: 'responded',
|
||||
},
|
||||
},
|
||||
])
|
||||
assert.deepEqual([...duplicateResponded.respondedMessageIds], ['msg-checkpoint'])
|
||||
assert.deepEqual([...duplicateResponded.duplicateMessageIds], ['msg-checkpoint-duplicate'])
|
||||
|
||||
const replyBeforeEngineResolution = analyzeCheckpointMessages([
|
||||
checkpoint,
|
||||
{
|
||||
id: 'msg-user',
|
||||
channelId: 'session:task-1',
|
||||
sender: 'user',
|
||||
senderName: 'You',
|
||||
content: 'Please make one more change.',
|
||||
timestamp: 2,
|
||||
mentions: [],
|
||||
metadata: {
|
||||
response_to_checkpoint_id: 'cp-delivery',
|
||||
response_to_checkpoint_type: 'company_delivery_feedback',
|
||||
},
|
||||
},
|
||||
])
|
||||
assert.deepEqual([...replyBeforeEngineResolution.respondedMessageIds], ['msg-checkpoint'])
|
||||
assert.deepEqual([...replyBeforeEngineResolution.pendingMessageIds], [])
|
||||
assert.equal(replyBeforeEngineResolution.latestPendingReplyMetadata, undefined)
|
||||
|
||||
const responded = analyzeCheckpointMessages([
|
||||
{
|
||||
...checkpoint,
|
||||
metadata: {
|
||||
...checkpoint.metadata,
|
||||
checkpoint_status: 'responded',
|
||||
checkpoint_response_message_id: 'msg-user',
|
||||
},
|
||||
},
|
||||
])
|
||||
assert.deepEqual([...responded.respondedMessageIds], ['msg-checkpoint'])
|
||||
|
||||
const expiredApproval: ChatMessage = {
|
||||
id: 'msg-expired-approval',
|
||||
channelId: 'session:task-1',
|
||||
sender: 'assistant',
|
||||
senderName: 'OPC',
|
||||
content: 'Approve external_agent?',
|
||||
timestamp: 3,
|
||||
mentions: [],
|
||||
metadata: {
|
||||
checkpoint_type: 'human_escalation',
|
||||
checkpoint_id: 'esc-expired',
|
||||
escalation_id: 'esc-expired',
|
||||
escalation_type: 'decision_needed',
|
||||
prompt: 'Approve external_agent?',
|
||||
options: [{ id: 'approve_once', label: 'Approve once' }],
|
||||
checkpoint_status: 'timeout',
|
||||
},
|
||||
}
|
||||
const staleApproval: ChatMessage = {
|
||||
...expiredApproval,
|
||||
id: 'msg-stale-approval',
|
||||
metadata: {
|
||||
...expiredApproval.metadata,
|
||||
checkpoint_id: 'esc-stale',
|
||||
escalation_id: 'esc-stale',
|
||||
checkpoint_status: 'stale',
|
||||
},
|
||||
}
|
||||
const supersededRecruitment: ChatMessage = {
|
||||
...checkpoint,
|
||||
id: 'msg-superseded-recruitment',
|
||||
metadata: {
|
||||
...checkpoint.metadata,
|
||||
checkpoint_type: 'company_recruitment_confirmation',
|
||||
checkpoint_id: 'cp-recruit-old',
|
||||
checkpoint_status: 'superseded',
|
||||
},
|
||||
}
|
||||
const ignoredDelivery: ChatMessage = {
|
||||
...checkpoint,
|
||||
id: 'msg-ignored-delivery',
|
||||
metadata: {
|
||||
...checkpoint.metadata,
|
||||
checkpoint_status: 'ignored',
|
||||
},
|
||||
}
|
||||
const terminal = analyzeCheckpointMessages([expiredApproval, staleApproval, supersededRecruitment, ignoredDelivery])
|
||||
assert.deepEqual([...terminal.pendingMessageIds], [])
|
||||
assert.deepEqual([...terminal.respondedMessageIds], ['msg-expired-approval', 'msg-stale-approval', 'msg-superseded-recruitment', 'msg-ignored-delivery'])
|
||||
|
||||
console.log('checkpointUtils.test.ts: OK (checkpoint pending/reply/terminal status handling)')
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ChatMessage, ChatMessageMeta, CheckpointReplyMetadata } from '../types/chat'
|
||||
|
||||
const CHECKPOINT_TYPES = new Set([
|
||||
'company_work_item_gate',
|
||||
'company_delivery_feedback',
|
||||
'company_staffing_selection',
|
||||
'company_recruitment_confirmation',
|
||||
'company_reorg_pending',
|
||||
'human_escalation',
|
||||
'task_user_input',
|
||||
])
|
||||
|
||||
const TERMINAL_CHECKPOINT_STATUSES = new Set([
|
||||
'responded',
|
||||
'resolved',
|
||||
'timeout',
|
||||
'timed_out',
|
||||
'expired',
|
||||
'stale',
|
||||
'superseded',
|
||||
'ignored',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'invalid',
|
||||
])
|
||||
|
||||
export function isCheckpointType(value: string | undefined): boolean {
|
||||
return CHECKPOINT_TYPES.has(String(value ?? '').trim())
|
||||
}
|
||||
|
||||
export function isCheckpointCardMetadata(meta: ChatMessageMeta | undefined): boolean {
|
||||
if (!isCheckpointType(meta?.checkpoint_type)) {
|
||||
return false
|
||||
}
|
||||
if (meta?.self_evolution_completed) {
|
||||
return false
|
||||
}
|
||||
if (String(meta?.kind ?? '').trim() === 'company_self_evolution_result') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function isCheckpointResolved(meta: ChatMessageMeta | undefined): boolean {
|
||||
const status = String(meta?.checkpoint_status ?? '').trim().toLowerCase()
|
||||
if (TERMINAL_CHECKPOINT_STATUSES.has(status)) {
|
||||
return true
|
||||
}
|
||||
return !!String(meta?.checkpoint_response_message_id ?? '').trim()
|
||||
}
|
||||
|
||||
export function toCheckpointReplyMetadata(meta: ChatMessageMeta | undefined): CheckpointReplyMetadata | undefined {
|
||||
const checkpointId = String(meta?.checkpoint_id ?? '').trim()
|
||||
if (!checkpointId) {
|
||||
return undefined
|
||||
}
|
||||
const checkpointType = String(meta?.checkpoint_type ?? '').trim()
|
||||
const escalationId = String(meta?.escalation_id ?? '').trim()
|
||||
return {
|
||||
response_to_checkpoint_id: checkpointId,
|
||||
response_to_checkpoint_type: checkpointType || undefined,
|
||||
response_to_escalation_id: escalationId || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function checkpointReplyMetadataForComposer(
|
||||
meta: CheckpointReplyMetadata | undefined,
|
||||
): CheckpointReplyMetadata | undefined {
|
||||
const checkpointType = String(meta?.response_to_checkpoint_type ?? '').trim()
|
||||
if (checkpointType === 'company_delivery_feedback' || checkpointType === 'human_escalation') {
|
||||
return undefined
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
export function isResponseForCheckpoint(message: ChatMessage, checkpointMeta: ChatMessageMeta | undefined): boolean {
|
||||
if (message.sender !== 'user') {
|
||||
return false
|
||||
}
|
||||
const checkpointId = String(checkpointMeta?.checkpoint_id ?? '').trim()
|
||||
if (!checkpointId) {
|
||||
return false
|
||||
}
|
||||
const replyMeta = message.metadata
|
||||
if (String(replyMeta?.response_to_checkpoint_id ?? '').trim() === checkpointId) {
|
||||
return true
|
||||
}
|
||||
const checkpointType = String(checkpointMeta?.checkpoint_type ?? '').trim()
|
||||
const escalationId = String(checkpointMeta?.escalation_id ?? '').trim()
|
||||
return checkpointType === 'human_escalation'
|
||||
&& !!escalationId
|
||||
&& String(replyMeta?.response_to_escalation_id ?? '').trim() === escalationId
|
||||
}
|
||||
|
||||
export function analyzeCheckpointMessages(messages: ChatMessage[]): {
|
||||
pendingMessageIds: Set<string>
|
||||
respondedMessageIds: Set<string>
|
||||
duplicateMessageIds: Set<string>
|
||||
latestPendingReplyMetadata?: CheckpointReplyMetadata
|
||||
} {
|
||||
const pendingMessageIds = new Set<string>()
|
||||
const respondedMessageIds = new Set<string>()
|
||||
const duplicateMessageIds = new Set<string>()
|
||||
let latestPendingReplyMetadata: CheckpointReplyMetadata | undefined
|
||||
const latestCheckpointReplyIndex = new Map<string, number>()
|
||||
const latestEscalationReplyIndex = new Map<string, number>()
|
||||
const seenCheckpointIds = new Set<string>()
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
if (message.sender !== 'user') continue
|
||||
const replyMeta = message.metadata
|
||||
const checkpointId = String(replyMeta?.response_to_checkpoint_id ?? '').trim()
|
||||
if (checkpointId) {
|
||||
latestCheckpointReplyIndex.set(checkpointId, i)
|
||||
}
|
||||
const escalationId = String(replyMeta?.response_to_escalation_id ?? '').trim()
|
||||
if (escalationId) {
|
||||
latestEscalationReplyIndex.set(escalationId, i)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
const checkpointMeta = message.metadata
|
||||
if (!isCheckpointCardMetadata(checkpointMeta)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const checkpointId = String(checkpointMeta?.checkpoint_id ?? '').trim()
|
||||
const checkpointType = String(checkpointMeta?.checkpoint_type ?? '').trim()
|
||||
const escalationId = String(checkpointMeta?.escalation_id ?? '').trim()
|
||||
if (checkpointId && seenCheckpointIds.has(checkpointId)) {
|
||||
duplicateMessageIds.add(message.id)
|
||||
continue
|
||||
}
|
||||
if (checkpointId) {
|
||||
seenCheckpointIds.add(checkpointId)
|
||||
}
|
||||
|
||||
const hasLaterCheckpointReply = !!checkpointId && (latestCheckpointReplyIndex.get(checkpointId) ?? -1) > i
|
||||
const hasLaterEscalationReply = checkpointType === 'human_escalation'
|
||||
&& !!escalationId
|
||||
&& (latestEscalationReplyIndex.get(escalationId) ?? -1) > i
|
||||
|
||||
if (isCheckpointResolved(checkpointMeta) || hasLaterCheckpointReply || hasLaterEscalationReply) {
|
||||
respondedMessageIds.add(message.id)
|
||||
continue
|
||||
}
|
||||
|
||||
pendingMessageIds.add(message.id)
|
||||
latestPendingReplyMetadata = toCheckpointReplyMetadata(checkpointMeta) ?? latestPendingReplyMetadata
|
||||
}
|
||||
|
||||
return {
|
||||
pendingMessageIds,
|
||||
respondedMessageIds,
|
||||
duplicateMessageIds,
|
||||
latestPendingReplyMetadata,
|
||||
}
|
||||
}
|
||||
@@ -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:00–4: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:00–23:59 = day, 0:00–4: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">⇄</span>}
|
||||
{onStart && (
|
||||
<button
|
||||
className="kanban-start-btn"
|
||||
title={companyMode ? 'Start Work Item' : 'Start task'}
|
||||
onClick={e => { e.stopPropagation(); onStart(task.id) }}
|
||||
>
|
||||
▶
|
||||
</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">👤</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}>×</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 && (
|
||||
<> · 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}>×</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
Reference in New Issue
Block a user