Files
OpenOPC/opc/plugins/office_ui/frontend_src/org/OrgTab.tsx
T
小东 cb6b8ed6e1 feat: harden company resume, external sessions, and Office UI i18n
Improve suspend/resume identity continuity and external broker session handling, add Office UI internationalization with rebuilt assets, and expand company org configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 09:20:40 +08:00

439 lines
21 KiB
TypeScript

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 { useI18n } from '../i18n'
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 { t } = useI18n()
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(t('org.createdToast', { name: orgCreateResult.organization_name || orgCreateResult.name }))
}, [orgCreateResult, showToast, t])
// 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 ? t('org.customOrg') : t('org.corporateCompany'))
const activeOrgId = (isCustomMode ? (data?.organization_id || activeSavedOrg) : 'corporate') || ''
const architectureKindLabel = isCustomMode ? t('org.kind.saved') : t('org.kind.corporate')
const architectureStateLabel = isCustomMode
? activeSavedOrg ? t('org.state.editableSaved') : t('org.state.editableDraft')
: t('org.state.readOnly')
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || t('common.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(t('org.architectureApplied'))
}
}, [orgVersion, applyingPresetId, showToast, t])
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">{t('org.loading')}</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>{t('org.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">{t('common.autoSaved')}</span>
</div>
</div>
<div className="org-control-panel">
<label className="org-switcher">
<span className="org-switcher-label">{t('org.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={t('org.organization')}
>
<option value="corporate">{t('org.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>
{t('org.newOrganization')}
</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> {t('org.roles')}
</span>
<span className="org-stat">
<img src={TAB_ICON.employees} alt="" className="org-stat-icon" />
<b>{displayEmployees.length}</b> {t('org.employees')}
</span>
<span className="org-stat">
<img src={TAB_ICON.runtime} alt="" className="org-stat-icon" />
<b>{runtimeView.runtimeTeams.length}</b> {t('org.runtimeTeams')}
</span>
<span className="org-stat">
<img src={STAT_ICON.active} alt="" className="org-stat-icon" />
<b>{activeAgents.length}</b> {t('common.active')}
</span>
</div>
</div>
<div className="org-subtabs">
{([
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: t('org.tab.team'), count: displayRoles.length },
{ id: 'runtime' as SubTab, icon: TAB_ICON.runtime, label: t('org.tab.runtime'), count: runtimeView.runtimeTeams.length },
{ id: 'architecture' as SubTab, icon: TAB_ICON.architecture, label: t('org.tab.architecture'), count: marketPresets?.length ?? 0 },
{ id: 'employees' as SubTab, icon: TAB_ICON.employees, label: t('org.tab.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>
)
}