Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,449 @@
import { useMemo, useState, type ReactNode } from 'react'
import type {
ArchitecturePreset,
ArchitecturePresetDetail,
InstalledPackageInfo,
ChannelStatusInfo,
ReorgProposalInfo,
} from '../types/visual'
import { PackageCard } from './PackageCard'
import { CollapsibleSection } from './CollapsibleSection'
/* ── Inline SVG icon data-URIs (no external CDN) ────────────────── */
const ICON = {
search: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z'/%3E%3C/svg%3E",
arch: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z'/%3E%3C/svg%3E",
packages: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z'/%3E%3C/svg%3E",
channels: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z'/%3E%3C/svg%3E",
reorg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z'/%3E%3C/svg%3E",
importPkg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z'/%3E%3C/svg%3E",
arrow: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z'/%3E%3C/svg%3E",
gateReview: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23f59e0b' d='M12 2L4 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-3zm-2 16l-4-4 1.41-1.41L10 15.17l6.59-6.59L18 10l-8 8z'/%3E%3C/svg%3E",
gateApproval: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E",
gateHold: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23ef4444' d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E",
}
const PATTERN_LABELS: Record<string, string> = {
pipeline: 'Pipeline',
hub_spoke: 'Hub & Spoke',
review_loop: 'Review Loop',
hierarchical: 'Hierarchical',
flat: 'Flat Team',
}
interface ArchitectureMarketplaceProps {
presets: ArchitecturePreset[]
installedIds: Set<string>
previewData: ArchitecturePresetDetail | null
applyingPresetId: string | null
readOnly: boolean
onPreview: (presetId: string) => void
onApplyPreset: (presetId: string, strategy: string) => void
onClearPreview: () => void
installedPackages: InstalledPackageInfo[]
channels: ChannelStatusInfo[]
reorgProposals: ReorgProposalInfo[]
isCustomMode: boolean
onReorgDecide: (proposalId: string, approved: boolean, notes?: string) => void
onMarketInstall: (path: string, strategy: string) => void
onMarketUninstall: (packageId: string) => void
}
export function ArchitectureMarketplace({
presets, installedIds, previewData, applyingPresetId, readOnly,
onPreview, onApplyPreset, onClearPreview,
installedPackages, channels, reorgProposals, isCustomMode,
onReorgDecide, onMarketInstall, onMarketUninstall,
}: ArchitectureMarketplaceProps) {
const [search, setSearch] = useState('')
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [activePattern, setActivePattern] = useState<string | null>(null)
const [showImportForm, setShowImportForm] = useState(false)
const [importPath, setImportPath] = useState('')
const [uninstallingId, setUninstallingId] = useState<string | null>(null)
const categories = useMemo(() => {
const cats = new Set<string>()
for (const p of presets) { if (p.category) cats.add(p.category) }
return Array.from(cats).sort()
}, [presets])
const patterns = useMemo(() => {
const pats = new Set<string>()
for (const p of presets) { if (p.collaboration_pattern) pats.add(p.collaboration_pattern) }
return Array.from(pats).sort()
}, [presets])
const filteredPresets = useMemo(() => {
let result = presets
if (activeCategory) result = result.filter(p => p.category === activeCategory)
if (activePattern) result = result.filter(p => p.collaboration_pattern === activePattern)
if (search.trim()) {
const q = search.trim().toLowerCase()
result = result.filter(p =>
p.name.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
p.tags.some(t => t.toLowerCase().includes(q)) ||
(PATTERN_LABELS[p.collaboration_pattern] || '').toLowerCase().includes(q),
)
}
return result
}, [presets, activeCategory, activePattern, search])
const handleImport = () => {
if (!importPath.trim()) return
onMarketInstall(importPath.trim(), 'namespace')
setShowImportForm(false)
setImportPath('')
}
const handleUninstall = (pkgId: string) => {
if (!confirm('Uninstall this package? Roles and work-item templates from this package will be removed.')) return
setUninstallingId(pkgId)
onMarketUninstall(pkgId)
setTimeout(() => setUninstallingId(null), 3000)
}
return (
<div className="mkt-container" data-testid="architecture-marketplace">
{/* Toolbar */}
<div className="mkt-toolbar">
<div className="mkt-search-wrap">
<img src={ICON.search} alt="" className="mkt-search-icon" />
<input
className="mkt-search"
placeholder="Search architectures..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<span className="mkt-count">{filteredPresets.length} architectures</span>
</div>
{/* Filter pills */}
<div className="mkt-filters">
{categories.length > 0 && (
<div className="mkt-pill-row">
<button className={`mkt-pill${!activeCategory ? ' active' : ''}`}
onClick={() => setActiveCategory(null)}>All</button>
{categories.map(cat => (
<button key={cat} className={`mkt-pill${activeCategory === cat ? ' active' : ''}`}
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
>{cat}</button>
))}
</div>
)}
{patterns.length > 0 && (
<div className="mkt-pill-row">
<button className={`mkt-pill mkt-pill-pattern${!activePattern ? ' active' : ''}`}
onClick={() => setActivePattern(null)}>All Patterns</button>
{patterns.map(pat => (
<button key={pat} className={`mkt-pill mkt-pill-pattern${activePattern === pat ? ' active' : ''}`}
onClick={() => setActivePattern(activePattern === pat ? null : pat)}
>{PATTERN_LABELS[pat] || pat}</button>
))}
</div>
)}
</div>
{/* Architecture Blueprints grid */}
{filteredPresets.length > 0 ? (
<div className="mkt-section">
<div className="mkt-section-header">
<img src={ICON.arch} alt="" className="mkt-section-icon" />
<h3 className="mkt-section-title">Architecture Blueprints</h3>
<span className="mkt-section-count">{filteredPresets.length}</span>
</div>
<div className="mkt-arch-grid">
{filteredPresets.map(p => (
<ArchCard key={p.id} preset={p}
isInstalled={installedIds.has(p.id)}
isApplying={applyingPresetId === p.id}
readOnly={readOnly}
onPreview={() => onPreview(p.id)}
onApply={() => onApplyPreset(p.id, 'namespace')}
/>
))}
</div>
</div>
) : (
<div className="mkt-empty"><p>No architectures match your search.</p></div>
)}
{/* Installed Packages */}
<CollapsibleSection icon={ICON.packages} title="Installed Packages" count={installedPackages.length}
extra={isCustomMode ? (
<button className="myorg-inline-btn" onClick={() => setShowImportForm(!showImportForm)}>
<img src={ICON.importPkg} alt="" className="myorg-inline-icon" /> Import
</button>
) : undefined}>
{isCustomMode && showImportForm && (
<div className="myorg-form">
<div className="oc-form-row">
<label>Path</label>
<input value={importPath} onChange={e => setImportPath(e.target.value)}
placeholder="/path/to/package.opcpkg" />
</div>
<div className="oc-form-actions">
<button className="oc-btn-primary" onClick={handleImport} disabled={!importPath.trim()}>Install</button>
<button className="oc-btn-ghost" onClick={() => setShowImportForm(false)}>Cancel</button>
</div>
</div>
)}
{installedPackages.length > 0 ? (
<div className="pkg-grid">
{installedPackages.map(pkg => (
<PackageCard key={pkg.package_id} pkg={pkg}
onUninstall={isCustomMode ? handleUninstall : undefined}
uninstallingId={uninstallingId} />
))}
</div>
) : (
<div className="myorg-empty-hint">No packages installed.</div>
)}
</CollapsibleSection>
{/* Channels & Connectors */}
<CollapsibleSection icon={ICON.channels} title="Channels & Connectors" count={channels.length}>
{channels.length > 0 ? (
<div className="org-channels-grid">
{channels.map(ch => (
<div key={ch.name} className={`org-channel-card${ch.running ? ' org-ch-running' : ''}${!ch.enabled ? ' org-ch-disabled' : ''}`}>
<div className="org-ch-header">
<span className={`org-ch-dot${ch.running ? ' running' : ch.ready ? ' ready' : ch.configured ? ' configured' : ''}`} />
<span className="org-ch-name">{ch.name}</span>
</div>
<div className="org-ch-status-row">
{ch.enabled && <span className="org-ch-badge org-ch-enabled">enabled</span>}
{ch.running && <span className="org-ch-badge org-ch-running-badge">running</span>}
{!ch.enabled && <span className="org-ch-badge org-ch-off">disabled</span>}
</div>
</div>
))}
</div>
) : (
<div className="myorg-empty-hint">No channels configured.</div>
)}
</CollapsibleSection>
{/* Reorg Proposals */}
{reorgProposals.length > 0 && (
<CollapsibleSection icon={ICON.reorg} title="Reorg Proposals" count={reorgProposals.length}>
<div className="org-reorg-list">
{reorgProposals.map(p => {
const isPending = p.status === 'proposed'
return (
<div key={p.proposal_id} className={`org-reorg-card org-reorg-${p.status}`}>
<div className="org-reorg-header">
<span className="org-reorg-title">{p.title || p.summary || 'Untitled'}</span>
<span className="org-reorg-status">{p.status}</span>
</div>
{p.summary && <div className="org-reorg-summary">{p.summary}</div>}
{isPending && isCustomMode && (
<div className="org-reorg-actions">
<button className="org-reorg-approve" onClick={() => onReorgDecide(p.proposal_id, true)}>Approve</button>
<button className="org-reorg-deny" onClick={() => onReorgDecide(p.proposal_id, false)}>Deny</button>
</div>
)}
</div>
)
})}
</div>
</CollapsibleSection>
)}
{/* Architecture preview modal */}
{previewData && (
<ArchPreviewModal
data={previewData}
isInstalled={installedIds.has(previewData.id)}
isApplying={applyingPresetId === previewData.id}
onApply={() => onApplyPreset(previewData.id, 'namespace')}
onClose={onClearPreview}
/>
)}
</div>
)
}
/* ── Architecture Card ───────────────────────────────────────────────── */
function ArchCard({ preset: p, isInstalled, isApplying, readOnly, onPreview, onApply }: {
preset: ArchitecturePreset
isInstalled: boolean
isApplying: boolean
readOnly: boolean
onPreview: () => void
onApply: () => void
}) {
const patternLabel = PATTERN_LABELS[p.collaboration_pattern] || p.collaboration_pattern
return (
<div className="mkt-arch-card" style={{ borderLeftColor: p.color || 'var(--accent)' }}
onClick={onPreview}>
<div className="mkt-arch-header">
<span className="mkt-arch-emoji">{p.emoji}</span>
<div className="mkt-arch-title-wrap">
<span className="mkt-arch-name">{p.name}</span>
<div className="mkt-arch-badges">
<span className="mkt-arch-category">{p.category}</span>
{p.collaboration_pattern && <span className="mkt-arch-pattern">{patternLabel}</span>}
</div>
</div>
</div>
{p.dag_summary && <div className="mkt-arch-dag-summary">{p.dag_summary}</div>}
<div className="mkt-arch-desc">{p.description}</div>
<div className="mkt-arch-stats">
<span className="mkt-arch-stat">{p.roles_count} roles</span>
<span className="mkt-arch-stat">{p.work_item_templates_count} templates</span>
{p.gates_count > 0 && <span className="mkt-arch-stat">{p.gates_count} checkpoints</span>}
{p.team_size && <span className="mkt-arch-stat">{p.team_size} people</span>}
</div>
{p.tags.length > 0 && (
<div className="mkt-arch-tags">
{p.tags.slice(0, 4).map(t => <span key={t} className="mkt-tag">{t}</span>)}
</div>
)}
<div className="mkt-arch-actions">
{isInstalled ? (
<span className="mkt-installed-badge">Installed</span>
) : !readOnly ? (
<button className="mkt-btn mkt-btn-primary mkt-btn-sm"
disabled={isApplying}
onClick={e => { e.stopPropagation(); onApply() }}
>{isApplying ? 'Applying...' : 'Use This'}</button>
) : null}
<button className="mkt-btn mkt-btn-ghost mkt-btn-sm"
onClick={e => { e.stopPropagation(); onPreview() }}
>Preview</button>
</div>
</div>
)
}
/* ── Architecture Preview Modal ──────────────────────────────────────── */
function ArchPreviewModal({ data, isInstalled, isApplying, onApply, onClose }: {
data: ArchitecturePresetDetail
isInstalled: boolean
isApplying: boolean
onApply: () => void
onClose: () => void
}) {
return (
<div className="mkt-modal-overlay" onClick={onClose}>
<div className="mkt-modal" onClick={e => e.stopPropagation()}>
<div className="mkt-modal-header" style={{ borderBottomColor: data.color || 'var(--border)' }}>
<span className="mkt-modal-emoji">{data.emoji}</span>
<div>
<h2 className="mkt-modal-name">{data.name}</h2>
<span className="mkt-modal-category">{data.category}</span>
</div>
<button className="mkt-modal-close" onClick={onClose}>&times;</button>
</div>
<div className="mkt-modal-body">
<p className="mkt-modal-desc">{data.description}</p>
{data.tags.length > 0 && (
<div className="mkt-modal-section">
<div className="mkt-modal-label">Tags</div>
<div className="mkt-modal-tags">
{data.tags.map(t => <span key={t} className="mkt-tag">{t}</span>)}
</div>
</div>
)}
<div className="mkt-modal-section">
<div className="mkt-modal-label">Roles ({data.roles.length})</div>
<div className="mkt-role-list">
{data.roles.map(r => (
<div key={r.id} className="mkt-role-item">
<div className="mkt-role-name">{r.name} <code>{r.id}</code></div>
<div className="mkt-role-resp">{r.responsibility}</div>
<div className="mkt-role-meta">
reports to: <code>{r.reports_to}</code>
{r.can_spawn && r.can_spawn.length > 0 && (
<> &middot; spawns: {r.can_spawn.map(s => <code key={s}>{s}</code>)}</>
)}
</div>
</div>
))}
</div>
</div>
{/* Work item templates */}
<div className="mkt-modal-section">
<div className="mkt-modal-label">Work item templates ({data.work_item_templates.length} templates)</div>
<div className="mkt-dag-wrap">
<ModalWorkItemTemplates templates={data.work_item_templates} />
</div>
</div>
</div>
<div className="mkt-modal-footer">
{isInstalled ? (
<span className="mkt-installed-badge">Already Installed</span>
) : (
<button className="mkt-btn mkt-btn-primary" disabled={isApplying} onClick={onApply}>
{isApplying ? 'Applying...' : 'Use This Architecture'}
</button>
)}
</div>
</div>
</div>
)
}
function ModalWorkItemTemplates({ templates }: { templates: ArchitecturePresetDetail['work_item_templates'] }): ReactNode {
type Group = { group: string | null; templates: typeof templates }
const groups: Group[] = []
let currentGroup: string | null = '__init__'
let currentTemplates: typeof templates = []
for (const template of templates) {
if (template.parallel_group !== currentGroup) {
if (currentTemplates.length > 0) groups.push({ group: currentGroup, templates: currentTemplates })
currentGroup = template.parallel_group
currentTemplates = [template]
} else {
currentTemplates.push(template)
}
}
if (currentTemplates.length > 0) groups.push({ group: currentGroup, templates: currentTemplates })
return (
<div className="org-dag">
{groups.map((g, gi) => (
<div key={gi} className="org-dag-group-wrap">
{gi > 0 && <div className="org-dag-arrow"><img src={ICON.arrow} alt="→" className="org-dag-arrow-icon" /></div>}
<div className={`org-dag-group${g.templates.length > 1 ? ' org-dag-parallel' : ''}`}>
{g.templates.length > 1 && <div className="org-dag-parallel-label">parallel</div>}
{g.templates.map(template => (
<div key={template.id} className="org-dag-node">
<div className="org-dag-node-header">
<span className="org-dag-node-title">{template.title}</span>
<span className="org-dag-node-role">{template.role_id}</span>
</div>
<div className="org-dag-node-id">{template.id}</div>
{template.gate && (
<div className={`org-dag-gate org-gate-${template.gate.type}`}>
<img
src={template.gate.type === 'review' ? ICON.gateReview : template.gate.type === 'approval' ? ICON.gateApproval : ICON.gateHold}
alt="" className="org-gate-icon"
/>
<span>{template.gate.type}</span>
{template.gate.reviewer_role && <span className="org-gate-reviewer">by {template.gate.reviewer_role}</span>}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,26 @@
import { useState, type ReactNode } from 'react'
interface CollapsibleSectionProps {
icon: string
title: string
count: number
extra?: ReactNode
children: ReactNode
defaultExpanded?: boolean
}
export function CollapsibleSection({ icon, title, count, extra, children, defaultExpanded = false }: CollapsibleSectionProps) {
const [expanded, setExpanded] = useState(defaultExpanded)
return (
<div className="myorg-collapsible">
<button className="myorg-collapsible-toggle" onClick={() => setExpanded(!expanded)}>
<span className="myorg-toggle-icon">{expanded ? '\u25BE' : '\u25B8'}</span>
<img src={icon} alt="" className="myorg-section-icon" />
<span className="myorg-collapsible-title">{title}</span>
<span className="myorg-collapsible-count">{count}</span>
{extra && <span className="myorg-collapsible-extra" onClick={e => e.stopPropagation()}>{extra}</span>}
</button>
{expanded && <div className="myorg-collapsible-body">{children}</div>}
</div>
)
}
@@ -0,0 +1,191 @@
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
/* ── Inline SVG icon data-URIs ──────────────────────────────────── */
const ICON = {
download: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z'/%3E%3C/svg%3E",
upload: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z'/%3E%3C/svg%3E",
check: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E",
warn: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23ef4444' d='M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z'/%3E%3C/svg%3E",
}
interface ConfigImportExportPanelProps {
onExport: () => void
onImport: (yaml: string, dryRun: boolean) => void
configExportYaml?: string | null
importPreview?: { roles_added: number; roles_removed: number; employees_changed: number } | null
importError?: string | null
}
export function ConfigImportExportPanel({
onExport, onImport, configExportYaml, importPreview, importError,
}: ConfigImportExportPanelProps) {
const [yamlText, setYamlText] = useState('')
const [fileName, setFileName] = useState<string | null>(null)
const [dryRunDone, setDryRunDone] = useState(false)
const exportPending = useRef(false)
const lastExportedYaml = useRef<string | null>(null)
// Trigger browser download when server returns the exported YAML
useEffect(() => {
if (!exportPending.current) return
if (!configExportYaml) return
if (configExportYaml === lastExportedYaml.current) return
lastExportedYaml.current = configExportYaml
exportPending.current = false
const blob = new Blob([configExportYaml], { type: 'application/x-yaml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
a.download = `org_config_${stamp}.yaml`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [configExportYaml])
// Reset dry-run state when user edits the YAML
useEffect(() => {
setDryRunDone(false)
}, [yamlText])
// Flip dry-run state on successful preview (not on error)
useEffect(() => {
if (importPreview && !importError) setDryRunDone(true)
}, [importPreview, importError])
const handleExport = () => {
exportPending.current = true
onExport()
}
const handleFile = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setFileName(file.name)
const reader = new FileReader()
reader.onload = () => {
const result = reader.result
if (typeof result === 'string') setYamlText(result)
}
reader.readAsText(file)
e.target.value = '' // allow re-selecting same file
}
const handleDryRun = () => {
if (!yamlText.trim()) return
onImport(yamlText, true)
}
const handleApply = () => {
if (!dryRunDone || !yamlText.trim()) return
if (!confirm('Apply this config? The current company architecture will be overwritten.')) return
onImport(yamlText, false)
setDryRunDone(false)
setYamlText('')
setFileName(null)
}
const handleClear = () => {
setYamlText('')
setFileName(null)
setDryRunDone(false)
}
return (
<div className="cfg-io-panel" data-testid="config-import-export-panel">
<div className="cfg-io-header">
<h3 className="cfg-io-title">Config Import / Export</h3>
<p className="cfg-io-subtitle">Download the current company architecture as YAML, or upload one to replace it.</p>
</div>
{/* Export */}
<div className="cfg-io-section">
<div className="cfg-io-section-header">
<img src={ICON.download} alt="" className="cfg-io-section-icon" />
<span className="cfg-io-section-title">Download current config</span>
</div>
<button className="cfg-io-btn cfg-io-btn-primary" onClick={handleExport}>
<img src={ICON.download} alt="" className="cfg-io-btn-icon" /> Download YAML
</button>
</div>
{/* Import */}
<div className="cfg-io-section">
<div className="cfg-io-section-header">
<img src={ICON.upload} alt="" className="cfg-io-section-icon" />
<span className="cfg-io-section-title">Upload config</span>
</div>
<div className="cfg-io-upload-row">
<label className="cfg-io-file-label">
<input type="file" accept=".yaml,.yml" onChange={handleFile} className="cfg-io-file-input" />
<span className="cfg-io-file-btn">Choose file</span>
<span className="cfg-io-file-name">{fileName ?? 'no file selected'}</span>
</label>
</div>
<textarea
className="cfg-io-textarea"
placeholder="…or paste YAML here"
value={yamlText}
onChange={e => setYamlText(e.target.value)}
spellCheck={false}
rows={10}
/>
<div className="cfg-io-actions">
<button className="cfg-io-btn cfg-io-btn-ghost"
onClick={handleDryRun}
disabled={!yamlText.trim()}>
Dry run
</button>
<button className="cfg-io-btn cfg-io-btn-primary"
onClick={handleApply}
disabled={!dryRunDone || !yamlText.trim()}>
Apply
</button>
{yamlText && (
<button className="cfg-io-btn cfg-io-btn-ghost" onClick={handleClear}>Clear</button>
)}
</div>
{/* Preview */}
{importPreview && !importError && (
<div className="cfg-io-preview">
<img src={ICON.check} alt="" className="cfg-io-preview-icon" />
<div className="cfg-io-preview-body">
<div className="cfg-io-preview-title">Dry run OK ready to apply</div>
<div className="cfg-io-preview-stats">
<span className="cfg-io-preview-stat">
<span className="cfg-io-stat-label">Roles added</span>
<span className="cfg-io-stat-value">{importPreview.roles_added}</span>
</span>
<span className="cfg-io-preview-stat">
<span className="cfg-io-stat-label">Roles removed</span>
<span className="cfg-io-stat-value">{importPreview.roles_removed}</span>
</span>
<span className="cfg-io-preview-stat">
<span className="cfg-io-stat-label">Employees changed</span>
<span className="cfg-io-stat-value">{importPreview.employees_changed}</span>
</span>
</div>
</div>
</div>
)}
{/* Error */}
{importError && (
<div className="cfg-io-error">
<img src={ICON.warn} alt="" className="cfg-io-error-icon" />
<div className="cfg-io-error-body">
<div className="cfg-io-error-title">Validation failed</div>
<pre className="cfg-io-error-text">{importError}</pre>
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,179 @@
import type { OrgRole, RuntimeFrontierSummary, RuntimeSeatInfo, RuntimeTeamInfo, RuntimeWorkItemInfo, RuntimePolicy } from '../types/visual'
interface DelegationStrategyPanelProps {
roles: OrgRole[]
runtimeTeams: RuntimeTeamInfo[]
runtimeSeats: RuntimeSeatInfo[]
workItems: RuntimeWorkItemInfo[]
frontier: RuntimeFrontierSummary
companyProfile: string
runtimePolicy?: RuntimePolicy
finalDeciderRoleId?: string | null
topLevelRoleIds?: string[]
readOnly?: boolean
onUpdateOrgStrategy?: (data: { final_decider_role_id?: string | null }) => void
onUpdateRuntimePolicy?: (policy: Record<string, any>) => void
}
function adaptiveForWorkItem(item: RuntimeWorkItemInfo): Record<string, unknown> | undefined {
if (item.adaptive && typeof item.adaptive === 'object') return item.adaptive
const metadata = item.metadata
if (metadata && typeof metadata === 'object' && metadata.adaptive && typeof metadata.adaptive === 'object') {
return metadata.adaptive as Record<string, unknown>
}
return undefined
}
function missingSignalsForWorkItem(item: RuntimeWorkItemInfo): string[] {
const adaptive = adaptiveForWorkItem(item)
const signals = Array.isArray(adaptive?.signals) ? adaptive.signals : []
return signals
.filter(signal => signal && typeof signal === 'object')
.filter(signal => Boolean((signal as Record<string, unknown>).required ?? true) && !Boolean((signal as Record<string, unknown>).satisfied))
.map(signal => String((signal as Record<string, unknown>).name ?? '').trim())
.filter(Boolean)
}
function gateOwnerForWorkItem(item: RuntimeWorkItemInfo): string {
const adaptive = adaptiveForWorkItem(item)
const stageProfile = adaptive?.work_item_profile
if (stageProfile && typeof stageProfile === 'object') {
return String((stageProfile as Record<string, unknown>).gate_owner_role_id ?? '').trim()
}
return ''
}
function adaptiveConfidenceLabel(item: RuntimeWorkItemInfo): string {
const adaptive = adaptiveForWorkItem(item)
const confidence = typeof adaptive?.confidence === 'number' ? adaptive.confidence : undefined
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : ''
}
export function DelegationStrategyPanel({
roles,
runtimeTeams,
runtimeSeats,
workItems,
frontier,
companyProfile,
runtimePolicy,
finalDeciderRoleId,
topLevelRoleIds,
readOnly = false,
onUpdateOrgStrategy,
onUpdateRuntimePolicy,
}: DelegationStrategyPanelProps) {
const topLevel = roles.filter(r => (topLevelRoleIds ?? []).includes(r.role_id))
const selectedFinalDecider = finalDeciderRoleId || (topLevel.length === 1 ? topLevel[0]?.role_id : '')
const hasSelectionError = topLevel.length > 1 && !selectedFinalDecider
const roleNameMap = new Map(roles.map(r => [r.role_id, r.name]))
return (
<div className="wfe-container">
<div className="wfe-header">
<h3 className="wfe-title">Actor Runtime</h3>
<span className="wfe-profile-badge">{companyProfile}</span>
</div>
<div className="myorg-collapsible" style={{ margin: '0 0 8px' }}>
<div className="myorg-collapsible-body" style={{ display: 'block' }}>
<div className="oc-form-row">
<label>Final decider</label>
<select
value={selectedFinalDecider}
disabled={readOnly}
onChange={e => {
if (readOnly) return
onUpdateOrgStrategy?.({ final_decider_role_id: e.target.value || null })
}}
>
<option value="">{topLevel.length > 1 ? 'Select top-level role' : 'Auto-select only top-level role'}</option>
{topLevel.map(role => (
<option key={role.role_id} value={role.role_id}>{role.name}</option>
))}
</select>
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
Runtime wakeups, delegation, approvals, and recovery are seat-scoped.
</div>
{hasSelectionError && (
<div className="org-toast org-toast--warn" style={{ margin: '0 0 8px' }}>
Multiple top-level roles exist. Select one final decider before company execution can start.
</div>
)}
</div>
</div>
{(runtimeTeams.length || runtimeSeats.length || workItems.length) ? (
<div className="myorg-collapsible" style={{ margin: '0 0 8px' }}>
<div className="myorg-collapsible-body" style={{ display: 'block' }}>
<div className="oc-form-row">
<label>Runtime</label>
<div>
{frontier.status || 'running'}
{frontier.run_id ? ` (${frontier.run_id.slice(0, 8)})` : ''}
</div>
</div>
<div className="oc-form-row">
<label>Frontier</label>
<div>
{frontier.running_count ?? 0} running, {frontier.ready_count ?? 0} ready, {frontier.blocked_count ?? 0} blocked, {frontier.waiting_count ?? 0} waiting
</div>
</div>
<div className="oc-form-row">
<label>Teams</label>
<div>{runtimeTeams.length}</div>
</div>
<div className="oc-form-row">
<label>Seats</label>
<div>{runtimeSeats.length}</div>
</div>
<div className="oc-form-row">
<label>Work items</label>
<div>{workItems.length}</div>
</div>
{workItems.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{workItems.slice(0, 6).map(item => {
const adaptive = adaptiveForWorkItem(item)
const normalizedState = typeof adaptive?.normalized_state === 'string' ? adaptive.normalized_state : ''
const blockedReason = typeof adaptive?.blocked_reason === 'string' ? adaptive.blocked_reason : (item.blocked_reason ?? '')
const gateOwner = gateOwnerForWorkItem(item)
const missingSignals = missingSignalsForWorkItem(item)
const confidence = adaptiveConfidenceLabel(item)
const summary = [
blockedReason ? `waiting ${blockedReason}` : '',
gateOwner ? `gate ${roleNameMap.get(gateOwner) ?? gateOwner}` : '',
missingSignals.length ? `signals ${missingSignals.join(', ')}` : '',
confidence ? `confidence ${confidence}` : '',
normalizedState === 'invalidated' ? 'invalidated' : '',
].filter(Boolean)
return (
<div key={item.work_item_id} style={{ marginBottom: 6 }}>
<div>
{roleNameMap.get(item.role_id) ?? item.role_id}: {item.title} [{item.phase}]
{item.kanban_column && (
<span style={{ opacity: 0.5 }}> · {item.kanban_column}</span>
)}
{item.batch_id && <span style={{ opacity: 0.5 }}> batch:{item.batch_id}</span>}
{normalizedState && normalizedState !== item.phase && (
<span style={{ opacity: 0.7 }}> state:{normalizedState}</span>
)}
</div>
{summary.length > 0 && (
<div style={{ marginLeft: 12, opacity: 0.85 }}>
{summary.join(' • ')}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,136 @@
import { useMemo, useState } from 'react'
import type { TalentTemplate, OrgRole, HireTalentHandler } from '../types/visual'
import { TalentCard } from './TalentCard'
import { TalentDetailModal } from './TalentDetailModal'
import { HireToRoleModal } from './HireToRoleModal'
/* ── Inline SVG icon data-URIs ──────────────────────────────────── */
const ICON = {
search: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z'/%3E%3C/svg%3E",
talent: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
}
interface EmployeesMarketplaceProps {
templates: TalentTemplate[]
vacantRoles: OrgRole[]
hiringTemplateId: string | null
readOnly: boolean
onHireTalent: HireTalentHandler
}
export function EmployeesMarketplace({
templates, vacantRoles, hiringTemplateId, readOnly, onHireTalent,
}: EmployeesMarketplaceProps) {
const [search, setSearch] = useState('')
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [detailTemplate, setDetailTemplate] = useState<TalentTemplate | null>(null)
const [hireForTemplate, setHireForTemplate] = useState<TalentTemplate | null>(null)
const categories = useMemo(() => {
const cats = new Set<string>()
for (const t of templates) { if (t.category) cats.add(t.category) }
return Array.from(cats).sort()
}, [templates])
const filteredTemplates = useMemo(() => {
let result = templates
if (activeCategory) result = result.filter(t => t.category === activeCategory)
if (search.trim()) {
const q = search.trim().toLowerCase()
result = result.filter(t =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.domains.some(d => d.toLowerCase().includes(q)) ||
t.tags.some(tag => tag.toLowerCase().includes(q)) ||
(t.vibe ?? '').toLowerCase().includes(q),
)
}
return result
}, [templates, activeCategory, search])
const handleCardHire = (templateId: string) => {
if (readOnly) return
const template = templates.find(t => t.template_id === templateId)
if (template) setHireForTemplate(template)
}
return (
<div className="mkt-container" data-testid="employees-marketplace">
{/* Toolbar */}
<div className="mkt-toolbar">
<div className="mkt-search-wrap">
<img src={ICON.search} alt="" className="mkt-search-icon" />
<input
className="mkt-search"
placeholder="Search talent templates..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<span className="mkt-count">
{filteredTemplates.length} employees
{vacantRoles.length > 0 && <> · {vacantRoles.length} vacant role{vacantRoles.length === 1 ? '' : 's'}</>}
</span>
</div>
{/* Category pills */}
{categories.length > 0 && (
<div className="mkt-filters">
<div className="mkt-pill-row">
<button className={`mkt-pill${!activeCategory ? ' active' : ''}`}
onClick={() => setActiveCategory(null)}>All</button>
{categories.map(cat => (
<button key={cat} className={`mkt-pill${activeCategory === cat ? ' active' : ''}`}
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
>{cat}</button>
))}
</div>
</div>
)}
{/* Talent grid */}
{filteredTemplates.length > 0 ? (
<div className="mkt-section">
<div className="mkt-section-header">
<img src={ICON.talent} alt="" className="mkt-section-icon" />
<h3 className="mkt-section-title">Talent Templates</h3>
<span className="mkt-section-count">{filteredTemplates.length}</span>
</div>
<div className="tm-grid">
{filteredTemplates.map(t => (
<TalentCard key={t.template_id} template={t}
hiringId={hiringTemplateId}
onHire={handleCardHire}
onClick={setDetailTemplate}
/>
))}
</div>
</div>
) : (
<div className="mkt-empty">
<p>{templates.length === 0 ? 'No talent templates available.' : 'No employees match your search.'}</p>
</div>
)}
{/* Detail modal */}
{detailTemplate && (
<TalentDetailModal
template={detailTemplate}
vacantRoles={vacantRoles}
hiringId={hiringTemplateId}
readOnly={readOnly}
onHire={(tid, rid) => { if (!readOnly) { onHireTalent(tid, rid); setDetailTemplate(null) } }}
onClose={() => setDetailTemplate(null)}
/>
)}
<HireToRoleModal
open={hireForTemplate !== null}
template={hireForTemplate}
vacantRoles={vacantRoles}
onConfirm={(tid, rid) => { onHireTalent(tid, rid); setHireForTemplate(null) }}
onClose={() => setHireForTemplate(null)}
/>
</div>
)
}
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import type { OrgRole, TalentTemplate, RoleId, TemplateId } from '../types/visual'
import { asRoleId, asTemplateId } from '../types/visual'
interface HireToRoleModalProps {
open: boolean
template: TalentTemplate | null
vacantRoles: OrgRole[]
onConfirm: (template: TemplateId, role: RoleId) => void
onClose: () => void
}
export function HireToRoleModal({
open, template, vacantRoles, onConfirm, onClose,
}: HireToRoleModalProps) {
const [selectedRoleId, setSelectedRoleId] = useState<string>('')
useEffect(() => {
if (open) setSelectedRoleId('')
}, [open, template?.template_id])
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open || !template) return null
const noVacancies = vacantRoles.length === 0
const canConfirm = !noVacancies && selectedRoleId !== ''
const handleConfirm = () => {
if (!canConfirm) return
onConfirm(asTemplateId(template.template_id), asRoleId(selectedRoleId))
}
return (
<div className="htr-overlay" role="dialog" aria-modal="true" onMouseDown={onClose}>
<div className="htr-modal" onMouseDown={event => event.stopPropagation()}>
<header className="htr-header">
<div>
<h3 className="htr-title">Hire {template.name}</h3>
<p className="htr-subtitle">Pick the role to fill with this employee.</p>
</div>
<button className="htr-close" type="button" onClick={onClose} aria-label="Close">x</button>
</header>
<div className="htr-body">
{noVacancies ? (
<div className="htr-empty">
<p className="htr-empty-title">No vacant roles.</p>
<p className="htr-empty-hint">
Add a role in the Team tab first, then come back to hire.
</p>
</div>
) : (
<div className="htr-role-list" role="listbox" aria-label="Vacant roles">
{vacantRoles.map(role => {
const selected = role.role_id === selectedRoleId
return (
<button
key={role.role_id}
type="button"
role="option"
aria-selected={selected}
className={`htr-role-row${selected ? ' is-selected' : ''}`}
onClick={() => setSelectedRoleId(role.role_id)}
>
<span className="htr-role-name">{role.name}</span>
{role.responsibility && (
<span className="htr-role-resp">{role.responsibility}</span>
)}
</button>
)
})}
</div>
)}
</div>
<footer className="htr-footer">
<button type="button" className="btn btn-ghost" onClick={onClose}>
{noVacancies ? 'Close' : 'Cancel'}
</button>
{!noVacancies && (
<button
type="button"
className="btn btn-primary"
onClick={handleConfirm}
disabled={!canConfirm}
>
Hire to selected role
</button>
)}
</footer>
</div>
</div>
)
}
@@ -0,0 +1,257 @@
import { useEffect, useMemo, useState } from 'react'
import type { OrgCreateMemberInput, OrgSavedCreatePayload } from '../types/visual'
interface OrgCreateResult extends OrgSavedCreatePayload {
nonce: number
}
interface OrgCreateModalProps {
open: boolean
pending?: boolean
result?: OrgCreateResult | null
onClose: () => void
onCreate: (organizationName: string, members: OrgCreateMemberInput[]) => void
}
type MemberDraft = {
name: string
responsibility: string
prompt: string
reportsToIndex: number | null
}
const INITIAL_MEMBERS: MemberDraft[] = [
{ name: '', responsibility: '', prompt: '', reportsToIndex: null },
{ name: '', responsibility: '', prompt: '', reportsToIndex: 0 },
]
function slugLabel(value: string): string {
return value.trim() || 'Member'
}
export function OrgCreateModal({ open, pending, result, onClose, onCreate }: OrgCreateModalProps) {
const [step, setStep] = useState(1)
const [organizationName, setOrganizationName] = useState('')
const [members, setMembers] = useState<MemberDraft[]>(INITIAL_MEMBERS)
const [localError, setLocalError] = useState('')
useEffect(() => {
if (!open) return
setStep(1)
setOrganizationName('')
setMembers(INITIAL_MEMBERS)
setLocalError('')
}, [open])
useEffect(() => {
if (!open || !result) return
if (result.ok) {
onClose()
return
}
setLocalError(result.error || 'Failed to create organization')
}, [open, result, onClose])
const organizationValid = organizationName.trim().length > 0
const validMembers = useMemo(
() => members.map((member, index) => ({ member, index })).filter(item => item.member.name.trim()),
[members],
)
const originalIndexToCreateIndex = useMemo(
() => new Map(validMembers.map((item, createIndex) => [item.index, createIndex])),
[validMembers],
)
const membersValid = validMembers.length >= 2
const canCreate = organizationValid && membersValid && !pending
const previewMembers = useMemo(
() => validMembers.map(({ member, index }, createIndex) => {
const mappedParent = member.reportsToIndex == null ? null : originalIndexToCreateIndex.get(member.reportsToIndex)
return {
...member,
roleName: slugLabel(member.name),
managerName: mappedParent != null && mappedParent < createIndex
? slugLabel(validMembers[mappedParent]?.member.name || '')
: 'Owner',
index,
}
}),
[originalIndexToCreateIndex, validMembers],
)
if (!open) return null
const updateMember = (index: number, patch: Partial<MemberDraft>) => {
setMembers(prev => prev.map((member, idx) => idx === index ? { ...member, ...patch } : member))
}
const addMember = () => {
setMembers(prev => [...prev, { name: '', responsibility: '', prompt: '', reportsToIndex: 0 }])
}
const removeMember = (index: number) => {
setMembers(prev => {
const next = prev.filter((_, idx) => idx !== index)
return next.map((member, idx) => ({
...member,
reportsToIndex: member.reportsToIndex == null
? null
: member.reportsToIndex >= index
? Math.max(0, member.reportsToIndex - 1)
: member.reportsToIndex,
})).map((member, idx) => idx === 0 ? { ...member, reportsToIndex: null } : member)
})
}
const submit = () => {
if (!canCreate) return
setLocalError('')
onCreate(
organizationName.trim(),
validMembers.map(({ member }, createIndex) => {
const mappedParent = member.reportsToIndex == null ? null : originalIndexToCreateIndex.get(member.reportsToIndex)
return {
name: member.name.trim(),
responsibility: member.responsibility.trim(),
prompt: member.prompt.trim(),
reports_to_index: mappedParent != null && mappedParent < createIndex
? mappedParent
: createIndex === 0 ? null : 0,
}
}),
)
}
return (
<div className="org-create-backdrop" role="presentation" onMouseDown={onClose}>
<div className="org-create-modal" role="dialog" aria-modal="true" aria-labelledby="org-create-title" onMouseDown={e => e.stopPropagation()}>
<div className="org-create-header">
<div>
<span className="org-create-eyebrow">New organization</span>
<h3 id="org-create-title" className="org-create-title">Create a saved org</h3>
</div>
<button type="button" className="org-create-close" onClick={onClose} aria-label="Close">x</button>
</div>
<div className="org-create-steps" aria-label="Create organization steps">
{[
['1', 'Name'],
['2', 'Members'],
['3', 'Review'],
].map(([id, label]) => (
<span key={id} className={`org-create-step${step === Number(id) ? ' org-create-step--active' : step > Number(id) ? ' org-create-step--done' : ''}`}>
<span>{id}</span>{label}
</span>
))}
</div>
{step === 1 && (
<div className="org-create-panel">
<label className="org-create-field">
<span>Organization name</span>
<input
value={organizationName}
onChange={e => setOrganizationName(e.target.value)}
placeholder="HKU Research Lab"
autoFocus
/>
</label>
</div>
)}
{step === 2 && (
<div className="org-create-panel">
<div className="org-create-member-list">
{members.map((member, index) => (
<div className="org-create-member-row" key={index}>
<input
value={member.name}
onChange={e => updateMember(index, { name: e.target.value })}
placeholder={index === 0 ? 'Lead role' : 'Member role'}
/>
<input
value={member.responsibility}
onChange={e => updateMember(index, { responsibility: e.target.value })}
placeholder="Responsibility"
/>
<select
value={member.reportsToIndex == null ? 'owner' : String(member.reportsToIndex)}
onChange={e => updateMember(index, { reportsToIndex: e.target.value === 'owner' ? null : Number(e.target.value) })}
disabled={index === 0}
aria-label="Reports to"
>
<option value="owner">Owner</option>
{members.slice(0, index).map((candidate, candidateIndex) => (
<option key={candidateIndex} value={candidateIndex}>
{slugLabel(candidate.name)}
</option>
))}
</select>
<button
type="button"
className="org-create-icon-btn"
onClick={() => removeMember(index)}
disabled={members.length <= 2}
title="Remove member"
aria-label="Remove member"
>
-
</button>
<textarea
value={member.prompt}
onChange={e => updateMember(index, { prompt: e.target.value })}
placeholder="Prompt optional"
aria-label={`${index === 0 ? 'Lead role' : 'Member role'} prompt optional`}
/>
</div>
))}
</div>
<button type="button" className="org-create-add" onClick={addMember}>+ Add member</button>
</div>
)}
{step === 3 && (
<div className="org-create-panel">
<div className="org-create-review">
<div className="org-create-review-head">
<span>{organizationName.trim()}</span>
<b>{validMembers.length} members</b>
</div>
{previewMembers.map(member => (
<div className="org-create-review-row" key={member.index}>
<strong>{member.roleName}</strong>
<span>
{member.managerName}
{member.prompt.trim() ? <em>Prompt</em> : null}
</span>
</div>
))}
</div>
</div>
)}
{localError && <div className="org-create-error">{localError}</div>}
<div className="org-create-actions">
<button type="button" className="btn btn-ghost" onClick={step === 1 ? onClose : () => setStep(step - 1)}>
{step === 1 ? 'Cancel' : 'Back'}
</button>
{step < 3 ? (
<button
type="button"
className="btn btn-primary"
onClick={() => setStep(step + 1)}
disabled={step === 1 ? !organizationValid : !membersValid}
>
Next
</button>
) : (
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canCreate}>
{pending ? 'Creating...' : 'Create organization'}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,128 @@
/**
* Structural regression test for OrgTab's 4-tab layout.
*
* Guards the sub-tab rename (flow → runtime, marketplace → architecture +
* employees) and the three new marketplace panels. Reads OrgTab.tsx as
* source text and asserts against it — the existing zero-framework test
* convention (see runtimeOrg.test.ts, workItemSessions.test.ts) runs with
* plain `tsx` and requires no vitest / jsdom / @testing-library install.
*
* Why source-scan instead of React render:
* OrgTab.tsx imports './org.css'; Node can't load CSS without a vite
* transform. A source-scan catches the primary regression concerns
* (tab label rename, legacy label removal, panel import presence,
* default active tab) without pulling in a test runtime.
*
* Run with:
* tsx opc/plugins/office_ui/frontend_src/org/OrgTab.test.tsx
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(join(here, 'OrgTab.tsx'), 'utf8')
const createModalSrc = readFileSync(join(here, 'OrgCreateModal.tsx'), 'utf8')
const visualTypesSrc = readFileSync(join(here, '..', 'types', 'visual.ts'), 'utf8')
const orgCssSrc = readFileSync(join(here, 'org.css'), 'utf8')
// ── 1. Four sub-tab labels declared ──
for (const label of ['Team', 'Runtime', 'Architecture', 'Employees']) {
assert.match(
src,
new RegExp(`label:\\s*['"]${label}['"]`),
`OrgTab.tsx must declare tab label "${label}" (sub-tab rename regression)`,
)
}
// ── 2. Legacy labels removed ──
assert.doesNotMatch(
src,
/label:\s*['"]Marketplace['"]/,
'OrgTab.tsx must NOT declare legacy "Marketplace" sub-tab label',
)
assert.doesNotMatch(
src,
/label:\s*['"]Flow['"]/,
'OrgTab.tsx must NOT declare legacy "Flow" sub-tab label',
)
// ── 3. Active tabs typed against the new SubTab union ──
assert.match(
src,
/type\s+SubTab\s*=\s*['"]team['"]\s*\|\s*['"]runtime['"]\s*\|\s*['"]architecture['"]\s*\|\s*['"]employees['"]/,
'OrgTab.tsx must define SubTab = "team" | "runtime" | "architecture" | "employees"',
)
// ── 4. The three marketplace panels are imported ──
for (const comp of [
'ArchitectureMarketplace',
'EmployeesMarketplace',
'ConfigImportExportPanel',
]) {
assert.match(
src,
new RegExp(`import\\s*\\{\\s*${comp}\\s*\\}\\s*from\\s*['"]\\./${comp}['"]`),
`OrgTab.tsx must import ${comp} from './${comp}'`,
)
}
// ── 5. Default active tab is 'team' ──
assert.match(
src,
/useState<SubTab>\(\s*['"]team['"]\s*\)/,
'OrgTab.tsx must initialize activeTab state to "team"',
)
// ── 6. data-testid wired on each marketplace panel root ──
for (const [file, testId] of [
['EmployeesMarketplace.tsx', 'employees-marketplace'],
['ArchitectureMarketplace.tsx', 'architecture-marketplace'],
['ConfigImportExportPanel.tsx', 'config-import-export-panel'],
] as const) {
const panelSrc = readFileSync(join(here, file), 'utf8')
assert.match(
panelSrc,
new RegExp(`data-testid="${testId}"`),
`${file} root must carry data-testid="${testId}"`,
)
}
// ── 7. Create-org prompt is optional and carried in the member payload ──
assert.match(
visualTypesSrc,
/prompt\?:\s*string/,
'OrgCreateMemberInput must allow an optional prompt field',
)
assert.match(
createModalSrc,
/<textarea[\s\S]+placeholder="Prompt optional"/,
'OrgCreateModal must render an optional prompt textarea for each role',
)
assert.match(
createModalSrc,
/prompt:\s*member\.prompt\.trim\(\)/,
'OrgCreateModal submit payload must trim and include each role prompt',
)
assert.doesNotMatch(
createModalSrc,
/membersValid[\s\S]{0,120}prompt/,
'OrgCreateModal must not require prompt text for member validity',
)
// ── 8. Native select option popovers must have readable themed colors ──
assert.match(
orgCssSrc,
/\.org-switcher-select option\s*\{[\s\S]*background:\s*var\(--bg-elevated\);[\s\S]*color:\s*var\(--text\);/,
'Organization select options must use explicit themed colors',
)
assert.match(
orgCssSrc,
/\.org-create-member-row select option\s*\{[\s\S]*background:\s*var\(--bg-elevated\);[\s\S]*color:\s*var\(--text\);/,
'Create-org reports-to select options must use explicit themed colors',
)
console.log(
'OrgTab.test.tsx: OK (tabs, marketplace panels, create-org prompt, select option theme colors)',
)
@@ -0,0 +1,436 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
OrgInfoPayload,
OrgCreateMemberInput,
OrgSavedCreatePayload,
SavedOrgSummary,
TalentTemplate,
EmployeeDetailPayload,
ReorgProposalInfo,
ArchitecturePreset,
ArchitecturePresetDetail,
HireTalentHandler,
} from '../types/visual'
import { TeamView } from './TeamView'
import { DelegationStrategyPanel } from './DelegationStrategyPanel'
import { ArchitectureMarketplace } from './ArchitectureMarketplace'
import { EmployeesMarketplace } from './EmployeesMarketplace'
import { ConfigImportExportPanel } from './ConfigImportExportPanel'
import { OrgCreateModal } from './OrgCreateModal'
import { getRuntimeOrgView } from '../lib/runtimeOrg'
import './org.css'
import './team.css'
import './marketplace.css'
import './config.css'
import './structure.css'
/* ── Inline SVG icon data-URIs (no external CDN) ────────────────── */
const TAB_ICON = {
team: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z'/%3E%3C/svg%3E",
runtime: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M13 2.05v2.02c3.95.49 7 3.85 7 7.93 0 3.21-1.81 6-4.72 7.72L13 17v5h5l-1.22-1.22C19.91 19.07 22 15.76 22 12c0-5.18-3.95-9.45-9-9.95zM11 2.05C5.95 2.55 2 6.82 2 12c0 3.76 2.09 7.07 5.22 8.78L6 22h5V2.05z'/%3E%3C/svg%3E",
architecture: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z'/%3E%3C/svg%3E",
employees: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
}
const STAT_ICON = {
agents: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20 9V7c0-1.1-.9-2-2-2h-3c0-1.66-1.34-3-3-3S9 3.34 9 5H6c-1.1 0-2 .9-2 2v2c-1.66 0-3 1.34-3 3s1.34 3 3 3v4c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-4c1.66 0 3-1.34 3-3s-1.34-3-3-3z'/%3E%3C/svg%3E",
active: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z'/%3E%3C/svg%3E",
}
const SECTION_ICON = {
packages: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z'/%3E%3C/svg%3E",
channels: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z'/%3E%3C/svg%3E",
reorg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z'/%3E%3C/svg%3E",
importPkg: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z'/%3E%3C/svg%3E",
}
type SubTab = 'team' | 'runtime' | 'architecture' | 'employees'
function humanizeOrgName(value?: string | null): string {
const normalized = String(value ?? '').trim()
if (!normalized) return ''
return normalized
.replace(/^org[_-]/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, char => char.toUpperCase())
}
interface OrgTabProps {
data: OrgInfoPayload | null
/** role_id -> recruited names for the selected session (canvas display only). */
sessionRecruitmentByRole?: Record<string, string[]> | null
talents: TalentTemplate[]
employeeDetail: EmployeeDetailPayload | null
reorgProposals: ReorgProposalInfo[]
isCustomMode?: boolean
onRequestData: () => void
onRequestTalents: () => void
onRequestEmployeeDetail: (employeeId: string) => void
onHireTalent: HireTalentHandler
hiringTemplateId?: string | null
onImportEmployee?: (employeeId: string) => void
onRequestReorgList: () => void
onReorgDecide: (proposalId: string, approved: boolean, notes?: string) => void
// Market
onMarketExport?: (data: { package_id: string; name: string; description: string; version: string }) => void
onMarketInstall?: (path: string, strategy: string) => void
onMarketUninstall?: (packageId: string) => void
// Architecture gallery
marketPresets?: ArchitecturePreset[]
marketPreviewData?: ArchitecturePresetDetail | null
onMarketBrowse?: () => void
onMarketPreview?: (presetId: string) => void
onMarketApplyPreset?: (presetId: string, strategy: string) => void
onMarketClearPreview?: () => void
// Config import/export
onConfigExport?: () => void
onConfigImport?: (yaml: string, dryRun: boolean) => void
configExportYaml?: string | null
configImportPreview?: { roles_added: number; roles_removed: number; employees_changed: number } | null
configImportError?: string | null
// Saved org architectures (named snapshots) — rendered in the Team tab toolbar
onSavedOrgsList?: () => void
onSavedOrgSaveAs?: (name: string, overwrite: boolean) => void
onSavedOrgCreate?: (organizationName: string, members: OrgCreateMemberInput[]) => void
onSavedOrgLoad?: (name: string) => void
onSavedOrgDelete?: (name: string) => void
savedOrgsList?: SavedOrgSummary[] | null
activeSavedOrg?: string | null
activeSavedOrgVersionAtLoad?: number | null
orgCreatePending?: boolean
orgCreateResult?: (OrgSavedCreatePayload & { nonce: number }) | null
onSelectCorporate?: () => void
// Org editing
onAddRole?: (roleId: string, name: string, responsibility: string, reportsTo: string, icon?: string | null) => void
onBulkAddRoles?: (roles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string }>) => void
onUpdateRole?: (roleId: string, updates: { name?: string; responsibility?: string; reports_to?: string; can_spawn?: string[]; icon?: string | null; execution_strategy?: string; preferred_external_agent?: string | null; prompt_refs?: string[] }) => void
onDeleteRole?: (roleId: string) => void
onUpdateOrgStrategy?: (data: { final_decider_role_id?: string | null }) => void
onUpdateRuntimePolicy?: (policy: Record<string, any>) => void
onResetArchitecture?: () => void
}
export function OrgTab({
data, sessionRecruitmentByRole, talents, employeeDetail, reorgProposals, isCustomMode,
onRequestData, onRequestTalents, onRequestEmployeeDetail,
onHireTalent, hiringTemplateId, onImportEmployee, onRequestReorgList, onReorgDecide,
onMarketExport, onMarketInstall, onMarketUninstall,
marketPresets, marketPreviewData, onMarketBrowse, onMarketPreview, onMarketApplyPreset, onMarketClearPreview,
onAddRole, onBulkAddRoles, onUpdateRole, onDeleteRole, onUpdateOrgStrategy,
onUpdateRuntimePolicy, onResetArchitecture,
onConfigExport, onConfigImport, configExportYaml, configImportPreview, configImportError,
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgCreate, onSavedOrgLoad, onSavedOrgDelete, savedOrgsList,
activeSavedOrg, activeSavedOrgVersionAtLoad, orgCreatePending, orgCreateResult, onSelectCorporate,
}: OrgTabProps) {
const [activeTab, setActiveTab] = useState<SubTab>('team')
const [createOpen, setCreateOpen] = useState(false)
const switchTab = useCallback((tab: SubTab) => {
setActiveTab(tab)
}, [])
const [applyingPresetId, setApplyingPresetId] = useState<string | null>(null)
const versionAtApply = useRef<number>(-1) // track org_version when apply starts
const [toast, setToast] = useState<{ msg: string; type: 'info' | 'warn' } | null>(null)
const toastTimer = useRef<ReturnType<typeof setTimeout>>(null)
const showToast = useCallback((msg: string, type: 'info' | 'warn' = 'info') => {
setToast({ msg, type })
if (toastTimer.current) clearTimeout(toastTimer.current)
toastTimer.current = setTimeout(() => setToast(null), 3000)
}, [])
useEffect(() => () => {
if (toastTimer.current) clearTimeout(toastTimer.current)
}, [])
const onRequestDataRef = useRef(onRequestData)
const onRequestTalentsRef = useRef(onRequestTalents)
const onRequestReorgListRef = useRef(onRequestReorgList)
const onMarketBrowseRef = useRef(onMarketBrowse)
const onSavedOrgsListRef = useRef(onSavedOrgsList)
onRequestDataRef.current = onRequestData
onRequestTalentsRef.current = onRequestTalents
onRequestReorgListRef.current = onRequestReorgList
onMarketBrowseRef.current = onMarketBrowse
onSavedOrgsListRef.current = onSavedOrgsList
useEffect(() => {
onRequestDataRef.current()
onRequestTalentsRef.current()
onRequestReorgListRef.current()
onMarketBrowseRef.current?.()
onSavedOrgsListRef.current?.()
}, [])
useEffect(() => {
if (!orgCreateResult || !orgCreateResult.ok) return
setCreateOpen(false)
setActiveTab('team')
showToast(`Created ${orgCreateResult.organization_name || orgCreateResult.name} and saved automatically`)
}, [orgCreateResult, showToast])
// In org mode: show only user-owned roles. In company mode: show all roles read-only.
const allRoles = data?.roles ?? []
const displayRoles = useMemo(() => isCustomMode ? allRoles.filter(r => !r.is_builtin) : allRoles, [allRoles, isCustomMode])
const displayEmployees = useMemo(() => data?.employees ?? [], [data?.employees])
const runtimeView = useMemo(() => getRuntimeOrgView(data), [data])
const activeAgents = useMemo(() => displayEmployees.filter(e => e.linked_agent_id), [displayEmployees])
const configuredOrgName = data?.organization_name?.trim()
const activeOrgLabel = configuredOrgName || humanizeOrgName(activeSavedOrg) || (isCustomMode ? 'Custom org' : 'Corporate company')
const activeOrgId = (isCustomMode ? (data?.organization_id || activeSavedOrg) : 'corporate') || ''
const architectureKindLabel = isCustomMode ? 'Saved org' : 'Corporate'
const architectureStateLabel = isCustomMode
? activeSavedOrg ? 'Editable saved architecture' : 'Editable draft architecture'
: 'Built-in read-only architecture'
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || 'ready'
// Roles that already have at least one non-placeholder employee.
const filledRoleIds = useMemo(
() => {
const ids = new Set<string>()
for (const employee of displayEmployees) {
if (employee.is_default_employee) continue
const roleIds = employee.role_ids?.length ? employee.role_ids : [employee.role_id]
for (const roleId of roleIds) {
if (roleId) ids.add(roleId)
}
}
return ids
},
[displayEmployees],
)
const vacantRoles = useMemo(() => displayRoles.filter(r => !filledRoleIds.has(r.role_id)), [displayRoles, filledRoleIds])
const installedIds = useMemo(() => new Set((data?.installed_packages ?? []).map(p => p.package_id)), [data?.installed_packages])
// When applying a preset, wait for org_version to change (every config.save() increments it)
const orgVersion = data?.org_version ?? 0
useEffect(() => {
if (applyingPresetId && versionAtApply.current >= 0 && orgVersion !== versionAtApply.current) {
setApplyingPresetId(null)
versionAtApply.current = -1
setActiveTab('team')
showToast('Architecture applied successfully')
}
}, [orgVersion, applyingPresetId, showToast])
const handleApplyPreset = (presetId: string, strategy: string) => {
versionAtApply.current = orgVersion // snapshot current version
setApplyingPresetId(presetId)
onMarketApplyPreset?.(presetId, strategy)
}
const handleOrgSelection = (value: string) => {
if (value === 'corporate') {
onSelectCorporate?.()
return
}
if (value.startsWith('org:')) {
const orgName = value.slice(4)
if (orgName) onSavedOrgLoad?.(orgName)
}
}
if (!data) {
return <div className="org-tab"><div className="org-loading">Loading organization data...</div></div>
}
const installedPackages = data.installed_packages ?? []
const savedOrgOptions = savedOrgsList ?? []
const selectedOrgValue = isCustomMode && activeSavedOrg ? `org:${activeSavedOrg}` : 'corporate'
return (
<div className="org-tab">
<div className={`org-header${isCustomMode ? ' org-header--custom' : ' org-header--corporate'}`}>
<div className="org-header-main">
<div className="org-eyebrow">
<span>Company</span>
<span className="org-eyebrow-separator">/</span>
<span>{architectureKindLabel}</span>
</div>
<div className="org-title-row">
<h2 className="org-title">{activeOrgLabel}</h2>
<span className="org-version-badge">v{data.org_version}</span>
<span className={`org-state-badge${isCustomMode ? ' org-state-badge--editable' : ' org-state-badge--readonly'}`}>
{architectureStateLabel}
</span>
</div>
<div className="org-header-meta">
<span className="org-meta-pill">{data.company_profile || (isCustomMode ? 'custom' : 'corporate')}</span>
{activeOrgId && <code className="org-meta-code">{activeOrgId}</code>}
<span className="org-meta-pill org-meta-pill--runtime">{runtimeStateLabel}</span>
<span className="org-meta-pill org-meta-pill--saved">Auto-saved</span>
</div>
</div>
<div className="org-control-panel">
<label className="org-switcher">
<span className="org-switcher-label">Organization</span>
<span className="org-switcher-select-wrap">
<select
className="org-switcher-select"
value={selectedOrgValue}
onChange={e => handleOrgSelection(e.target.value)}
onFocus={() => onSavedOrgsList?.()}
onPointerDown={() => onSavedOrgsList?.()}
aria-label="Organization"
>
<option value="corporate">Corporate</option>
{savedOrgOptions.map(org => (
<option key={org.name} value={`org:${org.name}`}>
{(org.organization_name || org.name).trim() || org.name}
</option>
))}
</select>
</span>
</label>
<button type="button" className="org-create-trigger" onClick={() => setCreateOpen(true)}>
<span className="org-create-trigger-icon" aria-hidden>+</span>
New organization
</button>
</div>
<div className="org-stats-strip">
<span className="org-stat">
<img src={STAT_ICON.agents} alt="" className="org-stat-icon" />
<b>{displayRoles.length}</b> roles
</span>
<span className="org-stat">
<img src={TAB_ICON.employees} alt="" className="org-stat-icon" />
<b>{displayEmployees.length}</b> employees
</span>
<span className="org-stat">
<img src={TAB_ICON.runtime} alt="" className="org-stat-icon" />
<b>{runtimeView.runtimeTeams.length}</b> runtime teams
</span>
<span className="org-stat">
<img src={STAT_ICON.active} alt="" className="org-stat-icon" />
<b>{activeAgents.length}</b> active
</span>
</div>
</div>
<div className="org-subtabs">
{([
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: 'Team', count: displayRoles.length },
{ id: 'runtime' as SubTab, icon: TAB_ICON.runtime, label: 'Runtime', count: runtimeView.runtimeTeams.length },
{ id: 'architecture' as SubTab, icon: TAB_ICON.architecture, label: 'Architecture', count: marketPresets?.length ?? 0 },
{ id: 'employees' as SubTab, icon: TAB_ICON.employees, label: 'Employees', count: talents.length },
]).map(tab => (
<button key={tab.id}
className={`org-subtab${activeTab === tab.id ? ' org-subtab--active' : ''}`}
onClick={() => switchTab(tab.id)}>
<img src={tab.icon} alt="" className="org-subtab-icon" />
<span className="org-subtab-label">{tab.label}</span>
<span className="org-subtab-count">{tab.count}</span>
</button>
))}
</div>
{/* ── Tab content ─────────────────────────────────── */}
{/* Toast notification */}
{toast && (
<div className={`org-toast org-toast--${toast.type}`} onClick={() => setToast(null)}>
{toast.msg}
</div>
)}
<div className="org-tab-content">
{/* Team tab */}
{activeTab === 'team' && (
<TeamView
roles={displayRoles}
employees={displayEmployees}
sessionRecruitmentByRole={sessionRecruitmentByRole}
isCustomMode={isCustomMode}
onAddRole={onAddRole ?? (() => {})}
onBulkAddRoles={onBulkAddRoles}
onUpdateRole={onUpdateRole ?? (() => {})}
onDeleteRole={onDeleteRole ?? (() => {})}
onExport={onMarketExport ?? (() => {})}
onImportEmployee={onImportEmployee}
onResetArchitecture={onResetArchitecture}
onSwitchToTab={(target) => setActiveTab(target)}
savedOrgsList={savedOrgsList}
activeSavedOrg={activeSavedOrg ?? null}
currentOrgVersion={data?.org_version ?? 0}
versionAtLoad={activeSavedOrgVersionAtLoad ?? null}
onSavedOrgsList={onSavedOrgsList}
onSavedOrgSaveAs={onSavedOrgSaveAs}
onSavedOrgLoad={onSavedOrgLoad}
onSavedOrgDelete={onSavedOrgDelete}
/>
)}
{/* Runtime tab */}
{activeTab === 'runtime' && (
<DelegationStrategyPanel
roles={displayRoles}
runtimeTeams={runtimeView.runtimeTeams}
runtimeSeats={runtimeView.runtimeSeats}
workItems={runtimeView.workItems}
frontier={runtimeView.frontier}
companyProfile={data.company_profile}
runtimePolicy={data.runtime_policy}
finalDeciderRoleId={data.final_decider_role_id}
topLevelRoleIds={data.top_level_role_ids}
readOnly={!isCustomMode}
onUpdateOrgStrategy={onUpdateOrgStrategy}
onUpdateRuntimePolicy={onUpdateRuntimePolicy}
/>
)}
{/* Architecture tab */}
{activeTab === 'architecture' && (
<>
<ArchitectureMarketplace
presets={marketPresets ?? []}
installedIds={installedIds}
previewData={marketPreviewData ?? null}
applyingPresetId={applyingPresetId}
readOnly={!isCustomMode}
onPreview={onMarketPreview ?? (() => {})}
onApplyPreset={handleApplyPreset}
onClearPreview={onMarketClearPreview ?? (() => {})}
installedPackages={installedPackages}
channels={data.channels}
reorgProposals={reorgProposals}
isCustomMode={!!isCustomMode}
onReorgDecide={onReorgDecide}
onMarketInstall={(p, s) => onMarketInstall?.(p, s)}
onMarketUninstall={(id) => onMarketUninstall?.(id)}
/>
{isCustomMode && (
<ConfigImportExportPanel
onExport={onConfigExport ?? (() => {})}
onImport={onConfigImport ?? (() => {})}
configExportYaml={configExportYaml ?? null}
importPreview={configImportPreview ?? null}
importError={configImportError ?? null}
/>
)}
</>
)}
{/* Employees tab */}
{activeTab === 'employees' && (
<EmployeesMarketplace
templates={talents}
vacantRoles={vacantRoles}
hiringTemplateId={hiringTemplateId ?? null}
readOnly={!isCustomMode}
onHireTalent={onHireTalent}
/>
)}
</div>
<OrgCreateModal
open={createOpen}
pending={orgCreatePending}
result={orgCreateResult ?? null}
onClose={() => setCreateOpen(false)}
onCreate={(organizationName, members) => onSavedOrgCreate?.(organizationName, members)}
/>
</div>
)
}
@@ -0,0 +1,378 @@
/**
* OrgVersionSwitcher — editor-toolbar "version picker" for the org.
*
* Conceptually equivalent to Figma's page switcher or VS Code's git-branch
* indicator: a compact pill showing the active saved-org name (+ modified
* indicator when the editor has changed since the last loaded snapshot),
* plus a glass popover command-menu for search / load / save-as-copy
* / delete.
*
* All visual tokens match the house dialect (refined-technical, dark, no
* emoji, no purple gradients). Styles live in structure.css under `.sos-*`.
*
* Lives in the StructureEditor toolbar (Team tab, org mode only).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
interface SavedOrg {
name: string
organization_name?: string
saved_at: number
roles_count: number
employees_count: number
}
export interface OrgVersionSwitcherProps {
savedOrgs: SavedOrg[] | null
activeName: string | null
isDirty: boolean
onRefresh: () => void
onSaveAs: (name: string, overwrite: boolean) => void
onLoad: (name: string) => void
onDelete: (name: string) => void
}
const MAX_DISPLAY_NAME = 80
function formatRelativeTime(epochSeconds: number): string {
const now = Date.now() / 1000
const diff = Math.max(0, now - epochSeconds)
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
try {
return new Date(epochSeconds * 1000).toLocaleDateString()
} catch {
return ''
}
}
function displayOrgName(org: SavedOrg): string {
return (org.organization_name || org.name).trim() || org.name
}
function isValidDisplayName(value: string): boolean {
const trimmed = value.trim()
return trimmed.length > 0
&& trimmed.length <= MAX_DISPLAY_NAME
&& !/[\\/]/.test(trimmed)
&& !/[\u0000-\u001f]/.test(trimmed)
}
function slugifyOrgDisplayName(value: string): string {
const ascii = value.normalize('NFKD').replace(/[^\x00-\x7F]/g, '')
const slug = ascii
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^[_-]+|[_-]+$/g, '')
return slug.slice(0, 64) || 'org'
}
/* Inline SVG glyphs — match the house convention (no emoji, no font icons). */
function LayersGlyph({ className }: { className?: string }) {
return (
<svg className={className} width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M8 1.5L1.5 4.5L8 7.5L14.5 4.5L8 1.5Z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<path d="M2 8L8 10.8L14 8" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" opacity="0.6" />
<path d="M2 11.5L8 14.2L14 11.5" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" opacity="0.35" />
</svg>
)
}
function Caret({ className }: { className?: string }) {
return (
<svg className={className} width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function SearchGlyph({ className }: { className?: string }) {
return (
<svg className={className} width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
<circle cx="5" cy="5" r="3.2" stroke="currentColor" strokeWidth="1.3" />
<path d="M7.5 7.5L10 10" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
)
}
export function OrgVersionSwitcher({
savedOrgs, activeName, isDirty, onRefresh, onSaveAs, onLoad, onDelete,
}: OrgVersionSwitcherProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [highlighted, setHighlighted] = useState(0)
const [saveAsMode, setSaveAsMode] = useState(false)
const [saveAsName, setSaveAsName] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [loadingName, setLoadingName] = useState<string | null>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const saveInputRef = useRef<HTMLInputElement>(null)
// Clear loading state once the list or activeName updates (proxy for ack).
useEffect(() => { setLoadingName(null) }, [activeName, savedOrgs])
// Filter list by search.
const filtered = (savedOrgs ?? []).filter(o =>
!search.trim() || o.name.toLowerCase().includes(search.trim().toLowerCase()),
)
// Refresh once on first open.
const firstOpenRef = useRef(false)
useEffect(() => {
if (open && !firstOpenRef.current) {
firstOpenRef.current = true
onRefresh()
}
}, [open, onRefresh])
// Auto-focus save input when entering save-as mode.
useEffect(() => {
if (saveAsMode) saveInputRef.current?.focus()
}, [saveAsMode])
// Clamp highlight when filtered list shrinks.
useEffect(() => {
setHighlighted(h => Math.min(h, Math.max(0, filtered.length - 1)))
}, [filtered.length])
// Close on outside click.
useEffect(() => {
if (!open) return
const onDocClick = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
closePopover()
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const closePopover = useCallback(() => {
setOpen(false)
setSearch('')
setHighlighted(0)
setSaveAsMode(false)
setSaveAsName('')
setConfirmDelete(null)
}, [])
const handleLoad = useCallback((name: string) => {
if (name === activeName) {
// Already active — no need to round-trip; just close the popover.
closePopover()
return
}
setLoadingName(name)
onLoad(name)
closePopover()
}, [onLoad, closePopover, activeName])
const handleDelete = useCallback((name: string) => {
onDelete(name)
setConfirmDelete(null)
}, [onDelete])
const saveAsTrimmed = saveAsName.trim()
const saveAsValid = isValidDisplayName(saveAsTrimmed)
const saveAsSlug = slugifyOrgDisplayName(saveAsTrimmed)
const saveAsExists = (savedOrgs ?? []).some(o =>
o.name === saveAsSlug || displayOrgName(o).toLowerCase() === saveAsTrimmed.toLowerCase(),
)
const handleSaveAs = useCallback(() => {
if (!saveAsValid) return
onSaveAs(saveAsTrimmed, saveAsExists)
setSaveAsMode(false)
setSaveAsName('')
closePopover()
}, [saveAsValid, saveAsTrimmed, saveAsExists, onSaveAs, closePopover])
// Keyboard navigation inside the popover.
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
closePopover()
return
}
if (saveAsMode) {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveAs()
}
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlighted(h => Math.min(h + 1, Math.max(0, filtered.length - 1)))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlighted(h => Math.max(0, h - 1))
} else if (e.key === 'Enter') {
const target = filtered[highlighted]
if (target) handleLoad(target.name)
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
const target = filtered[highlighted]
if (target) {
e.preventDefault()
setConfirmDelete(target.name)
}
}
}
return (
<div className="sos-wrap" ref={wrapperRef}>
<button
type="button"
className="sos-pill"
data-open={open ? 'true' : 'false'}
onClick={() => setOpen(o => !o)}
aria-haspopup="listbox"
aria-expanded={open}
title={activeName ? `Active saved architecture: ${activeName}` : 'No saved architecture loaded'}
>
<LayersGlyph className="sos-pill-glyph" />
{activeName ? (
<span className="sos-pill-name">{activeName}</span>
) : (
<span className="sos-pill-name sos-pill-name--placeholder">draft</span>
)}
{isDirty && <span className="sos-pill-dirty" aria-label="Modified since opened" />}
<Caret className="sos-pill-caret" />
</button>
{open && (
<div className="sos-popover" role="listbox" onKeyDown={onKeyDown} tabIndex={-1}>
<div className="sos-search-row">
<SearchGlyph className="sos-search-glyph" />
<input
type="text"
className="sos-search-input"
placeholder="Search architectures…"
value={search}
onChange={e => { setSearch(e.target.value); setHighlighted(0) }}
autoFocus
onKeyDown={onKeyDown}
/>
<kbd className="sos-search-hint"> </kbd>
</div>
<div className="sos-list">
{savedOrgs === null ? (
<div className="sos-empty">Loading</div>
) : filtered.length === 0 ? (
<div className="sos-empty">
{(savedOrgs ?? []).length === 0 ? 'No saved architectures.' : 'No matches.'}
</div>
) : (
filtered.map((org, idx) => {
const isActive = activeName === org.name
const isHighlighted = highlighted === idx
const title = displayOrgName(org)
return (
<div
key={org.name}
className={`sos-row${isActive ? ' sos-row--active' : ''}`}
data-highlighted={isHighlighted ? 'true' : 'false'}
onMouseEnter={() => setHighlighted(idx)}
onClick={() => handleLoad(org.name)}
role="option"
aria-selected={isActive}
>
<div className="sos-row-meta">
<span className="sos-row-name">{title}</span>
<span className="sos-row-stats">
{title !== org.name && `${org.name} · `}
{org.roles_count} {org.roles_count === 1 ? 'role' : 'roles'}
{' · '}
{org.employees_count} {org.employees_count === 1 ? 'employee' : 'employees'}
{' · '}
{formatRelativeTime(org.saved_at)}
</span>
</div>
{loadingName === org.name ? (
<span className="sos-row-loading">loading</span>
) : isActive ? (
<span className="sos-row-active-chip">active</span>
) : confirmDelete === org.name ? (
<button
type="button"
className="sos-row-delete sos-row-delete--confirm"
onClick={e => { e.stopPropagation(); handleDelete(org.name) }}
>
confirm
</button>
) : (
<button
type="button"
className="sos-row-delete"
onClick={e => { e.stopPropagation(); setConfirmDelete(org.name) }}
title="Delete"
>
delete
</button>
)}
</div>
)
})
)}
</div>
<div className="sos-save-as">
{saveAsMode ? (
<div className="sos-save-as-form">
<input
ref={saveInputRef}
type="text"
className="sos-save-as-input"
placeholder="Organization name"
value={saveAsName}
onChange={e => setSaveAsName(e.target.value)}
onKeyDown={onKeyDown}
/>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSaveAs}
disabled={!saveAsValid}
>
{saveAsExists ? 'Overwrite copy' : 'Save as copy'}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => { setSaveAsMode(false); setSaveAsName('') }}
>
Cancel
</button>
</div>
) : (
<button
type="button"
className="sos-save-as-trigger"
onClick={() => setSaveAsMode(true)}
>
+ Save as copy...
</button>
)}
{saveAsMode && saveAsName && !saveAsValid && (
<div className="sos-save-as-hint sos-save-as-hint--warn">
Use up to 80 characters. Slashes are not allowed.
</div>
)}
{saveAsMode && saveAsValid && saveAsExists && (
<div className="sos-save-as-hint sos-save-as-hint--warn">
Name exists - this will overwrite that copy.
</div>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,56 @@
import type { InstalledPackageInfo } from '../types/visual'
interface PackageCardProps {
pkg: InstalledPackageInfo
onUninstall?: (packageId: string) => void
uninstallingId?: string | null
}
export function PackageCard({ pkg, onUninstall, uninstallingId }: PackageCardProps) {
const isUninstalling = uninstallingId === pkg.package_id
return (
<div className="pkg-card">
<div className="pkg-card-header">
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z'/%3E%3C/svg%3E"
alt="package"
className="pkg-card-icon"
/>
<div className="pkg-card-title-wrap">
<span className="pkg-card-name">{pkg.name || pkg.package_id}</span>
<span className="pkg-card-version">v{pkg.version}</span>
</div>
</div>
<div className="pkg-card-stats">
<span className="pkg-card-stat">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E" alt="roles" className="pkg-stat-icon" />
{pkg.role_ids.length} roles
</span>
<span className="pkg-card-stat">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.11-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E" alt="templates" className="pkg-stat-icon" />
{pkg.template_ids.length} templates
</span>
</div>
{pkg.installed_at && (
<div className="pkg-card-date">
Installed {new Date(pkg.installed_at).toLocaleDateString()}
</div>
)}
{onUninstall && (
<div className="pkg-card-actions">
<button
className="pkg-btn pkg-btn-danger"
disabled={isUninstalling}
onClick={(e) => { e.stopPropagation(); onUninstall(pkg.package_id) }}
>
{isUninstalling ? 'Removing...' : 'Uninstall'}
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,468 @@
/**
* RoleInspector — Figma-style floating property panel (D4).
*
* Renders on the right of the StructureEditor when a role is selected.
* Six collapsible groups:
* 1. Identity (expanded by default): name, responsibility, icon
* 2. Hierarchy (expanded by default): reports_to, can_spawn
* 3. Tools (collapsed): 22-item checklist grouped by prefix
* 4. Prompts (collapsed): textarea (one prompt_ref per line)
* 5. Runtime (collapsed): execution_strategy, preferred_external_agent
* 6. Advanced (collapsed): role_type, skill_refs, artifact_contract_ref
*
* Edits are debounced 500ms and batched into a single onUpdateRole call per
* quiescence window.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { OrgRole, OrgEmployee } from '../types/visual'
import { ROLE_ICON_KEYS, ROLE_ICONS, resolveRoleIcon, type RoleIconKey } from './roleIcons'
/** Tool union derived from company_runtime_profiles.py _CORPORATE_*_TOOLS. */
const AVAILABLE_TOOLS = [
'file_read', 'file_write', 'file_edit', 'file_search', 'list_dir',
'shell_exec',
'web_search', 'web_fetch',
'todo_read', 'todo_write',
'browser_navigate', 'browser_navigate_back', 'browser_snapshot',
'browser_wait_for', 'browser_scroll', 'browser_click', 'browser_type',
'browser_select_option', 'browser_take_screenshot',
'browser_evaluate', 'browser_close',
] as const
const TOOL_GROUPS: { label: string; prefix: string; tools: readonly string[] }[] = [
{ label: 'Files', prefix: 'file_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('file_')) },
{ label: 'Shell', prefix: 'shell_', tools: AVAILABLE_TOOLS.filter(t => t === 'shell_exec') },
{ label: 'Web', prefix: 'web_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('web_')) },
{ label: 'TODOs', prefix: 'todo_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('todo_')) },
{ label: 'Browser', prefix: 'browser_', tools: AVAILABLE_TOOLS.filter(t => t.startsWith('browser_')) },
]
const EXTERNAL_AGENTS = ['codex', 'cursor', 'claude_code', 'opencode'] as const
const EXECUTION_STRATEGIES = [
{ value: 'auto', label: 'Auto', hint: 'System picks native or external based on role config' },
{ value: 'native', label: 'Native', hint: 'Run directly in-process via LLM' },
{ value: 'external', label: 'External', hint: 'Delegate to an external agent (codex, cursor, etc.)' },
] as const
/* ── Props ─────────────────────────────────────────────────────── */
interface RoleInspectorProps {
role: OrgRole
allRoles: OrgRole[]
employees: OrgEmployee[]
readOnly?: boolean
onUpdateRole: (roleId: string, updates: RoleUpdatePatch) => void
onDeleteRole: (roleId: string) => void
onClose: () => void
}
/** Matches the shape that App.tsx's onUpdateRole accepts; `tools` is forwarded
* via the same path (backend RoleConfig Pydantic model accepts it). */
export interface RoleUpdatePatch {
name?: string
responsibility?: string
reports_to?: string
can_spawn?: string[]
icon?: string | null
execution_strategy?: string
preferred_external_agent?: string | null
prompt_refs?: string[]
tools?: string[]
}
/* ── RoleInspector ─────────────────────────────────────────────── */
export function RoleInspector({
role, allRoles, employees, readOnly,
onUpdateRole, onDeleteRole, onClose,
}: RoleInspectorProps) {
const [name, setName] = useState(role.name)
const [responsibility, setResponsibility] = useState(role.responsibility)
const [reportsTo, setReportsTo] = useState(role.reports_to)
const [iconKey, setIconKey] = useState<string | null>(role.icon ?? null)
const [canSpawn, setCanSpawn] = useState<Set<string>>(() => new Set(role.can_spawn))
const [tools, setTools] = useState<Set<string>>(() => new Set(role.tools))
const [execStrategy, setExecStrategy] = useState<string>(
role.runtime_policy?.execution_strategy ?? 'auto',
)
const [extAgent, setExtAgent] = useState<string | null>(role.preferred_external_agent ?? null)
const [promptRefs, setPromptRefs] = useState<string>((role.prompt_refs ?? []).join('\n'))
const [confirmDelete, setConfirmDelete] = useState(false)
// Reset local state when selected role changes
const lastRoleIdRef = useRef(role.role_id)
useEffect(() => {
if (lastRoleIdRef.current === role.role_id) return
lastRoleIdRef.current = role.role_id
setName(role.name)
setResponsibility(role.responsibility)
setReportsTo(role.reports_to)
setIconKey(role.icon ?? null)
setCanSpawn(new Set(role.can_spawn))
setTools(new Set(role.tools))
setExecStrategy(role.runtime_policy?.execution_strategy ?? 'auto')
setExtAgent(role.preferred_external_agent ?? null)
setPromptRefs((role.prompt_refs ?? []).join('\n'))
setConfirmDelete(false)
}, [role])
/* ── Debounced save: batch fragments, fire once after 500ms quiescence ── */
const dirtyRef = useRef<RoleUpdatePatch>({})
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const roleIdRef = useRef(role.role_id)
useEffect(() => { roleIdRef.current = role.role_id }, [role.role_id])
const flush = useCallback(() => {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
const patch = dirtyRef.current
dirtyRef.current = {}
if (Object.keys(patch).length === 0) return
onUpdateRole(roleIdRef.current, patch)
}, [onUpdateRole])
const scheduleSave = useCallback((fragment: RoleUpdatePatch) => {
if (readOnly) return
dirtyRef.current = { ...dirtyRef.current, ...fragment }
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(flush, 500)
}, [flush, readOnly])
// Unmount flush — capture any pending edits so they are not lost
useEffect(() => {
return () => {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
const patch = dirtyRef.current
if (Object.keys(patch).length > 0) {
dirtyRef.current = {}
onUpdateRole(roleIdRef.current, patch)
}
}
}, [onUpdateRole])
/* ── Setters ───────────────────────────────────────────────── */
const handleNameChange = (v: string) => { setName(v); scheduleSave({ name: v }) }
const handleResponsibilityChange = (v: string) => { setResponsibility(v); scheduleSave({ responsibility: v }) }
const handleReportsToChange = (v: string) => { setReportsTo(v); scheduleSave({ reports_to: v }) }
const handleIconChange = (v: string | null) => { setIconKey(v); scheduleSave({ icon: v }) }
const handleExecStrategyChange = (v: string) => { setExecStrategy(v); scheduleSave({ execution_strategy: v }) }
const handleExtAgentChange = (v: string | null) => { setExtAgent(v); scheduleSave({ preferred_external_agent: v }) }
const handlePromptRefsChange = (v: string) => {
setPromptRefs(v)
const lines = v.split('\n').map(s => s.trim()).filter(Boolean)
scheduleSave({ prompt_refs: lines })
}
const toggleCanSpawn = (id: string) => {
const next = new Set(canSpawn)
if (next.has(id)) next.delete(id); else next.add(id)
setCanSpawn(next)
scheduleSave({ can_spawn: Array.from(next) })
}
const toggleTool = (toolName: string) => {
const next = new Set(tools)
if (next.has(toolName)) next.delete(toolName); else next.add(toolName)
setTools(next)
scheduleSave({ tools: Array.from(next) })
}
/* ── Derived ───────────────────────────────────────────────── */
const otherRoles = useMemo(
() => allRoles.filter(r => r.role_id !== role.role_id),
[allRoles, role.role_id],
)
const employeeCount = useMemo(
() => employees.filter(e => (e.role_ids?.length ? e.role_ids : [e.role_id]).includes(role.role_id)).length,
[employees, role.role_id],
)
const promptRefCount = useMemo(
() => promptRefs.split('\n').filter(s => s.trim()).length,
[promptRefs],
)
/* ── Delete (2-step confirm) ──────────────────────────────── */
const handleDelete = () => {
if (!confirmDelete) { setConfirmDelete(true); return }
flush()
onDeleteRole(role.role_id)
}
return (
<aside className="ri-panel" aria-label={`Inspector for role ${role.name}`}>
<header className="ri-panel-header">
<img src={resolveRoleIcon(iconKey)} alt="" className="ri-panel-icon" />
<div className="ri-panel-title-wrap">
<h3 className="ri-panel-title">{name || '(unnamed)'}</h3>
<code className="ri-panel-id">{role.role_id}</code>
</div>
<button className="btn btn-ghost btn-sm ri-panel-close" onClick={onClose} title="Close (Esc)"></button>
</header>
<div className="ri-panel-body">
<InspectorGroup title="Identity" defaultExpanded>
<InspectorField label="Name">
<input
className="ri-text-input"
value={name}
onChange={e => handleNameChange(e.target.value)}
disabled={readOnly}
/>
</InspectorField>
<InspectorField label="Responsibility">
<textarea
className="ri-textarea"
rows={3}
value={responsibility}
onChange={e => handleResponsibilityChange(e.target.value)}
disabled={readOnly}
/>
</InspectorField>
<InspectorField label="Icon">
<IconPicker value={iconKey} onChange={handleIconChange} readOnly={readOnly} />
</InspectorField>
</InspectorGroup>
<InspectorGroup title="Hierarchy" defaultExpanded>
<InspectorField label="Reports to">
<select
className="ri-select"
value={reportsTo}
onChange={e => handleReportsToChange(e.target.value)}
disabled={readOnly}
>
<option value="owner">You (Owner)</option>
{otherRoles.map(r => (
<option key={r.role_id} value={r.role_id}>{r.name}</option>
))}
</select>
</InspectorField>
<InspectorField label="Can delegate to">
<MultiSelect
allIds={otherRoles.map(r => r.role_id)}
labelFor={(id) => otherRoles.find(r => r.role_id === id)?.name ?? id}
selected={canSpawn}
onToggle={toggleCanSpawn}
readOnly={readOnly}
/>
</InspectorField>
<InspectorField label="Employees assigned">
<span className="ri-meta-value">{employeeCount}</span>
</InspectorField>
</InspectorGroup>
<InspectorGroup title={`Tools (${tools.size})`}>
<ToolChecklist tools={tools} onToggle={toggleTool} readOnly={readOnly} />
</InspectorGroup>
<InspectorGroup title={`Prompts (${promptRefCount})`}>
<InspectorField label="Prompt refs / inline">
<textarea
className="ri-textarea ri-textarea-mono"
rows={5}
placeholder="One prompt ref or inline instruction per line"
value={promptRefs}
onChange={e => handlePromptRefsChange(e.target.value)}
disabled={readOnly}
/>
</InspectorField>
</InspectorGroup>
<InspectorGroup title="Runtime policy">
<InspectorField label="Execution strategy">
<div className="ri-radio-group">
{EXECUTION_STRATEGIES.map(opt => (
<label key={opt.value} className="ri-radio" title={opt.hint}>
<input
type="radio"
name={`exec-strategy-${role.role_id}`}
value={opt.value}
checked={execStrategy === opt.value}
onChange={() => handleExecStrategyChange(opt.value)}
disabled={readOnly}
/>
{opt.label}
</label>
))}
</div>
</InspectorField>
{execStrategy === 'external' && (
<InspectorField label="Preferred external agent">
<select
className="ri-select"
value={extAgent ?? ''}
onChange={e => handleExtAgentChange(e.target.value || null)}
disabled={readOnly}
>
<option value="">(any)</option>
{EXTERNAL_AGENTS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
</InspectorField>
)}
</InspectorGroup>
<InspectorGroup title="Advanced">
<InspectorField label="Role type">
<span className="ri-meta-value">{role.role_type ?? 'worker'}</span>
</InspectorField>
<InspectorField label="Skills">
<span className="ri-meta-value">
{role.skill_refs && role.skill_refs.length > 0 ? role.skill_refs.join(', ') : '(none)'}
</span>
</InspectorField>
<InspectorField label="Artifact contract">
<span className="ri-meta-value">{role.artifact_contract_ref ?? '(none)'}</span>
</InspectorField>
</InspectorGroup>
</div>
{!readOnly && (
<footer className="ri-panel-footer">
<button
className={`btn btn-sm ${confirmDelete ? 'btn-danger' : 'btn-ghost'}`}
onClick={handleDelete}
onBlur={() => setConfirmDelete(false)}
>
{confirmDelete ? 'Confirm delete?' : 'Delete role'}
</button>
</footer>
)}
</aside>
)
}
/* ── Sub-components ────────────────────────────────────────────── */
function InspectorGroup({ title, defaultExpanded = false, children }: {
title: string
defaultExpanded?: boolean
children: ReactNode
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
return (
<section className={`ri-group${expanded ? ' is-expanded' : ''}`}>
<button className="ri-group-header" onClick={() => setExpanded(e => !e)}>
<span className="ri-group-caret">{expanded ? '▾' : '▸'}</span>
<span className="ri-group-title">{title}</span>
</button>
{expanded && <div className="ri-group-body">{children}</div>}
</section>
)
}
function InspectorField({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="ri-field">
<label className="ri-field-label">{label}</label>
<div className="ri-field-control">{children}</div>
</div>
)
}
function IconPicker({ value, onChange, readOnly }: {
value: string | null
onChange: (v: string | null) => void
readOnly?: boolean
}) {
const [open, setOpen] = useState(false)
return (
<div className="ri-icon-picker">
<button
className="ri-icon-picker-trigger"
onClick={() => !readOnly && setOpen(o => !o)}
disabled={readOnly}
>
<img src={resolveRoleIcon(value)} alt="" className="ri-icon-picker-current" />
<span className="ri-icon-picker-label">{value ?? 'generic'}</span>
</button>
{open && (
<div className="ri-icon-picker-popover">
<button
className={`ri-icon-option${value === null ? ' is-active' : ''}`}
onClick={() => { onChange(null); setOpen(false) }}
title="Default icon"
>
<img src={ROLE_ICONS.generic} alt="" />
</button>
{(ROLE_ICON_KEYS as readonly RoleIconKey[]).filter(k => k !== 'generic').map(key => (
<button
key={key}
className={`ri-icon-option${value === key ? ' is-active' : ''}`}
onClick={() => { onChange(key); setOpen(false) }}
title={key}
>
<img src={ROLE_ICONS[key]} alt={key} />
</button>
))}
</div>
)}
</div>
)
}
function MultiSelect({ allIds, labelFor, selected, onToggle, readOnly }: {
allIds: string[]
labelFor: (id: string) => string
selected: Set<string>
onToggle: (id: string) => void
readOnly?: boolean
}) {
const [open, setOpen] = useState(false)
const available = allIds.filter(id => !selected.has(id))
return (
<div className="ri-multiselect">
<div className="ri-chips">
{Array.from(selected).map(id => (
<span key={id} className="ri-chip">
{labelFor(id)}
{!readOnly && (
<button className="ri-chip-x" onClick={() => onToggle(id)} title="Remove">×</button>
)}
</span>
))}
{!readOnly && available.length > 0 && (
<button className="ri-chip-add" onClick={() => setOpen(o => !o)}>+ Add</button>
)}
{selected.size === 0 && readOnly && <span className="ri-meta-value">(none)</span>}
</div>
{open && !readOnly && (
<div className="ri-multiselect-popover">
{available.map(id => (
<button
key={id}
className="ri-multiselect-option"
onClick={() => { onToggle(id); setOpen(false) }}
>{labelFor(id)}</button>
))}
</div>
)}
</div>
)
}
function ToolChecklist({ tools, onToggle, readOnly }: {
tools: Set<string>
onToggle: (name: string) => void
readOnly?: boolean
}) {
return (
<div className="ri-toolcheck">
{TOOL_GROUPS.map(g => (
<fieldset key={g.prefix} className="ri-toolcheck-group">
<legend className="ri-toolcheck-legend">{g.label}</legend>
<div className="ri-toolcheck-items">
{g.tools.map(t => (
<label key={t} className="ri-toolcheck-item">
<input
type="checkbox"
checked={tools.has(t)}
onChange={() => onToggle(t)}
disabled={readOnly}
/>
<span className="ri-toolcheck-name">{t.replace(g.prefix, '') || t}</span>
</label>
))}
</div>
</fieldset>
))}
</div>
)
}
@@ -0,0 +1,345 @@
/**
* RoleTable — Tanstack-table bulk editor for roles.
*
* Columns:
* - checkbox (multi-select)
* - icon (click to open IconPicker — deferred; currently read-only)
* - name (inline editable via double-click)
* - role_id (monospace, immutable)
* - reports_to (dropdown)
* - tools (count, clickable to open popover)
* - agent (select)
* - employees (count)
* - actions (⋯ menu: Delete)
*
* Row click → selects the row in StructureEditor (opens Inspector).
* Multi-select → bulk-edit bar at top ("Change agent for N roles", etc.)
*/
import { useMemo, useState, type ChangeEvent } from 'react'
import {
useReactTable, getCoreRowModel, getSortedRowModel, flexRender,
type ColumnDef, type SortingState,
} from '@tanstack/react-table'
import type { OrgRole, OrgEmployee } from '../types/visual'
import { resolveRoleIcon } from './roleIcons'
const EXTERNAL_AGENTS = ['codex', 'cursor', 'claude_code', 'opencode'] as const
interface RoleTableProps {
roles: OrgRole[]
employees: OrgEmployee[]
selectedIds: string[]
onSelectRow: (id: string) => void
onUpdateRole: (roleId: string, updates: {
name?: string
reports_to?: string
preferred_external_agent?: string | null
}) => void
onDeleteRole: (roleId: string) => void
readOnly?: boolean
}
interface TableRow {
role_id: string
name: string
icon: string | null
reports_to: string
toolCount: number
agent: string | null
employeeCount: number
}
/* ── RoleTable ───────────────────────────────────────────────── */
export function RoleTable({
roles, employees, selectedIds, onSelectRow,
onUpdateRole, onDeleteRole, readOnly,
}: RoleTableProps) {
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
const [editingCell, setEditingCell] = useState<{ rowId: string; col: 'name' } | null>(null)
const [nameBuffer, setNameBuffer] = useState('')
const data: TableRow[] = useMemo(() => {
const countByRole = new Map<string, number>()
for (const e of employees) {
const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id]
for (const roleId of roleIds) {
if (roleId) countByRole.set(roleId, (countByRole.get(roleId) ?? 0) + 1)
}
}
return roles.map(r => ({
role_id: r.role_id,
name: r.name,
icon: r.icon ?? null,
reports_to: r.reports_to,
toolCount: r.tools?.length ?? 0,
agent: r.preferred_external_agent ?? null,
employeeCount: countByRole.get(r.role_id) ?? 0,
}))
}, [roles, employees])
const reportsToOptions = useMemo(
() => [{ id: 'owner', name: 'Owner' }, ...roles.map(r => ({ id: r.role_id, name: r.name }))],
[roles],
)
const commitName = (rowId: string) => {
const trimmed = nameBuffer.trim()
if (trimmed && trimmed !== roles.find(r => r.role_id === rowId)?.name) {
onUpdateRole(rowId, { name: trimmed })
}
setEditingCell(null)
setNameBuffer('')
}
const columns: ColumnDef<TableRow>[] = useMemo(() => [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
ref={el => { if (el) el.indeterminate = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() }}
onChange={table.getToggleAllRowsSelectedHandler()}
disabled={readOnly}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
disabled={readOnly}
aria-label={`Select ${row.original.role_id}`}
onClick={e => e.stopPropagation()}
/>
),
size: 32,
enableSorting: false,
},
{
id: 'icon',
header: '',
cell: ({ row }) => (
<img src={resolveRoleIcon(row.original.icon)} alt="" className="rt-cell-icon" />
),
size: 32,
enableSorting: false,
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ row }) => {
const r = row.original
const isEditing = editingCell?.rowId === r.role_id && editingCell.col === 'name'
if (isEditing) {
return (
<input
autoFocus
className="rt-inline-input"
value={nameBuffer}
onChange={e => setNameBuffer(e.target.value)}
onBlur={() => commitName(r.role_id)}
onKeyDown={e => {
if (e.key === 'Enter') commitName(r.role_id)
else if (e.key === 'Escape') { setEditingCell(null); setNameBuffer('') }
}}
onClick={e => e.stopPropagation()}
/>
)
}
return (
<span
className="rt-cell-name"
onDoubleClick={e => {
if (readOnly) return
e.stopPropagation()
setNameBuffer(r.name)
setEditingCell({ rowId: r.role_id, col: 'name' })
}}
title="Double-click to edit"
>
{r.name}
</span>
)
},
},
{
id: 'role_id',
header: 'ID',
accessorKey: 'role_id',
cell: ({ row }) => <code className="rt-cell-id">{row.original.role_id}</code>,
},
{
id: 'reports_to',
header: 'Reports to',
accessorKey: 'reports_to',
cell: ({ row }) => {
const r = row.original
return (
<select
className="rt-cell-select"
value={r.reports_to}
onChange={(e: ChangeEvent<HTMLSelectElement>) => onUpdateRole(r.role_id, { reports_to: e.target.value })}
disabled={readOnly}
onClick={e => e.stopPropagation()}
>
{reportsToOptions.filter(o => o.id !== r.role_id).map(o => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
)
},
},
{
id: 'toolCount',
header: 'Tools',
accessorKey: 'toolCount',
cell: ({ row }) => (
<span className="rt-cell-count">{row.original.toolCount}</span>
),
size: 64,
},
{
id: 'agent',
header: 'Agent',
accessorKey: 'agent',
cell: ({ row }) => {
const r = row.original
return (
<select
className="rt-cell-select"
value={r.agent ?? ''}
onChange={(e: ChangeEvent<HTMLSelectElement>) => onUpdateRole(r.role_id, { preferred_external_agent: e.target.value || null })}
disabled={readOnly}
onClick={e => e.stopPropagation()}
>
<option value="">(auto)</option>
{EXTERNAL_AGENTS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
)
},
},
{
id: 'employeeCount',
header: 'People',
accessorKey: 'employeeCount',
cell: ({ row }) => <span className="rt-cell-count">{row.original.employeeCount}</span>,
size: 64,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<button
className="rt-cell-action"
onClick={e => {
e.stopPropagation()
if (readOnly) return
if (confirm(`Delete role "${row.original.name}"?`)) onDeleteRole(row.original.role_id)
}}
disabled={readOnly}
title="Delete"
></button>
),
size: 40,
enableSorting: false,
},
], [editingCell, nameBuffer, readOnly, reportsToOptions, onUpdateRole, onDeleteRole])
const table = useReactTable({
data,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
getRowId: (row) => row.role_id,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const selectedCount = Object.values(rowSelection).filter(Boolean).length
const selectedRoleIds = Object.keys(rowSelection).filter(id => rowSelection[id])
/* ── Bulk actions ────────────────────────────────────────── */
const bulkSetAgent = (agent: string | null) => {
if (readOnly) return
selectedRoleIds.forEach(id => onUpdateRole(id, { preferred_external_agent: agent }))
setRowSelection({})
}
const bulkDelete = () => {
if (readOnly) return
if (!confirm(`Delete ${selectedCount} roles? This cannot be undone.`)) return
selectedRoleIds.forEach(id => onDeleteRole(id))
setRowSelection({})
}
return (
<div className="rt-container">
{selectedCount > 0 && !readOnly && (
<div className="rt-bulk-bar">
<span className="rt-bulk-count">{selectedCount} selected</span>
<select
className="rt-bulk-select"
defaultValue=""
onChange={e => { const v = e.target.value; if (v) bulkSetAgent(v === '__auto__' ? null : v) }}
>
<option value="" disabled>Change agent</option>
<option value="__auto__">(auto)</option>
{EXTERNAL_AGENTS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<button className="btn btn-danger btn-sm" onClick={bulkDelete}>Delete selected</button>
<button className="btn btn-ghost btn-sm" onClick={() => setRowSelection({})}>Clear</button>
</div>
)}
<div className="rt-table-wrap">
<table className="rt-table">
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th
key={h.id}
style={{ width: h.getSize() }}
className={h.column.getCanSort() ? 'rt-th-sortable' : ''}
onClick={h.column.getToggleSortingHandler()}
>
{flexRender(h.column.columnDef.header, h.getContext())}
{h.column.getIsSorted() === 'asc' && <span className="rt-sort-caret"> </span>}
{h.column.getIsSorted() === 'desc' && <span className="rt-sort-caret"> </span>}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.length === 0 && (
<tr><td colSpan={9} className="rt-empty">No roles. Add one via the "+ Add role" button.</td></tr>
)}
{table.getRowModel().rows.map(row => {
const isSelected = selectedIds.includes(row.original.role_id)
return (
<tr
key={row.id}
className={`rt-row${row.getIsSelected() ? ' rt-row-checked' : ''}${isSelected ? ' rt-row-active' : ''}`}
onClick={() => onSelectRow(row.original.role_id)}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id} style={{ width: cell.column.getSize() }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,250 @@
import { useMemo, useCallback, useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react'
import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, applyNodeChanges } from '@xyflow/react'
import type { Node, Edge, NodeChange } from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import type { OrgRole, OrgEmployee } from '../types/visual'
import { StructureCanvasNode, type StructureCanvasNodeData } from './StructureCanvasNode'
import { computeDagreLayout } from './dagreLayout'
const nodeTypes = { roleNode: StructureCanvasNode }
export interface StructureCanvasHandle {
/** Re-run dagre and animate nodes to tidy positions. */
autoLayout: () => void
}
interface StructureCanvasProps {
roles: OrgRole[]
employees: OrgEmployee[]
/**
* role_id -> recruited names for the currently selected session. When
* provided it takes precedence over the global `employees` for the node
* subtitle, so the canvas reflects the selected session's hires. Null/absent
* -> fall back to the global org employees.
*/
sessionRecruitmentByRole?: Record<string, string[]> | null
selectedRoleId: string | null
onSelectRole: (roleId: string | null) => void
onReparent: (roleId: string, newParentId: string) => void
readOnly?: boolean
}
/**
* Constrained canvas for org structure.
* Positions are managed internally. See D1 + D3 in the plan doc:
* - dagre runs on mount, on role add/delete, and on explicit autoLayout() calls.
* - Role-field updates do NOT reflow the graph.
* - Reparenting reflows (a new parent->child edge would leave the graph stale).
*/
export const StructureCanvas = forwardRef<StructureCanvasHandle, StructureCanvasProps>(function StructureCanvas(props, ref) {
return (
<ReactFlowProvider>
<StructureCanvasInner {...props} forwardedRef={ref} />
</ReactFlowProvider>
)
})
function StructureCanvasInner({ roles, employees, sessionRecruitmentByRole, selectedRoleId, onSelectRole, onReparent, readOnly, forwardedRef }: StructureCanvasProps & { forwardedRef: React.ForwardedRef<StructureCanvasHandle> }) {
const employeesByRole = useMemo(() => {
const m = new Map<string, OrgEmployee[]>()
for (const e of employees) {
const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id]
for (const roleId of roleIds) {
if (!roleId) continue
const arr = m.get(roleId) ?? []
arr.push(e)
m.set(roleId, arr)
}
}
return m
}, [employees])
// Names of the actually recruited people per role.
// - When the selected session carries a recruitment map, it is authoritative
// (a role absent from it is unstaffed *for that session*).
// - Otherwise fall back to the global org employees, excluding placeholder/
// default employees (which carry the role name itself, not a real hire).
const recruitedNamesByRole = useCallback(
(roleId: string): string[] => {
if (sessionRecruitmentByRole) return sessionRecruitmentByRole[roleId] ?? []
return (employeesByRole.get(roleId) ?? [])
.filter(e => !e.is_default_employee)
.map(e => e.name)
.filter(Boolean)
},
[employeesByRole, sessionRecruitmentByRole],
)
// Layout invalidation key. Incrementing -> dagre re-runs.
const [layoutVersion, setLayoutVersion] = useState(0)
// Re-layout automatically when the set of role IDs changes (add/delete).
const roleIdsKey = useMemo(() => roles.map(r => r.role_id).sort().join('|'), [roles])
const prevRoleIdsKeyRef = useRef(roleIdsKey)
useEffect(() => {
if (prevRoleIdsKeyRef.current !== roleIdsKey) {
prevRoleIdsKeyRef.current = roleIdsKey
setLayoutVersion(v => v + 1)
}
}, [roleIdsKey])
// Expose imperative autoLayout() to parent so the "Auto-layout" button can trigger it.
useImperativeHandle(forwardedRef, () => ({
autoLayout: () => setLayoutVersion(v => v + 1),
}), [])
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Free-form drag positions: a node the user has dragged sticks where it was
// dropped (purely visual — never changes the org structure). Cleared whenever
// dagre re-runs (autoLayout / role add-delete / reparent) so an explicit
// "Auto-layout" tidies everything back into the hierarchy.
const [manualPositions, setManualPositions] = useState<Record<string, { x: number; y: number }>>({})
useEffect(() => { setManualPositions({}) }, [layoutVersion])
// Compute layout once per layoutVersion bump (NOT on every roles update).
const laidOut = useMemo(() => {
const ownerNode: Node<StructureCanvasNodeData> = {
id: 'owner',
type: 'roleNode',
position: { x: 0, y: 0 },
draggable: false,
data: {
roleId: 'owner', name: 'You (Owner)', responsibility: '',
icon: null, employeeCount: 0, employeeNames: [],
isOwner: true, isSelected: false, isDropTarget: false,
},
}
const roleNodes: Node<StructureCanvasNodeData>[] = roles.map(r => ({
id: r.role_id,
type: 'roleNode',
position: { x: 0, y: 0 },
// Always draggable: in editable mode a drop onto another node reparents;
// otherwise the drag is a free visual reposition (no structural change).
draggable: true,
data: {
roleId: r.role_id, name: r.name, responsibility: r.responsibility,
icon: r.icon ?? null, employeeCount: recruitedNamesByRole(r.role_id).length,
employeeNames: recruitedNamesByRole(r.role_id),
isOwner: false, isSelected: false, isDropTarget: false,
},
}))
const all = [ownerNode, ...roleNodes]
const edges: Edge[] = roles.map(r => ({
id: `e-${r.reports_to}-${r.role_id}`,
source: r.reports_to,
target: r.role_id,
type: 'smoothstep',
}))
const positioned = computeDagreLayout(all, edges)
return { nodes: positioned, edges }
// eslint-disable-next-line react-hooks/exhaustive-deps -- layoutVersion is the explicit reflow trigger
}, [layoutVersion, readOnly])
// Live nodes: start from laid-out positions; overlay current role data
// (name/icon/etc.) without re-running dagre.
const liveNodes = useMemo(() => {
return laidOut.nodes.map(n => {
if (n.id === 'owner') return n
const role = roles.find(r => r.role_id === n.id)
if (!role) return n // node for a deleted role -- invariant: layoutVersion will bump and this clears
const manual = manualPositions[n.id]
return {
...n,
position: manual ?? n.position,
draggable: true,
data: {
...n.data,
name: role.name,
responsibility: role.responsibility,
icon: role.icon ?? null,
employeeCount: recruitedNamesByRole(role.role_id).length,
employeeNames: recruitedNamesByRole(role.role_id),
isSelected: role.role_id === selectedRoleId,
isDropTarget: role.role_id === dropTargetId,
},
}
})
}, [laidOut.nodes, roles, employeesByRole, recruitedNamesByRole, selectedRoleId, dropTargetId, readOnly, manualPositions])
const [stateNodes, setStateNodes] = useState(liveNodes)
useEffect(() => { setStateNodes(liveNodes) }, [liveNodes])
const handleNodesChange = useCallback((changes: NodeChange[]) => {
setStateNodes(curr => applyNodeChanges(changes, curr))
}, [])
const handleNodeDrag = useCallback((_evt: any, node: Node) => {
// Reparent drop-target highlighting only applies in editable mode. In
// read-only mode the drag is a pure visual reposition -- no target.
if (readOnly) return
// Bounding-box hit test for drop target
const W = 220, H = 80
const pt = { x: node.position.x + W / 2, y: node.position.y + H / 2 }
const hit = stateNodes.find(n => {
if (n.id === node.id) return false
return pt.x >= n.position.x && pt.x <= n.position.x + W &&
pt.y >= n.position.y && pt.y <= n.position.y + H
})
setDropTargetId(hit?.id ?? null)
}, [stateNodes, readOnly])
const handleNodeDragStop = useCallback((_evt: any, node: Node) => {
const target = dropTargetId
setDropTargetId(null)
// Remember the dropped position so the node stays put (visual only).
const keepPosition = () =>
setManualPositions(prev => ({ ...prev, [node.id]: { x: node.position.x, y: node.position.y } }))
// No valid reparent (read-only, no/own target, or a cycle) -> free reposition.
if (readOnly || !target || target === node.id || isDescendant(roles, node.id, target)) {
keepPosition()
return
}
onReparent(node.id, target)
// A reparent changes the edge structure -- reflow (also clears manual positions).
setLayoutVersion(v => v + 1)
}, [dropTargetId, roles, readOnly, onReparent])
const handleNodeClick = useCallback((_evt: any, node: Node) => {
onSelectRole(node.id === 'owner' ? null : node.id)
}, [onSelectRole])
// @xyflow/react v12's `.react-flow` CSS class does NOT set height/width.
// Wrap in a sized div so the canvas has a definite box to render into —
// this is the library's documented integration pattern for v12.
return (
<div style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={stateNodes}
edges={laidOut.edges}
nodeTypes={nodeTypes}
onNodesChange={handleNodesChange}
onNodeDrag={handleNodeDrag}
onNodeDragStop={handleNodeDragStop}
onNodeClick={handleNodeClick}
fitView
fitViewOptions={{ padding: 0.32, minZoom: 0.45, maxZoom: 1.2 }}
nodesConnectable={false}
edgesFocusable={false}
style={{ width: '100%', height: '100%' }}
>
<Background gap={28} size={1} color="rgba(240, 237, 232, 0.10)" />
<Controls showInteractive={false} />
<MiniMap pannable nodeStrokeWidth={0} maskColor="rgba(12, 17, 27, 0.6)" />
</ReactFlow>
</div>
)
}
// Note: no ReactFlow `proOptions.hideAttribution` used -- @xyflow/react v12 is MIT
// with the attribution fully removed at the library level, so no workaround needed.
/** Helper: is `candidateDescendantId` a descendant of `rootId`? */
function isDescendant(roles: OrgRole[], rootId: string, candidateDescendantId: string): boolean {
const children = roles.filter(r => r.reports_to === rootId).map(r => r.role_id)
for (const c of children) {
if (c === candidateDescendantId) return true
if (isDescendant(roles, c, candidateDescendantId)) return true
}
return false
}
@@ -0,0 +1,73 @@
import { memo } from 'react'
import { Handle, Position } from '@xyflow/react'
import { resolveRoleIcon } from './roleIcons'
export interface StructureCanvasNodeData {
[key: string]: unknown
roleId: string
name: string
responsibility: string
icon: string | null
employeeCount: number
/** Names of the actual recruited (non-placeholder) people staffed on this role. */
employeeNames: string[]
isOwner: boolean
isSelected: boolean
isDropTarget: boolean
}
/**
* Canvas node — single-accent refined card.
* Outer <div> always carries .oc-canvas-node (plus optional state
* modifiers) — this is the E2E anchor. Icon is rendered via CSS
* `mask-image` so its tint can track the active theme's --accent;
* the card reads consistently under Paper, OpenOPC, etc.
*/
export const StructureCanvasNode = memo(function StructureCanvasNode({ data }: { data: StructureCanvasNodeData }) {
const stateClass = [
'oc-canvas-node',
data.isOwner && 'is-owner',
data.isSelected && 'is-selected',
data.isDropTarget && 'is-drop-target',
].filter(Boolean).join(' ')
const iconSrc = resolveRoleIcon(data.icon)
return (
<div className={stateClass}>
<Handle type="target" position={Position.Top} className="oc-canvas-handle" />
<div className="oc-canvas-node-row">
<div className="oc-canvas-node-chip">
<span
className="oc-canvas-node-chip-icon"
style={{
WebkitMaskImage: `url("${iconSrc}")`,
maskImage: `url("${iconSrc}")`,
}}
aria-hidden
/>
</div>
<div className="oc-canvas-node-text">
<span className="oc-canvas-node-name">{data.name}</span>
<div className="oc-canvas-node-meta">
{data.employeeNames.length > 0 ? (
<span
className="oc-canvas-node-person"
title={data.employeeNames.join(', ')}
>
{data.employeeNames[0]}
{data.employeeNames.length > 1 ? ` +${data.employeeNames.length - 1}` : ''}
</span>
) : (
<span className="oc-canvas-node-id">{data.roleId}</span>
)}
{data.employeeCount > 0 && (
<span className="oc-canvas-node-badge">{data.employeeCount}</span>
)}
</div>
</div>
</div>
<Handle type="source" position={Position.Bottom} className="oc-canvas-handle" />
</div>
)
})
@@ -0,0 +1,223 @@
/**
* StructureEditor — top-level wrapper for Team sub-tab's role editor.
*
* Owns:
* - view mode (canvas | table)
* - selection state (which role is open in Inspector)
* - reparent handler (proxies to onUpdateRole)
* - keyboard shortcuts: Escape (close Inspector), Delete (delete selected
* role in Canvas mode), F (fit view to graph), ⌘D (duplicate selected)
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { OrgRole, OrgEmployee, SavedOrgSummary } from '../types/visual'
import { OrgVersionSwitcher } from './OrgVersionSwitcher'
import { RoleInspector, type RoleUpdatePatch } from './RoleInspector'
import { RoleTable } from './RoleTable'
import { StructureCanvas, type StructureCanvasHandle } from './StructureCanvas'
interface StructureEditorProps {
roles: OrgRole[]
employees: OrgEmployee[]
/** role_id -> recruited names for the selected session (canvas display only). */
sessionRecruitmentByRole?: Record<string, string[]> | null
isCustomMode?: boolean
onAddRole: (
roleId: string,
name: string,
responsibility: string,
reportsTo: string,
icon?: string | null,
) => void
onUpdateRole: (roleId: string, updates: RoleUpdatePatch) => void
onDeleteRole: (roleId: string) => void
// Saved org architectures — render a version-switcher pill in the toolbar
savedOrgsList?: SavedOrgSummary[] | null
activeSavedOrg?: string | null
currentOrgVersion?: number
versionAtLoad?: number | null
onSavedOrgsList?: () => void
onSavedOrgSaveAs?: (name: string, overwrite: boolean) => void
onSavedOrgLoad?: (name: string) => void
onSavedOrgDelete?: (name: string) => void
}
type EditorView = 'canvas' | 'table'
export function StructureEditor({
roles, employees, sessionRecruitmentByRole, isCustomMode,
onAddRole, onUpdateRole, onDeleteRole,
savedOrgsList, activeSavedOrg, currentOrgVersion, versionAtLoad,
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgLoad, onSavedOrgDelete,
}: StructureEditorProps) {
const [view, setView] = useState<EditorView>('canvas')
const [selectedId, setSelectedId] = useState<string | null>(null)
const canvasRef = useRef<StructureCanvasHandle>(null)
const selectedRole = useMemo(
() => roles.find(r => r.role_id === selectedId) ?? null,
[roles, selectedId],
)
/** Drag-to-reparent comes from StructureCanvas and turns into a normal role update. */
const handleReparent = useCallback((roleId: string, newParentId: string) => {
if (!isCustomMode) return
onUpdateRole(roleId, { reports_to: newParentId })
}, [onUpdateRole, isCustomMode])
/** Toolbar "Auto-layout" — triggers dagre reflow via forwardRef on Canvas. */
const handleAutoLayout = useCallback(() => {
canvasRef.current?.autoLayout()
}, [])
/**
* Toolbar "+ Add role" — generates a unique placeholder ID + default label,
* then selects the new role so Inspector opens ready-to-edit.
*/
const handleAddRoleQuick = useCallback(() => {
if (!isCustomMode) return
const existingIds = new Set(roles.map(r => r.role_id))
let id = 'new_role'
let suffix = 1
while (existingIds.has(id)) { suffix += 1; id = `new_role_${suffix}` }
onAddRole(id, 'New Role', '', 'owner', null)
setSelectedId(id)
}, [roles, onAddRole, isCustomMode])
/** Duplicate the currently-selected role (⌘D). */
const handleDuplicateSelected = useCallback(() => {
if (!isCustomMode || !selectedRole) return
const existingIds = new Set(roles.map(r => r.role_id))
let id = `${selectedRole.role_id}_copy`
let suffix = 1
while (existingIds.has(id)) { suffix += 1; id = `${selectedRole.role_id}_copy_${suffix}` }
onAddRole(id, `${selectedRole.name} (copy)`, selectedRole.responsibility, selectedRole.reports_to, selectedRole.icon)
setSelectedId(id)
}, [roles, onAddRole, selectedRole, isCustomMode])
/**
* Keyboard shortcuts (scoped to StructureEditor via a wrapper ref +
* document-level listener that first checks whether the event originated
* from inside the editor). Skips when user is typing in a form field.
*/
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Don't swallow shortcuts while user types in form fields.
const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return
// Only react if the editor is on screen (event path touches our root).
if (rootRef.current && !rootRef.current.contains(t)) return
if (e.key === 'Escape' && selectedId) {
setSelectedId(null)
e.preventDefault()
return
}
if (e.key === 'Delete' && selectedId && isCustomMode) {
onDeleteRole(selectedId)
setSelectedId(null)
e.preventDefault()
return
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'd' && selectedId && isCustomMode) {
handleDuplicateSelected()
e.preventDefault()
return
}
if (e.key.toLowerCase() === 'f' && view === 'canvas') {
handleAutoLayout()
e.preventDefault()
return
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [selectedId, isCustomMode, view, onDeleteRole, handleAutoLayout, handleDuplicateSelected])
return (
<div className="se-container" ref={rootRef}>
<div className="se-toolbar">
<div className="se-view-switcher" role="tablist" aria-label="Editor view">
<button
role="tab"
aria-selected={view === 'canvas'}
className={`se-view-btn${view === 'canvas' ? ' is-active' : ''}`}
onClick={() => setView('canvas')}
>Canvas</button>
<button
role="tab"
aria-selected={view === 'table'}
className={`se-view-btn${view === 'table' ? ' is-active' : ''}`}
onClick={() => setView('table')}
>Table</button>
</div>
<div className={`se-toolbar-actions${isCustomMode ? '' : ' se-toolbar-actions--readonly'}`}>
{isCustomMode ? (
<div className="se-saved-org-control">
<span className="se-toolbar-label">Saved org</span>
<OrgVersionSwitcher
savedOrgs={savedOrgsList ?? null}
activeName={activeSavedOrg ?? null}
isDirty={versionAtLoad != null && (currentOrgVersion ?? 0) !== versionAtLoad}
onRefresh={onSavedOrgsList ?? (() => {})}
onSaveAs={onSavedOrgSaveAs ?? (() => {})}
onLoad={onSavedOrgLoad ?? (() => {})}
onDelete={onSavedOrgDelete ?? (() => {})}
/>
</div>
) : (
<span className="se-readonly-pill">Read-only corporate</span>
)}
<div className="se-toolbar-divider" aria-hidden />
{view === 'canvas' && (
<button className="btn btn-ghost btn-sm" onClick={handleAutoLayout}>
Auto-layout
</button>
)}
{isCustomMode && (
<button className="btn btn-primary btn-sm" onClick={handleAddRoleQuick}>
+ Add role
</button>
)}
</div>
</div>
<div className="se-body">
{view === 'canvas' ? (
<StructureCanvas
ref={canvasRef}
roles={roles}
employees={employees}
sessionRecruitmentByRole={sessionRecruitmentByRole}
selectedRoleId={selectedId}
onSelectRole={setSelectedId}
onReparent={handleReparent}
readOnly={!isCustomMode}
/>
) : (
<RoleTable
roles={roles}
employees={employees}
selectedIds={selectedId ? [selectedId] : []}
onSelectRow={(id) => setSelectedId(id)}
onUpdateRole={onUpdateRole}
onDeleteRole={onDeleteRole}
readOnly={!isCustomMode}
/>
)}
{selectedRole && (
<RoleInspector
role={selectedRole}
allRoles={roles}
employees={employees}
readOnly={!isCustomMode}
onUpdateRole={onUpdateRole}
onDeleteRole={(id) => { onDeleteRole(id); setSelectedId(null) }}
onClose={() => setSelectedId(null)}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,89 @@
import type { TalentTemplate } from '../types/visual'
interface TalentCardProps {
template: TalentTemplate
hiringId?: string | null
onHire: (templateId: string) => void
onClick: (template: TalentTemplate) => void
}
/** Derive a 2-letter monogram from a name. "Creative Director" → "CD". */
function monogram(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return '?'
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[words.length - 1][0]).toUpperCase()
}
export function TalentCard({ template: t, hiringId, onHire, onClick }: TalentCardProps) {
const isHiring = hiringId === t.template_id
const avatarStyle = t.color
? {
background: `color-mix(in srgb, ${t.color} 18%, transparent)`,
color: t.color,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${t.color} 28%, transparent)`,
}
: undefined
const chipPool: string[] = []
const seen = new Set<string>()
for (const c of [...t.tags, ...t.domains]) {
if (!seen.has(c)) { seen.add(c); chipPool.push(c) }
}
const visibleChips = chipPool.slice(0, 3)
const overflow = chipPool.length - visibleChips.length
return (
<div className="tm-card" onClick={() => onClick(t)}>
<div className="tm-card-head">
<div className="tm-card-avatar" style={avatarStyle} aria-hidden>
{t.emoji ? (
<span className="tm-card-avatar-emoji">{t.emoji}</span>
) : (
<span className="tm-card-avatar-mono">{monogram(t.name)}</span>
)}
</div>
<div className="tm-card-head-text">
<div className="tm-card-name-row">
<span className="tm-card-name" title={t.name}>{t.name}</span>
{t.preferred_external_agent && (
<span
className="tm-card-agent-badge"
title={`Agent: ${t.preferred_external_agent}`}
>
{t.preferred_external_agent}
</span>
)}
</div>
{t.category && <span className="tm-card-category">{t.category}</span>}
</div>
</div>
<div className="tm-card-body">
{t.vibe && <div className="tm-card-vibe">"{t.vibe}"</div>}
{t.description && <div className="tm-card-desc">{t.description}</div>}
</div>
{visibleChips.length > 0 && (
<div className="tm-card-chips">
{visibleChips.map(c => (
<span key={c} className="tm-card-chip">{c}</span>
))}
{overflow > 0 && (
<span className="tm-card-chip tm-card-chip-more">+{overflow}</span>
)}
</div>
)}
<div className="tm-card-footer">
<button
className="tm-card-hire-btn"
disabled={isHiring}
onClick={(e) => { e.stopPropagation(); onHire(t.template_id) }}
>
{isHiring ? <><span className="spinner-inline" /> Hiring</> : 'Hire →'}
</button>
</div>
</div>
)
}
@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import type { OrgRole, TalentTemplate, HireTalentHandler } from '../types/visual'
import { asRoleId, asTemplateId } from '../types/visual'
interface TalentDetailModalProps {
template: TalentTemplate
vacantRoles: OrgRole[]
hiringId?: string | null
readOnly?: boolean
onHire: HireTalentHandler
onClose: () => void
}
export function TalentDetailModal({
template: t, vacantRoles, hiringId, readOnly, onHire, onClose,
}: TalentDetailModalProps) {
const [selectedRoleId, setSelectedRoleId] = useState<string>('')
const isHiring = hiringId === t.template_id
const noVacancies = vacantRoles.length === 0
const canHire = !readOnly && !isHiring && !noVacancies && selectedRoleId !== ''
useEffect(() => {
setSelectedRoleId('')
}, [t.template_id])
const handleHire = () => {
if (!canHire) return
onHire(asTemplateId(t.template_id), asRoleId(selectedRoleId))
}
return (
<div className="tm-detail-overlay" onClick={onClose}>
<div className="tm-detail-modal" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="tm-detail-header" style={{ borderBottomColor: t.color || 'var(--border)' }}>
{t.emoji && <span className="tm-detail-emoji">{t.emoji}</span>}
<div>
<h2 className="tm-detail-name">{t.name}</h2>
<span className="tm-detail-category">{t.category}</span>
</div>
<button className="tm-detail-close" onClick={onClose}>&times;</button>
</div>
{/* Body */}
<div className="tm-detail-body">
{t.vibe && (
<blockquote className="tm-detail-vibe">"{t.vibe}"</blockquote>
)}
{t.description && (
<p className="tm-detail-desc">{t.description}</p>
)}
{t.domains.length > 0 && (
<div className="tm-detail-section">
<div className="tm-detail-label">Domains</div>
<div className="tm-detail-tags">
{t.domains.map(d => <span key={d} className="org-domain-tag">{d}</span>)}
</div>
</div>
)}
{t.tags.length > 0 && (
<div className="tm-detail-section">
<div className="tm-detail-label">Tags</div>
<div className="tm-detail-tags">
{t.tags.map(tag => <span key={tag} className="org-tool-tag">{tag}</span>)}
</div>
</div>
)}
{t.preferred_external_agent && (
<div className="tm-detail-section">
<div className="tm-detail-label">Recommended Agent</div>
<span className="tm-card-agent-badge">{t.preferred_external_agent}</span>
</div>
)}
{!readOnly && (
<div className="tm-detail-section">
<div className="tm-detail-label">Hire into role</div>
{noVacancies ? (
<p className="tm-detail-vacancy-empty">
No vacant roles. Create a role in the Team tab first.
</p>
) : (
<div className="tm-detail-role-list" role="listbox" aria-label="Vacant roles">
{vacantRoles.map(role => {
const selected = role.role_id === selectedRoleId
return (
<button
key={role.role_id}
type="button"
role="option"
aria-selected={selected}
className={`tm-detail-role-row${selected ? ' is-selected' : ''}`}
onClick={() => setSelectedRoleId(role.role_id)}
>
<span className="tm-detail-role-name">{role.name}</span>
{role.responsibility && (
<span className="tm-detail-role-resp">{role.responsibility}</span>
)}
</button>
)
})}
</div>
)}
</div>
)}
</div>
{/* Hire footer */}
{!readOnly && (
<div className="tm-detail-footer">
<button
className="tm-detail-hire-btn"
disabled={!canHire}
onClick={handleHire}
>
{isHiring ? <><span className="spinner-inline" /> Hiring...</> : 'Hire to selected role'}
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,402 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { OrgRole, OrgEmployee, SavedOrgSummary } from '../types/visual'
import { StructureEditor } from './StructureEditor'
import { resolveRoleIcon } from './roleIcons'
/* ── Inline SVG icon data-URIs (no external CDN — see P4.5 Phase 5) ─── */
const ICON = {
rocket: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M13.13 22.19L11.5 18.36c3.07-1.39 5.51-3.94 6.69-7.07L22 13l-8.87 9.19zM5.64 12.5L2 10.87l9.19-8.87 1.63 3.81c-3.13 1.18-5.68 3.62-7.07 6.69zM14.54 9.46c-.78-.78-.78-2.05 0-2.83s2.05-.78 2.83 0 .78 2.05 0 2.83c-.79.78-2.05.78-2.83 0zM8 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z'/%3E%3C/svg%3E",
people: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z'/%3E%3C/svg%3E",
check: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E",
addPerson: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
trash: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z'/%3E%3C/svg%3E",
team: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
deploy: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2322c55e' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z'/%3E%3C/svg%3E",
person: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E",
}
/* ── Quick Start Wizard ────────────────────────────────────────── */
interface QuickStartProps {
onComplete: (roles: Array<{ name: string; responsibility: string; reportsTo: string }>) => void
onSwitchToTab: (target: 'employees' | 'architecture') => void
}
function QuickStartWizard({ onComplete, onSwitchToTab }: QuickStartProps) {
const [step, setStep] = useState(1)
const [members, setMembers] = useState<Array<{ name: string; resp: string; parent: string }>>([
{ name: '', resp: '', parent: 'owner' },
])
const addMember = () => setMembers([...members, { name: '', resp: '', parent: 'owner' }])
const updateMember = (i: number, field: string, val: string) => {
const next = [...members]; (next[i] as any)[field] = val; setMembers(next)
}
const removeMember = (i: number) => {
if (members.length <= 1) return
setMembers(members.filter((_, idx) => idx !== i))
}
const validMembers = members.filter(m => m.name.trim())
const memberNames = validMembers.map(m => m.name.trim()).filter(Boolean)
const handleFinish = () => {
onComplete(
validMembers.map(m => ({ name: m.name.trim(), responsibility: m.resp.trim(), reportsTo: m.parent })),
)
}
return (
<div className="qs-wizard">
<div className="qs-header">
<img src={ICON.rocket} alt="" className="qs-header-icon" />
<div>
<h3 className="qs-header-title">Build Your Team</h3>
<p className="qs-header-sub">Create an org team in a few steps, or <button className="qs-link-btn" onClick={() => onSwitchToTab('architecture')}>use a template</button></p>
</div>
</div>
<div className="qs-progress">
{[
{ n: 1, icon: ICON.people, label: 'Team Members' },
{ n: 2, icon: ICON.check, label: 'Preview' },
].map(s => (
<div key={s.n} className={`qs-step${step === s.n ? ' qs-step-active' : step > s.n ? ' qs-step-done' : ''}`}>
<img src={s.icon} alt="" className="qs-step-icon" />
<span>{s.label}</span>
</div>
))}
</div>
{step === 1 && (
<div className="qs-panel">
<h4>Who's on your team?</h4>
<p className="qs-hint">Add each team member with their name and what they do.</p>
<div className="qs-members">
{members.map((m, i) => (
<div key={i} className="qs-member-row">
<input className="qs-member-name" value={m.name} placeholder="Role name (e.g. Engineer)"
onChange={e => updateMember(i, 'name', e.target.value)} />
<input className="qs-member-resp" value={m.resp} placeholder="What do they do?"
onChange={e => updateMember(i, 'resp', e.target.value)} />
<select className="qs-member-parent" value={m.parent}
onChange={e => updateMember(i, 'parent', e.target.value)}>
<option value="owner">Reports to you</option>
{memberNames.filter(n => n !== m.name.trim()).map(n => (
<option key={n} value={n}>{`Managed by ${n}`}</option>
))}
</select>
{members.length > 1 && (
<button className="qs-remove-btn" onClick={() => removeMember(i)} title="Remove">
<img src={ICON.trash} alt="" className="qs-remove-icon" />
</button>
)}
</div>
))}
</div>
<button className="qs-add-member" onClick={addMember}>
<img src={ICON.addPerson} alt="" className="qs-add-icon" /> Add another member
</button>
<div className="qs-nav">
<span />
<button className="oc-btn-primary" onClick={() => setStep(2)} disabled={validMembers.length === 0}>
Next: Preview →
</button>
</div>
</div>
)}
{step === 2 && (
<div className="qs-panel">
<h4>Your team at a glance</h4>
<div className="qs-preview">
<div className="qs-preview-section">
<h5>Team ({validMembers.length} members)</h5>
{validMembers.map((m, i) => (
<div key={i} className="qs-preview-member">
<strong>{m.name}</strong>
{m.resp && <span className="qs-preview-resp"> — {m.resp}</span>}
<span className="qs-preview-parent">
{m.parent === 'owner' ? ' (reports to you)' : ` (managed by ${m.parent})`}
</span>
</div>
))}
</div>
<div className="qs-preview-section">
<h5>Actor Runtime</h5>
<p>Seat routing and delegation will be derived from your reporting structure and any team seats you configure later.</p>
</div>
</div>
<div className="qs-nav">
<button className="oc-btn-ghost" onClick={() => setStep(1)}>← Back</button>
<button className="oc-btn-primary qs-create-btn" onClick={handleFinish}>Create Team</button>
</div>
</div>
)}
</div>
)
}
/* ── TeamView ──────────────────────────────────────────────────── */
interface TeamViewProps {
roles: OrgRole[]
employees: OrgEmployee[]
/** role_id -> recruited names for the selected session (canvas display only). */
sessionRecruitmentByRole?: Record<string, string[]> | null
isCustomMode?: boolean
onAddRole: (roleId: string, name: string, responsibility: string, reportsTo: string, icon?: string | null) => void
onBulkAddRoles?: (roles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string }>) => void
onUpdateRole: (roleId: string, updates: { name?: string; responsibility?: string; reports_to?: string; can_spawn?: string[]; icon?: string | null; execution_strategy?: string; preferred_external_agent?: string | null; prompt_refs?: string[] }) => void
onDeleteRole: (roleId: string) => void
onExport: (data: { package_id: string; name: string; description: string; version: string }) => void
onImportEmployee?: (employeeId: string) => void
onResetArchitecture?: () => void
onSwitchToTab: (target: 'employees' | 'architecture') => void
// Saved org architectures — passed to StructureEditor for toolbar pill
savedOrgsList?: SavedOrgSummary[] | null
activeSavedOrg?: string | null
currentOrgVersion?: number
versionAtLoad?: number | null
onSavedOrgsList?: () => void
onSavedOrgSaveAs?: (name: string, overwrite: boolean) => void
onSavedOrgLoad?: (name: string) => void
onSavedOrgDelete?: (name: string) => void
}
export function TeamView({
roles, employees, sessionRecruitmentByRole, isCustomMode,
onAddRole, onBulkAddRoles, onUpdateRole, onDeleteRole, onExport,
onImportEmployee,
onResetArchitecture, onSwitchToTab,
savedOrgsList, activeSavedOrg, currentOrgVersion, versionAtLoad,
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgLoad, onSavedOrgDelete,
}: TeamViewProps) {
const [quickStartPending, setQuickStartPending] = useState(false)
const [showExportForm, setShowExportForm] = useState(false)
const [exportId, setExportId] = useState('')
const [exportName, setExportName] = useState('')
const [exportDesc, setExportDesc] = useState('')
const [exportVersion, setExportVersion] = useState('1.0.0')
const empByRole = useMemo(() => {
const m = new Map<string, OrgEmployee[]>()
for (const e of employees) {
const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id]
for (const roleId of roleIds) {
if (!roleId) continue
const list = m.get(roleId) || []; list.push(e); m.set(roleId, list)
}
}
return m
}, [employees])
const handleExport = () => {
if (!exportId.trim() || !exportName.trim()) return
onExport({ package_id: exportId.trim(), name: exportName.trim(), description: exportDesc, version: exportVersion })
setShowExportForm(false); setExportId(''); setExportName(''); setExportDesc(''); setExportVersion('1.0.0')
}
// When roles arrive after bulk add, clear the quick-start loading state.
const quickStartTimer = useRef<ReturnType<typeof setTimeout>>(null)
useEffect(() => {
if (quickStartPending && roles.length > 0) {
setQuickStartPending(false)
if (quickStartTimer.current) { clearTimeout(quickStartTimer.current); quickStartTimer.current = null }
}
}, [roles.length, quickStartPending])
// Timeout: if roles never arrive within 10s, reset to wizard
useEffect(() => {
if (quickStartPending) {
quickStartTimer.current = setTimeout(() => setQuickStartPending(false), 10000)
return () => { if (quickStartTimer.current) clearTimeout(quickStartTimer.current) }
}
}, [quickStartPending])
const handleQuickStart = (
newRoles: Array<{ name: string; responsibility: string; reportsTo: string }>,
) => {
const nameToId = new Map<string, string>()
const usedIds = new Set<string>()
const bulkRoles: Array<{ role_id: string; name: string; responsibility: string; reports_to: string }> = []
for (const r of newRoles) {
let id = r.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/^-+|-+$/g, '')
if (!id) continue // skip roles with empty ID (e.g. name was "!!!")
// Deduplicate: append suffix if ID already used
let finalId = id
let suffix = 2
while (usedIds.has(finalId)) { finalId = `${id}-${suffix++}` }
usedIds.add(finalId)
nameToId.set(r.name, finalId)
const parentId = r.reportsTo === 'owner' ? 'owner' : (nameToId.get(r.reportsTo) || 'owner')
bulkRoles.push({ role_id: finalId, name: r.name, responsibility: r.responsibility, reports_to: parentId })
}
if (bulkRoles.length === 0) return // all roles had invalid names
if (onBulkAddRoles) {
setQuickStartPending(true)
onBulkAddRoles(bulkRoles)
} else {
for (const r of bulkRoles) onAddRole(r.role_id, r.name, r.responsibility, r.reports_to)
setQuickStartPending(true)
}
}
const handleReset = () => {
if (!confirm('This will remove all org roles, employees, and work-item templates. Continue?')) return
onResetArchitecture?.()
}
const assignedRoleCount = useMemo(() => {
let count = 0
for (const role of roles) {
if ((empByRole.get(role.role_id) ?? []).length > 0) count += 1
}
return count
}, [roles, empByRole])
const linkedEmployeeCount = useMemo(
() => employees.filter(e => e.linked_agent_id).length,
[employees],
)
const vacantRoleCount = Math.max(0, roles.length - assignedRoleCount)
// Show wizard only in org mode when no roles exist
if (isCustomMode && roles.length === 0) {
return (
<div className="team-view">
{quickStartPending ? (
<div className="qs-wizard">
<div className="qs-loading">
<span className="spinner-inline" /> Setting up your team...
</div>
</div>
) : (
<QuickStartWizard onComplete={handleQuickStart} onSwitchToTab={onSwitchToTab} />
)}
</div>
)
}
return (
<div className="team-view">
<div className={`team-command-bar${isCustomMode ? ' team-command-bar--editable' : ' team-command-bar--readonly'}`}>
<div className="team-command-copy">
<span className="team-command-eyebrow">{isCustomMode ? 'Saved org workspace' : 'Corporate baseline'}</span>
<span className="team-command-title">{isCustomMode ? 'Editable company architecture' : 'Built-in company architecture'}</span>
</div>
<div className="team-command-metrics">
<span><b>{assignedRoleCount}</b> staffed roles</span>
<span><b>{vacantRoleCount}</b> vacant</span>
<span><b>{linkedEmployeeCount}</b> in office</span>
</div>
{isCustomMode && (
<div className="team-command-actions">
<button className="myorg-inline-btn" onClick={() => onSwitchToTab('employees')}>
<img src={ICON.addPerson} alt="" className="myorg-inline-icon" /> Hire Talent
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowExportForm(true)}>
Export Package
</button>
{onResetArchitecture && (
<button className="myorg-reset-btn" onClick={handleReset}>
<img src={ICON.trash} alt="" className="myorg-reset-icon" /> Reset
</button>
)}
</div>
)}
</div>
{!isCustomMode && (
<div className="team-readonly-strip">
Corporate is fixed and read-only; saved company architectures are edited separately.
</div>
)}
{/* Structure Editor (Canvas + Table + Inspector) */}
<StructureEditor
roles={roles}
employees={employees}
sessionRecruitmentByRole={sessionRecruitmentByRole}
isCustomMode={isCustomMode}
onAddRole={onAddRole}
onUpdateRole={onUpdateRole}
onDeleteRole={onDeleteRole}
savedOrgsList={savedOrgsList ?? null}
activeSavedOrg={activeSavedOrg ?? null}
currentOrgVersion={currentOrgVersion ?? 0}
versionAtLoad={versionAtLoad ?? null}
onSavedOrgsList={onSavedOrgsList}
onSavedOrgSaveAs={onSavedOrgSaveAs}
onSavedOrgLoad={onSavedOrgLoad}
onSavedOrgDelete={onSavedOrgDelete}
/>
{/* Export form */}
{showExportForm && (
<div className="myorg-form">
<h4 className="myorg-form-title">Export as .opcpkg</h4>
<div className="oc-form-row"><label>Package ID</label>
<input value={exportId} onChange={e => setExportId(e.target.value)} placeholder="my-architecture" /></div>
<div className="oc-form-row"><label>Name</label>
<input value={exportName} onChange={e => setExportName(e.target.value)} placeholder="My Architecture" /></div>
<div className="oc-form-row"><label>Description</label>
<input value={exportDesc} onChange={e => setExportDesc(e.target.value)} placeholder="An org team structure" /></div>
<div className="oc-form-row"><label>Version</label>
<input value={exportVersion} onChange={e => setExportVersion(e.target.value)} /></div>
<div className="oc-form-actions">
<button className="oc-btn-primary" onClick={handleExport} disabled={!exportId.trim() || !exportName.trim()}>Export</button>
<button className="oc-btn-ghost" onClick={() => setShowExportForm(false)}>Cancel</button>
</div>
</div>
)}
{/* Team Roster — enhanced with actions */}
<div className="myorg-section">
<div className="myorg-section-header">
<img src={ICON.team} alt="" className="myorg-section-icon" />
<h3 className="myorg-section-title">Team Roster</h3>
<span className="myorg-section-count">{employees.length} members</span>
<span className="myorg-section-spacer" />
<span className="myorg-section-note">{assignedRoleCount}/{roles.length} staffed</span>
</div>
<div className="team-roster-grid">
{roles.map(r => {
const emps = empByRole.get(r.role_id) || []
return (
<div key={r.role_id} className="team-roster-card">
<div className="team-roster-card-header">
<img src={resolveRoleIcon(r.icon)} alt="" className="team-roster-card-icon" />
<span className="team-roster-role-name">{r.name}</span>
<span className="team-roster-count">{emps.length || 'vacant'}</span>
</div>
{emps.length > 0 ? emps.map(e => (
<div key={e.employee_id} className="team-roster-emp">
<img src={ICON.person} alt="" className="team-roster-emp-avatar" />
<div className="team-roster-emp-info">
<span className="team-roster-emp-name">{e.name}</span>
<span className={`team-roster-seniority team-roster-seniority--${e.seniority}`}>{e.seniority}</span>
</div>
{e.linked_agent_id ? (
<span className="team-roster-badge team-roster-badge--active">In Office</span>
) : onImportEmployee && e.role_id ? (
<button className="team-roster-deploy-btn" onClick={() => onImportEmployee(e.employee_id)}
title="Add this employee to the office workspace">
<img src={ICON.deploy} alt="" className="team-roster-deploy-icon" /> Deploy
</button>
) : null}
</div>
)) : (
<div className="team-roster-vacant">
<span className="team-roster-vacant-text">No members yet</span>
{isCustomMode && <button className="team-roster-hire-btn" onClick={() => onSwitchToTab('employees')}>Hire</button>}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,226 @@
/* Config import/export panel styles. */
/* ── Config Import/Export Panel ──────────────────────────────────── */
.cfg-io-panel {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-top: 16px;
display: flex;
flex-direction: column;
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--text) 5%, transparent),
0 1px 2px rgba(0, 0, 0, 0.3),
0 8px 24px rgba(0, 0, 0, 0.12);
}
.cfg-io-header {
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.cfg-io-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.cfg-io-subtitle {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 0;
line-height: 1.5;
}
.cfg-io-section {
padding: 14px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.cfg-io-section + .cfg-io-section {
border-top: 1px solid var(--border);
}
.cfg-io-section-header {
display: flex;
align-items: center;
gap: 8px;
}
.cfg-io-section-icon {
width: 16px;
height: 16px;
opacity: 0.6;
}
.cfg-io-section-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.4px;
}
/* Upload row: hidden file input + styled label */
.cfg-io-upload-row {
display: flex;
align-items: center;
}
.cfg-io-file-label {
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.cfg-io-file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
border: 0;
}
.cfg-io-file-btn {
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-xs);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text);
transition: border-color 0.15s, color 0.15s;
}
.cfg-io-file-label:hover .cfg-io-file-btn {
border-color: var(--accent);
color: var(--accent);
}
.cfg-io-file-input:focus-visible + .cfg-io-file-btn {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.cfg-io-file-name {
font-size: 12px;
color: var(--text-secondary);
font-style: italic;
}
/* Textarea — monospace for YAML */
.cfg-io-textarea {
width: 100%;
padding: 10px 12px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
line-height: 1.5;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text);
box-sizing: border-box;
resize: vertical;
min-height: 140px;
}
.cfg-io-textarea:focus {
border-color: var(--accent);
outline: none;
}
.cfg-io-actions {
display: flex;
gap: 8px;
margin-top: 2px;
flex-wrap: wrap;
}
/* Preview box (success / ready to apply) */
.cfg-io-preview {
display: flex;
gap: 12px;
padding: 12px 14px;
background: color-mix(in srgb, var(--green) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--green) 28%, transparent);
border-radius: var(--radius-xs);
margin-top: 4px;
}
.cfg-io-preview-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
margin-top: 1px;
}
.cfg-io-preview-body {
flex: 1;
min-width: 0;
}
.cfg-io-preview-title {
font-size: 12px;
font-weight: 600;
color: var(--green);
margin-bottom: 8px;
}
.cfg-io-preview-stats {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.cfg-io-preview-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.cfg-io-stat-label {
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.4px;
font-weight: 500;
}
.cfg-io-stat-value {
font-size: 16px;
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
}
/* Error box */
.cfg-io-error {
display: flex;
gap: 12px;
padding: 12px 14px;
background: color-mix(in srgb, var(--red) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--red) 28%, transparent);
border-radius: var(--radius-xs);
margin-top: 4px;
}
.cfg-io-error-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
margin-top: 1px;
}
.cfg-io-error-body {
flex: 1;
min-width: 0;
}
.cfg-io-error-title {
font-size: 12px;
font-weight: 600;
color: var(--red);
margin-bottom: 6px;
}
.cfg-io-error-text {
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--text);
background: var(--bg-secondary);
padding: 8px 10px;
border-radius: 4px;
margin: 0;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
}
@@ -0,0 +1,31 @@
import dagre from '@dagrejs/dagre'
import type { Node, Edge } from '@xyflow/react'
/**
* Compute dagre layout and return nodes with updated x/y.
* Top-down orientation (TB): root "owner" at top, leaves at bottom.
*/
export function computeDagreLayout(
nodes: Node[],
edges: Edge[],
opts: { nodeWidth?: number; nodeHeight?: number } = {},
): Node[] {
const g = new dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({}))
g.setGraph({
rankdir: 'TB',
ranksep: 60,
nodesep: 36,
marginx: 24,
marginy: 24,
})
const W = opts.nodeWidth ?? 220
const H = opts.nodeHeight ?? 80
nodes.forEach(n => g.setNode(n.id, { width: W, height: H }))
edges.forEach(e => g.setEdge(e.source, e.target))
dagre.layout(g)
return nodes.map(n => {
const { x, y } = g.node(n.id)
return { ...n, position: { x: x - W / 2, y: y - H / 2 } }
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,935 @@
/* OrgTab shell and shared org UI primitives. */
/* ── Org Tab ──────────────────────────────────────────────────────── */
.org-tab {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
overflow-y: auto;
background:
linear-gradient(180deg,
color-mix(in srgb, var(--bg-elevated) 26%, var(--bg) 74%) 0%,
var(--bg) 240px);
color: var(--text);
}
.org-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
font-size: 14px;
}
.org-empty {
padding: 32px;
text-align: center;
color: var(--text-secondary);
font-size: 13px;
}
/* ── Header ─────────────────────────────────────────────────────── */
.org-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 28px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 18px;
flex-wrap: wrap;
background:
linear-gradient(180deg,
color-mix(in srgb, var(--bg-elevated) 34%, var(--bg) 66%),
color-mix(in srgb, var(--bg) 92%, var(--bg-elevated) 8%));
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.org-header-main {
display: flex;
flex-direction: column;
gap: 7px;
min-width: min(100%, 260px);
flex: 1 1 420px;
}
.org-eyebrow {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.org-eyebrow-separator {
color: color-mix(in srgb, var(--text-secondary) 46%, transparent);
}
.org-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.org-title {
font-size: 21px;
font-weight: 650;
margin: 0;
max-width: min(58vw, 680px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0;
}
.org-version-badge {
font-size: 10px;
padding: 2px 7px;
border-radius: 4px;
background: color-mix(in srgb, var(--accent) 13%, transparent);
color: var(--accent);
font-family: 'JetBrains Mono', 'SF Mono', monospace;
}
.org-state-badge,
.org-profile-badge {
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
}
.org-state-badge--editable {
background: color-mix(in srgb, var(--green) 11%, transparent);
color: var(--green);
border: 1px solid color-mix(in srgb, var(--green) 20%, transparent);
}
.org-state-badge--readonly {
background: color-mix(in srgb, var(--text-secondary) 10%, transparent);
color: var(--text-secondary);
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
}
.org-profile-badge {
background: color-mix(in srgb, var(--yellow) 11%, transparent);
color: var(--yellow);
}
.org-header-meta {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
min-width: 0;
}
.org-meta-pill,
.org-meta-code {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
background: color-mix(in srgb, var(--bg) 58%, transparent);
color: var(--text-secondary);
font-size: 10.5px;
line-height: 1;
text-transform: capitalize;
}
.org-meta-code {
max-width: min(52vw, 360px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
text-transform: none;
color: var(--text);
}
.org-meta-pill--runtime {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 22%, transparent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.org-meta-pill--saved {
color: var(--green);
border-color: color-mix(in srgb, var(--green) 24%, transparent);
background: color-mix(in srgb, var(--green) 8%, transparent);
}
.org-control-panel {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 10px;
flex: 0 1 430px;
min-width: min(100%, 300px);
}
.org-switcher {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 210px;
flex: 1 1 240px;
}
.org-switcher-label {
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.org-switcher-select-wrap {
position: relative;
display: block;
}
.org-switcher-select {
width: 100%;
min-height: 36px;
padding: 7px 34px 7px 11px;
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
border-radius: 7px;
background:
linear-gradient(180deg,
color-mix(in srgb, var(--bg-elevated) 76%, var(--bg) 24%),
color-mix(in srgb, var(--bg) 88%, var(--bg-elevated) 12%));
color: var(--text);
font: inherit;
font-size: 12px;
outline: none;
cursor: pointer;
appearance: none;
}
.org-switcher-select option {
background: var(--bg-elevated);
color: var(--text);
}
.org-switcher-select-wrap::after {
content: "";
position: absolute;
right: 12px;
top: 50%;
width: 7px;
height: 7px;
border-right: 1.5px solid var(--text-secondary);
border-bottom: 1.5px solid var(--text-secondary);
transform: translateY(-68%) rotate(45deg);
pointer-events: none;
}
.org-switcher-select:hover,
.org-switcher-select:focus {
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
}
.org-create-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 36px;
padding: 8px 14px;
border: 1px solid color-mix(in srgb, var(--accent) 36%, transparent);
border-radius: 7px;
background:
linear-gradient(180deg,
color-mix(in srgb, var(--accent) 22%, var(--bg-elevated) 78%),
color-mix(in srgb, var(--accent) 13%, var(--bg) 87%));
color: color-mix(in srgb, var(--accent) 84%, var(--text) 16%);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent);
transition: transform 0.15s, border-color 0.15s, background 0.15s;
}
.org-create-trigger:hover {
border-color: color-mix(in srgb, var(--accent) 54%, transparent);
transform: translateY(-1px);
}
.org-create-trigger-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 18%, transparent);
font-size: 16px;
line-height: 1;
}
/* ── Stats Strip ────────────────────────────────────────────────── */
.org-stats-strip {
display: grid;
grid-template-columns: repeat(4, minmax(104px, auto));
gap: 7px;
align-items: center;
justify-content: flex-end;
flex: 1 1 460px;
}
.org-stat {
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
font-size: 11px;
color: var(--text-secondary);
min-height: 32px;
padding: 6px 11px;
background: color-mix(in srgb, var(--bg-elevated) 58%, var(--bg) 42%);
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
white-space: nowrap;
}
.org-stat b {
color: var(--text);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.org-stat-icon {
width: 13px;
height: 13px;
opacity: 0.72;
}
/* ── Sub-tab Navigation ────────────────────────────────────────── */
.org-subtabs {
display: flex;
gap: 2px;
padding: 0 28px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: color-mix(in srgb, var(--bg) 92%, transparent);
position: sticky;
top: 0;
z-index: 10;
overflow-x: auto;
}
.org-subtab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 11px 16px 12px;
margin-bottom: -1px;
font-size: 12px;
color: var(--text-secondary);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.org-subtab:hover:not(.org-subtab--active) {
color: var(--text);
}
.org-subtab--active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.org-subtab-icon {
width: 14px;
height: 14px;
opacity: 0.6;
flex-shrink: 0;
}
.org-subtab--active .org-subtab-icon {
opacity: 1;
}
.org-subtab-label {
font-size: 12px;
}
.org-subtab-count {
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
background: color-mix(in srgb, var(--bg-elevated) 70%, var(--bg) 30%);
color: var(--text-secondary);
font-weight: 500;
}
.org-subtab--active .org-subtab-count {
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
/* ── Toast Notification ─────────────────────────────────────────── */
.org-toast {
padding: 8px 16px;
margin: 0 20px;
font-size: 12px;
border-radius: 6px;
cursor: pointer;
flex-shrink: 0;
}
.org-toast--info {
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
}
.org-toast--warn {
background: color-mix(in srgb, var(--yellow) 12%, transparent);
color: var(--yellow);
border: 1px solid color-mix(in srgb, var(--yellow) 25%, transparent);
}
.org-toast--ok {
background: color-mix(in srgb, var(--green) 12%, transparent);
color: var(--green);
border: 1px solid color-mix(in srgb, var(--green) 25%, transparent);
}
.org-toast--error {
background: color-mix(in srgb, var(--red) 12%, transparent);
color: var(--red);
border: 1px solid color-mix(in srgb, var(--red) 25%, transparent);
}
/* ── Tab Content Area ──────────────────────────────────────────── */
.org-tab-content {
flex: 1 0 auto;
overflow: visible;
padding: 16px 28px 28px;
}
/* ── Create Organization Modal ─────────────────────────────────── */
.org-create-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: color-mix(in srgb, #05070b 68%, transparent);
}
.org-create-modal {
width: min(720px, 100%);
max-height: min(720px, calc(100vh - 48px));
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
border-radius: 8px;
background:
linear-gradient(180deg,
color-mix(in srgb, var(--bg-elevated) 88%, var(--bg) 12%),
color-mix(in srgb, var(--bg) 94%, var(--bg-elevated) 6%));
box-shadow: 0 28px 80px rgb(0 0 0 / 0.46);
overflow: hidden;
}
.org-create-header,
.org-create-actions,
.org-create-review-head,
.org-create-review-row {
display: flex;
align-items: center;
}
.org-create-header {
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.org-create-eyebrow {
display: block;
margin-bottom: 5px;
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.org-create-title {
margin: 0;
color: var(--text);
font-size: 19px;
font-weight: 650;
letter-spacing: 0;
}
.org-create-close,
.org-create-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--bg) 60%, transparent);
color: var(--text-secondary);
cursor: pointer;
}
.org-create-close {
width: 30px;
height: 30px;
font-size: 14px;
}
.org-create-close:hover,
.org-create-icon-btn:hover:not(:disabled) {
color: var(--text);
border-color: color-mix(in srgb, var(--text-secondary) 34%, var(--border));
}
.org-create-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
flex-shrink: 0;
}
.org-create-step {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 34px;
padding: 7px 10px;
border: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--bg) 58%, transparent);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.org-create-step span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: color-mix(in srgb, var(--text-secondary) 12%, transparent);
color: var(--text-secondary);
font-size: 10px;
}
.org-create-step--active {
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
color: var(--text);
background: color-mix(in srgb, var(--accent) 9%, var(--bg) 91%);
}
.org-create-step--active span,
.org-create-step--done span {
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: var(--accent);
}
.org-create-panel {
min-height: 180px;
overflow: auto;
padding: 4px 2px;
}
.org-create-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.org-create-field span {
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.org-create-field input,
.org-create-member-row input,
.org-create-member-row select,
.org-create-member-row textarea {
min-height: 38px;
padding: 8px 10px;
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--bg) 64%, transparent);
color: var(--text);
font: inherit;
font-size: 13px;
outline: none;
}
.org-create-field input:focus,
.org-create-member-row input:focus,
.org-create-member-row select:focus,
.org-create-member-row textarea:focus {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.org-create-member-row select option {
background: var(--bg-elevated);
color: var(--text);
}
.org-create-member-row textarea {
grid-column: 1 / -1;
min-height: 62px;
line-height: 1.45;
resize: vertical;
}
.org-create-member-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.org-create-member-row {
display: grid;
grid-template-columns: minmax(130px, 1fr) minmax(150px, 1.5fr) minmax(120px, 0.8fr) 34px;
gap: 8px;
align-items: center;
}
.org-create-icon-btn {
width: 34px;
height: 34px;
font-size: 16px;
}
.org-create-icon-btn:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.org-create-add {
margin-top: 12px;
min-height: 34px;
padding: 7px 12px;
border: 1px dashed color-mix(in srgb, var(--accent) 34%, var(--border));
border-radius: 7px;
background: color-mix(in srgb, var(--accent) 7%, transparent);
color: var(--accent);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.org-create-review {
display: flex;
flex-direction: column;
gap: 8px;
}
.org-create-review-head,
.org-create-review-row {
justify-content: space-between;
gap: 14px;
min-height: 38px;
padding: 8px 10px;
border: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--bg) 56%, transparent);
}
.org-create-review-head span,
.org-create-review-row strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-create-review-head b,
.org-create-review-row span {
flex-shrink: 0;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.org-create-review-row span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.org-create-review-row em {
padding: 2px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
font-style: normal;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.org-create-error {
padding: 9px 11px;
border: 1px solid color-mix(in srgb, var(--red) 28%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--red) 10%, transparent);
color: var(--red);
font-size: 12px;
}
.org-create-actions {
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
@media (max-width: 1040px) {
.org-title {
max-width: 100%;
}
.org-control-panel {
flex: 1 1 100%;
justify-content: stretch;
}
.org-stats-strip {
grid-template-columns: repeat(2, minmax(128px, 1fr));
justify-content: stretch;
width: 100%;
}
}
@media (max-width: 760px) {
.org-header {
padding: 14px 16px 12px;
}
.org-header-main {
flex-basis: 100%;
}
.org-title {
max-width: 100%;
font-size: 18px;
}
.org-stats-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.org-control-panel {
flex-direction: column;
align-items: stretch;
}
.org-switcher {
min-width: 0;
}
.org-create-trigger {
width: 100%;
}
.org-subtabs {
padding: 0 16px;
}
.org-tab-content {
padding: 12px 16px 18px;
}
.org-create-backdrop {
align-items: flex-start;
padding: 14px;
}
.org-create-modal {
max-height: calc(100vh - 28px);
padding: 16px;
}
.org-create-steps {
grid-template-columns: 1fr;
}
.org-create-member-row {
grid-template-columns: 1fr;
}
.org-create-icon-btn {
width: 100%;
}
}
@media (max-width: 460px) {
.org-header {
gap: 12px;
}
.org-stats-strip {
grid-template-columns: 1fr;
}
.org-meta-code {
max-width: 100%;
}
}
/* ── Unified button system ───────────────────────────────────────── */
/* .btn = new canonical name. Old class names (e.g. .oc-btn-primary) */
/* are kept as aliases in grouped selectors so existing TSX keeps */
/* working without modification. */
.btn,
.oc-btn-primary, .oc-btn-ghost, .oc-btn-danger,
.mkt-btn, .mkt-btn-primary, .mkt-btn-ghost,
.pkg-btn, .pkg-btn-primary, .pkg-btn-secondary, .pkg-btn-danger, .pkg-btn-ghost,
.cfg-io-btn, .cfg-io-btn-primary, .cfg-io-btn-ghost,
.tm-detail-hire-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-xs);
border: 1px solid transparent;
background: transparent;
color: var(--text);
cursor: pointer;
white-space: nowrap;
font-family: inherit;
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
}
.btn:disabled,
.oc-btn-primary:disabled, .mkt-btn-primary:disabled,
.pkg-btn-primary:disabled, .pkg-btn-danger:disabled,
.cfg-io-btn:disabled, .cfg-io-btn-primary:disabled, .cfg-io-btn-ghost:disabled,
.tm-detail-hire-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Primary: accent fill, white text */
.btn-primary,
.oc-btn-primary, .mkt-btn-primary, .pkg-btn-primary, .cfg-io-btn-primary,
.tm-detail-hire-btn {
background: var(--accent);
color: var(--white);
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled),
.oc-btn-primary:hover:not(:disabled), .mkt-btn-primary:hover:not(:disabled),
.pkg-btn-primary:hover:not(:disabled), .cfg-io-btn-primary:hover:not(:disabled),
.tm-detail-hire-btn:hover:not(:disabled) {
opacity: 0.88;
}
/* Ghost: transparent, text-secondary, hover text */
.btn-ghost,
.oc-btn-ghost, .mkt-btn-ghost, .pkg-btn-ghost, .cfg-io-btn-ghost {
background: transparent;
color: var(--text-secondary);
border-color: var(--border);
}
.btn-ghost:hover:not(:disabled),
.oc-btn-ghost:hover:not(:disabled), .mkt-btn-ghost:hover:not(:disabled),
.pkg-btn-ghost:hover:not(:disabled), .cfg-io-btn-ghost:hover:not(:disabled) {
color: var(--text);
border-color: var(--text-secondary);
}
/* Secondary: filled surface, subtle */
.btn-secondary,
.pkg-btn-secondary {
background: var(--bg-secondary);
color: var(--text);
border-color: var(--border);
}
.btn-secondary:hover:not(:disabled),
.pkg-btn-secondary:hover:not(:disabled) {
border-color: var(--accent);
}
/* Danger: red outline, hover red fill */
.btn-danger,
.oc-btn-danger, .pkg-btn-danger {
background: transparent;
color: var(--red);
border-color: var(--red);
}
.btn-danger:hover:not(:disabled),
.oc-btn-danger:hover:not(:disabled), .pkg-btn-danger:hover:not(:disabled) {
background: var(--red);
color: var(--white);
}
/* Size: small */
.btn-sm,
.mkt-btn-sm, .pkg-btn-sm {
padding: 4px 10px;
font-size: 11px;
}
/* Size: large (hero CTA) */
.btn-lg,
.qs-create-btn {
padding: 8px 24px;
font-size: 14px;
}
/* Icon slot inside a button */
.btn-icon,
.pkg-btn-icon, .cfg-io-btn-icon {
width: 14px;
height: 14px;
opacity: 0.9;
flex-shrink: 0;
}
/* White-invert icon when inside a primary button (so icon is visible on accent bg) */
.btn-primary .btn-icon,
.btn-primary .pkg-btn-icon, .btn-primary .cfg-io-btn-icon,
.oc-btn-primary img, .mkt-btn-primary img,
.pkg-btn-primary .pkg-btn-icon, .cfg-io-btn-primary .cfg-io-btn-icon {
filter: brightness(0) invert(1);
}
/* ── Polish: universal focus-visible ring ──────────────────────────── */
/* WCAG-friendly accessibility ring for keyboard navigation. */
/* Applied to buttons (all .btn aliases), sub-tabs, and form controls. */
.btn:focus-visible,
.oc-btn-primary:focus-visible, .oc-btn-ghost:focus-visible, .oc-btn-danger:focus-visible,
.mkt-btn:focus-visible, .mkt-btn-primary:focus-visible, .mkt-btn-ghost:focus-visible,
.pkg-btn:focus-visible, .pkg-btn-primary:focus-visible, .pkg-btn-secondary:focus-visible,
.pkg-btn-danger:focus-visible, .pkg-btn-ghost:focus-visible,
.cfg-io-btn:focus-visible, .cfg-io-btn-primary:focus-visible, .cfg-io-btn-ghost:focus-visible,
.tm-detail-hire-btn:focus-visible,
.org-subtab:focus-visible,
.mkt-pill:focus-visible,
.myorg-inline-btn:focus-visible, .myorg-reset-btn:focus-visible,
.wfe-action-btn:focus-visible, .wfe-pattern-btn:focus-visible,
.oc-action-btn:focus-visible, .oc-advanced-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
/* Form controls: move the accent ring inside (via box-shadow) to avoid */
/* clipping against tight parent containers. */
.oc-form-row input:focus-visible,
.oc-form-row select:focus-visible,
.oc-form-row textarea:focus-visible,
.cfg-io-textarea:focus-visible,
.mkt-search:focus-visible,
.pkg-search:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
}
/* Remove default outline on focus when NOT keyboard-nav (mouse click). */
.btn:focus:not(:focus-visible),
.org-subtab:focus:not(:focus-visible),
.mkt-pill:focus:not(:focus-visible) {
outline: none;
}
@@ -0,0 +1,59 @@
/**
* Keyed icon library for Role icons.
*
* Avoids storing 300-char SVG data-URIs in config YAML by letting RoleConfig.icon
* hold a short key like "leader" / "strategy" that maps to the inline SVG at
* render time. Legacy data-URI strings still render via fallback in lookups.
*/
export const ROLE_ICON_KEYS = [
'leader', 'strategy', 'target',
'code', 'terminal', 'database', 'settings', 'bug',
'design', 'pen', 'layout',
'marketing', 'analytics', 'writing',
'clipboard', 'security', 'support', 'idea',
'person', 'team', 'camera', 'calendar', 'group', 'ai',
'generic',
] as const
export type RoleIconKey = typeof ROLE_ICON_KEYS[number]
export const ROLE_ICONS: Record<RoleIconKey, string> = {
// Inline SVG data-URIs — one per key. Content copied verbatim from
// ROLE_ICON_GALLERY in OrgChart.tsx (post P4.5 CDN elimination).
leader: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5v-2z'/%3E%3C/svg%3E",
strategy: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 22H5v-2h14v2zM17.16 8.26C18.22 9.63 19 10.85 19 12c0 2.21-3.13 4-7 4s-7-1.79-7-4c0-1.15.78-2.37 1.84-3.74C7.83 7.03 9 5.67 9 4.5a2.5 2.5 0 0 1 5 0c0 1.17 1.17 2.53 2.16 3.76zM12 6a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1z'/%3E%3C/svg%3E",
target: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6a2 2 0 1 0 0 4 2 2 0 0 0 0-4z'/%3E%3C/svg%3E",
code: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0L19.2 12l-4.6-4.6L16 6l6 6-6 6-1.4-1.4z'/%3E%3C/svg%3E",
terminal: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20 4H4c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8h16v10zM13 14h4v2h-4zm-3.41-.82l-2.59 2.59L8.41 17 12 13.41 8.41 9.82 7 11.24l2.59 2.59z'/%3E%3C/svg%3E",
database: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 2C6.48 2 2 3.79 2 6v12c0 2.21 4.48 4 10 4s10-1.79 10-4V6c0-2.21-4.48-4-10-4zm0 18c-4.42 0-8-1.34-8-3v-2.5c1.74 1.5 4.71 2.5 8 2.5s6.26-1 8-2.5V17c0 1.66-3.58 3-8 3zm0-5c-4.42 0-8-1.34-8-3v-2.5c1.74 1.5 4.71 2.5 8 2.5s6.26-1 8-2.5V12c0 1.66-3.58 3-8 3zm0-5c-4.42 0-8-1.34-8-3s3.58-3 8-3 8 1.34 8 3-3.58 3-8 3z'/%3E%3C/svg%3E",
settings: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19.43 12.98c.04-.32.07-.64.07-.98 0-.34-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z'/%3E%3C/svg%3E",
bug: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5s-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z'/%3E%3C/svg%3E",
design: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10c1.38 0 2.5-1.12 2.5-2.5 0-.61-.23-1.2-.64-1.67-.08-.1-.13-.21-.13-.33 0-.28.22-.5.5-.5H16c3.31 0 6-2.69 6-6 0-4.96-4.49-9-10-9zm5.5 11c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm-3-4c-.83 0-1.5-.67-1.5-1.5S13.67 6 14.5 6s1.5.67 1.5 1.5S15.33 9 14.5 9zm-5 0C8.67 9 8 8.33 8 7.5S8.67 6 9.5 6s1.5.67 1.5 1.5S10.33 9 9.5 9zm-3 4c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z'/%3E%3C/svg%3E",
pen: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z'/%3E%3C/svg%3E",
layout: "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",
marketing: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M18 11v2h4v-2h-4zm-2 6.61c.96.71 2.21 1.65 3.2 2.39.4-.53.8-1.07 1.2-1.6-.99-.74-2.24-1.68-3.2-2.4-.4.54-.8 1.08-1.2 1.61zM20.4 5.6c-.4-.53-.8-1.07-1.2-1.6-.99.74-2.24 1.68-3.2 2.4.4.53.8 1.07 1.2 1.6.96-.72 2.21-1.65 3.2-2.4zM4 9c-1.1 0-2 .9-2 2v2c0 1.1.9 2 2 2h1v4h2v-4h1l5 3V6L8 9H4zm11.5 3c0-1.33-.58-2.53-1.5-3.35v6.69c.92-.81 1.5-2.01 1.5-3.34z'/%3E%3C/svg%3E",
analytics: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99l1.5 1.5z'/%3E%3C/svg%3E",
writing: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z'/%3E%3C/svg%3E",
clipboard: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm2 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E",
security: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11V11.99z'/%3E%3C/svg%3E",
support: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M21 6h-2v9H6v2c0 .55.45 1 1 1h11l4 4V7c0-.55-.45-1-1-1zm-4 6V3c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1v14l4-4h10c.55 0 1-.45 1-1z'/%3E%3C/svg%3E",
idea: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7z'/%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",
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",
camera: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 15.2c-1.77 0-3.2-1.43-3.2-3.2s1.43-3.2 3.2-3.2 3.2 1.43 3.2 3.2-1.43 3.2-3.2 3.2zM9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z'/%3E%3C/svg%3E",
calendar: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z'/%3E%3C/svg%3E",
group: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73L18 18H6l.01-1.62c0-1.17.68-2.25 1.76-2.73 1.17-.51 2.6-.9 4.23-.9zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58C.48 14.9 0 15.62 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85-.85-.37-1.79-.58-2.78-.58-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z'/%3E%3C/svg%3E",
ai: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23888' d='M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z'/%3E%3C/svg%3E",
generic: "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",
}
/**
* Resolve a role.icon string to a usable `src` for <img>.
* Key -> SVG lookup; legacy full data-URI -> pass through.
*/
export function resolveRoleIcon(icon: string | null | undefined): string {
if (!icon) return ROLE_ICONS.generic
if (icon.startsWith('data:')) return icon // legacy
return ROLE_ICONS[icon as RoleIconKey] ?? ROLE_ICONS.generic
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,844 @@
/* Team, runtime summary, and My Organization styles. */
/* ── Team View ─────────────────────────────────────────────────── */
.team-view {
display: flex;
flex-direction: column;
gap: 14px;
padding: 0;
min-width: 0;
}
.team-command-bar {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto auto;
gap: 14px;
align-items: center;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-elevated) 34%, var(--bg) 66%);
}
.team-command-bar--readonly {
background: color-mix(in srgb, var(--bg-elevated) 24%, var(--bg) 76%);
}
.team-command-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.team-command-eyebrow {
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.team-command-title {
color: var(--text);
font-size: 13px;
font-weight: 600;
}
.team-command-metrics {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
justify-content: flex-end;
color: var(--text-secondary);
font-size: 11px;
}
.team-command-metrics span {
min-height: 24px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--bg) 54%, transparent);
white-space: nowrap;
}
.team-command-metrics b {
color: var(--text);
font-variant-numeric: tabular-nums;
}
.team-command-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.team-readonly-strip {
padding: 9px 12px;
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--text-secondary) 6%, transparent);
color: var(--text-secondary);
font-size: 12px;
}
/* Team Roster Grid */
.team-roster-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(282px, 1fr));
gap: 12px;
}
.team-roster-card {
min-width: 0;
background: color-mix(in srgb, var(--bg) 84%, var(--bg-elevated) 16%);
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
border-radius: 8px;
padding: 11px 12px;
transition: border-color 150ms ease, background 150ms ease;
}
.team-roster-card:hover {
background: color-mix(in srgb, var(--bg-elevated) 48%, var(--bg) 52%);
border-color: color-mix(in srgb, var(--border-hover) 78%, var(--accent) 22%);
}
.team-roster-card-header {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 10px;
padding-bottom: 9px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
}
.team-roster-card-icon {
width: 22px;
height: 22px;
padding: 4px;
border-radius: 7px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
}
.team-roster-role-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-roster-count {
font-size: 10px;
color: var(--text-secondary);
padding: 2px 7px;
background: color-mix(in srgb, var(--bg-elevated) 78%, var(--bg) 22%);
border-radius: 999px;
font-variant-numeric: tabular-nums;
}
/* Employee rows */
.team-roster-emp {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 0;
}
.team-roster-emp + .team-roster-emp {
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.team-roster-emp-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
opacity: 0.58;
}
.team-roster-emp-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.team-roster-emp-name {
font-size: 12.5px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-roster-seniority {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.3px;
font-weight: 600;
}
.team-roster-seniority--junior { color: var(--text-secondary); }
.team-roster-seniority--mid { color: var(--yellow); }
.team-roster-seniority--senior { color: var(--accent); }
.team-roster-seniority--lead { color: var(--green); }
/* Badges and actions */
.team-roster-badge {
font-size: 9px;
padding: 3px 8px;
border-radius: 999px;
font-weight: 500;
white-space: nowrap;
}
.team-roster-badge--active {
background: color-mix(in srgb, var(--green) 12%, transparent);
color: var(--green);
}
.team-roster-deploy-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
padding: 3px 10px;
border: 1px solid var(--accent);
border-radius: 999px;
background: none;
color: var(--accent);
cursor: pointer;
transition: all 0.15s;
}
.team-roster-deploy-btn:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.team-roster-deploy-icon {
width: 11px;
height: 11px;
}
/* Vacant state */
.team-roster-vacant {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 0;
}
.team-roster-vacant-text {
font-size: 11px;
color: var(--text-secondary);
font-style: italic;
}
.team-roster-hire-btn {
font-size: 10px;
padding: 3px 10px;
border: 1px solid var(--yellow);
border-radius: 999px;
background: none;
color: var(--yellow);
cursor: pointer;
transition: all 0.15s;
}
.team-roster-hire-btn:hover {
background: color-mix(in srgb, var(--yellow) 12%, transparent);
}
/* ══════════════════════════════════════════════════════════════════════
WORKFLOW EDITOR (.wfe-*)
══════════════════════════════════════════════════════════════════════ */
.wfe-container {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 12px;
}
.wfe-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.wfe-title { font-size: 14px; font-weight: 600; color: var(--text); margin: 0; }
.wfe-profile-badge {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 2px 6px;
border-radius: 3px;
background: var(--bg);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.wfe-header-actions { display: flex; gap: 8px; margin-left: auto; }
.wfe-action-btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 5px 10px; font-size: 11px;
border: 1px solid var(--border); border-radius: 6px;
background: transparent; color: var(--text-secondary); cursor: pointer;
transition: all 0.15s;
}
.wfe-action-btn:hover { border-color: var(--accent); color: var(--text); }
.wfe-action-icon { width: 13px; height: 13px; opacity: 0.6; }
.wfe-save-btn { border-color: var(--green, #27ae60); color: var(--green, #27ae60); }
.wfe-save-btn:hover { background: var(--green, #27ae60); color: var(--white); }
.wfe-dag-wrap { overflow-x: auto; overflow-y: visible; padding: 8px 0; min-height: 0; }
.wfe-dag-node { cursor: pointer; }
.wfe-dag-node:hover { box-shadow: 0 0 0 2px var(--accent); border-radius: 6px; }
.wfe-node-editing { box-shadow: 0 0 0 2px var(--accent); }
.wfe-editor {
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 14px;
margin-top: 12px;
}
.wfe-editor-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
}
.wfe-editor-title { font-size: 12px; font-weight: 600; color: var(--text); }
.wfe-editor-close {
font-size: 18px; background: none; border: none;
color: var(--text-secondary); cursor: pointer;
}
.wfe-editor-close:hover { color: var(--text); }
.wfe-add-form {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.wfe-readonly { opacity: 0.6; }
.wfe-inline-input,
.wfe-inline-select {
margin-left: 6px;
padding: 3px 6px;
font-size: 11px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
}
.wfe-hint { font-size: 10px; color: var(--text-secondary); font-style: italic; }
.wfe-empty { text-align: center; padding: 30px; color: var(--text-secondary); font-size: 13px; }
.wfe-empty-hint { font-size: 11px; opacity: 0.7; }
/* ══════════════════════════════════════════════════════════════════════
MY ORGANIZATION (.myorg-*)
══════════════════════════════════════════════════════════════════════ */
.myorg-container { padding: 0 4px; overflow-y: auto; flex: 1; }
.myorg-section {
background:
linear-gradient(180deg,
color-mix(in srgb, var(--bg-elevated) 30%, var(--bg) 70%),
color-mix(in srgb, var(--bg) 94%, var(--bg-elevated) 6%));
border: 1px solid color-mix(in srgb, var(--border) 86%, transparent);
border-radius: 8px;
padding: 15px 16px 16px;
margin-bottom: 0;
}
.myorg-section-header {
display: flex; align-items: center; gap: 9px; margin-bottom: 14px; flex-wrap: wrap;
}
.myorg-section-icon { width: 16px; height: 16px; opacity: 0.68; }
.myorg-section-title { font-size: 14px; font-weight: 600; color: var(--text); margin: 0; }
.myorg-section-count { font-size: 11px; color: var(--text-secondary); }
.myorg-section-spacer { flex: 1 1 auto; min-width: 12px; }
.myorg-section-note {
color: var(--text-secondary);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.myorg-form {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
}
.myorg-form-title { font-size: 13px; font-weight: 600; color: var(--text); margin: 0 0 10px; }
/* Roster grid */
.myorg-roster-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.myorg-roster-role {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
}
.myorg-roster-role-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 6px;
}
.myorg-roster-role-name { font-size: 12px; font-weight: 600; color: var(--text); }
.myorg-roster-role-count { font-size: 10px; color: var(--text-secondary); }
.myorg-roster-emp {
display: flex; align-items: center; gap: 6px;
padding: 3px 0;
}
.myorg-roster-emp-name { font-size: 11px; color: var(--text); }
.myorg-roster-seniority {
font-size: 9px; padding: 1px 5px; border-radius: 3px;
text-transform: uppercase; letter-spacing: 0.3px;
}
.myorg-seniority-junior { background: var(--bg-secondary); color: var(--text-secondary); }
.myorg-seniority-mid { background: rgba(52,152,219,0.15); color: var(--accent); }
.myorg-seniority-senior { background: rgba(39,174,96,0.15); color: var(--green, #27ae60); }
.myorg-roster-office-badge {
font-size: 9px; color: var(--green, #27ae60);
border: 1px solid var(--green, #27ae60);
padding: 0 4px; border-radius: 3px;
}
.myorg-roster-vacant { font-size: 10px; color: var(--text-secondary); font-style: italic; }
.myorg-empty-hint { font-size: 11px; color: var(--text-secondary); padding: 12px 0; }
/* Collapsible sections */
.myorg-collapsible {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.myorg-collapsible-toggle {
width: 100%;
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
font-size: 12px;
background: var(--bg-secondary);
border: none;
color: var(--text);
cursor: pointer;
text-align: left;
}
.myorg-collapsible-toggle:hover { background: var(--bg); }
.myorg-toggle-icon { font-size: 10px; color: var(--text-secondary); width: 12px; }
.myorg-collapsible-title { font-weight: 500; }
.myorg-collapsible-count {
font-size: 10px; color: var(--text-secondary);
background: var(--bg); padding: 1px 6px; border-radius: 8px;
}
.myorg-collapsible-extra { margin-left: auto; }
.myorg-collapsible-body { padding: 12px 14px; }
.myorg-inline-btn {
display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 16%, transparent);
border-radius: 999px;
padding: 5px 9px;
cursor: pointer;
}
.myorg-inline-btn:hover { background: color-mix(in srgb, var(--accent) 13%, transparent); }
.myorg-inline-icon { width: 12px; height: 12px; }
/* ── Reset Architecture ────────────────────────────────────────── */
.myorg-action-bar {
display: flex;
justify-content: flex-end;
padding: 0;
}
.myorg-reset-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 11px;
font-size: 11px;
color: var(--red);
background: color-mix(in srgb, var(--red) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--red) 30%, transparent);
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.myorg-reset-btn:hover {
background: color-mix(in srgb, var(--red) 10%, transparent);
border-color: var(--red);
}
.myorg-reset-icon { width: 12px; height: 12px; filter: hue-rotate(0deg); }
/* ── OrgChart: show responsibility instead of ID ───────────────── */
.oc-node-resp {
font-size: 10px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
}
/* ── Icon Picker ───────────────────────────────────────────────── */
.oc-icon-picker-trigger {
position: relative;
}
.oc-icon-preview-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
color: var(--text);
font-size: 12px;
transition: border-color 0.15s;
}
.oc-icon-preview-btn:hover {
border-color: var(--accent);
}
.oc-icon-preview-img {
width: 24px;
height: 24px;
border-radius: 4px;
}
.oc-icon-placeholder {
opacity: 0.35;
}
.oc-icon-preview-label {
color: var(--text-secondary);
font-size: 11px;
}
.oc-icon-clear-btn {
padding: 2px 8px;
font-size: 10px;
color: var(--text-secondary);
background: none;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
margin-left: 6px;
}
.oc-icon-clear-btn:hover {
color: var(--red);
border-color: var(--red);
}
.oc-icon-gallery {
position: absolute;
top: 100%;
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
padding: 8px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
margin-top: 4px;
width: 240px;
}
.oc-icon-option {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border: 1px solid transparent;
border-radius: 6px;
background: none;
cursor: pointer;
transition: all 0.15s;
}
.oc-icon-option:hover {
background: color-mix(in srgb, var(--accent) 10%, transparent);
border-color: var(--accent);
}
.oc-icon-option--active {
background: color-mix(in srgb, var(--accent) 15%, transparent);
border-color: var(--accent);
}
.oc-icon-option-img {
width: 24px;
height: 24px;
}
/* ── Quick Start Wizard ────────────────────────────────────────── */
.qs-wizard {
padding: 24px;
max-width: 640px;
margin: 0 auto;
}
.qs-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 48px 24px;
color: var(--accent);
font-size: 14px;
}
.qs-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
}
.qs-header-icon {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.qs-header-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.qs-header-sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--text-dim);
}
.qs-link-btn {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
padding: 0;
font-size: 12px;
text-decoration: underline;
}
/* Progress bar */
.qs-progress {
display: flex;
gap: 4px;
margin-bottom: 20px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.qs-step {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
color: var(--text-dim);
background: var(--bg-card);
transition: all 0.2s;
}
.qs-step-active {
background: var(--accent);
color: var(--white);
}
.qs-step-done {
background: var(--green);
color: var(--white);
}
.qs-step-icon {
width: 14px;
height: 14px;
filter: grayscale(1) opacity(0.5);
}
.qs-step-active .qs-step-icon,
.qs-step-done .qs-step-icon {
filter: brightness(10);
}
/* Panel */
.qs-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
}
.qs-panel h4 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.qs-hint {
font-size: 12px;
color: var(--text-dim);
margin: 0 0 16px;
}
/* Member rows */
.qs-members {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.qs-member-row {
display: flex;
gap: 6px;
align-items: center;
}
.qs-member-name {
flex: 1;
min-width: 0;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 12px;
}
.qs-member-resp {
flex: 1.5;
min-width: 0;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 12px;
}
.qs-member-parent {
flex: 1;
min-width: 0;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 11px;
}
.qs-remove-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
opacity: 0.4;
transition: opacity 0.15s;
}
.qs-remove-btn:hover { opacity: 1; }
.qs-remove-icon { width: 14px; height: 14px; }
.qs-add-member {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
border: 1px dashed var(--border);
background: none;
color: var(--accent);
font-size: 12px;
cursor: pointer;
margin-bottom: 16px;
}
.qs-add-member:hover { background: var(--bg); }
.qs-add-icon { width: 14px; height: 14px; }
/* Nav */
.qs-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
/* Pattern cards */
.qs-patterns {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.qs-pattern-card {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: 8px;
border: 2px solid var(--border);
cursor: pointer;
transition: border-color 0.15s;
}
.qs-pattern-card:hover { border-color: var(--accent); }
.qs-pattern-selected {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.qs-pattern-card input[type="radio"] {
margin-top: 3px;
flex-shrink: 0;
}
.qs-pattern-label {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.qs-pattern-desc {
font-size: 11px;
color: var(--text-dim);
margin: 2px 0;
}
.qs-pattern-preview {
font-size: 11px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--accent);
margin-top: 4px;
}
/* Preview */
.qs-preview {
margin-bottom: 8px;
}
.qs-preview-section {
margin-bottom: 12px;
}
.qs-preview-section h5 {
margin: 0 0 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.qs-preview-member {
font-size: 12px;
padding: 4px 0;
color: var(--text);
}
.qs-preview-resp { color: var(--text-dim); }
.qs-preview-parent { color: var(--text-dim); font-size: 11px; }
@media (max-width: 980px) {
.team-command-bar {
grid-template-columns: 1fr;
align-items: stretch;
}
.team-command-metrics,
.team-command-actions {
justify-content: flex-start;
}
}
@media (max-width: 560px) {
.team-roster-grid {
grid-template-columns: 1fr;
}
.team-command-actions {
align-items: stretch;
}
.team-command-actions > * {
justify-content: center;
}
.myorg-section {
padding: 12px;
}
.qs-wizard {
padding: 16px 0;
}
.qs-member-row {
flex-direction: column;
align-items: stretch;
}
}