From 7036a2da4fdf4c27686e80a531d0ebc27a6a4ad1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 11:45:24 +0000 Subject: [PATCH 1/5] feat(org): host-native organization wave 1 (migration map) Land talent market, asset-bus directory ingest, governance pauses, and native Organization home on the FreeOS host so those surfaces no longer need the managed Node iframe. Keep dual-identity bounds and the zero-Node default; desktop original App remains a transition link. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + dashboard/src/api/modules/orgModule.ts | 10 + dashboard/src/locales/en.json | 12 +- dashboard/src/locales/zh.json | 12 +- dashboard/src/org-ui/api/createClient.test.ts | 14 + dashboard/src/org-ui/api/createClient.ts | 93 +++++ dashboard/src/org-ui/index.ts | 5 + .../pages/employees/EmployeesPage.test.tsx | 54 +++ .../org-ui/pages/employees/EmployeesPage.tsx | 215 +++++++++-- .../src/org-ui/pages/employees/labels.ts | 23 +- .../pages/workspace/WorkspacePage.test.tsx | 12 + .../org-ui/pages/workspace/WorkspacePage.tsx | 31 ++ .../src/org-ui/pages/workspace/labels.ts | 15 +- .../Organization/OrganizationEntry.test.tsx | 116 ++++-- .../pages/Organization/OrganizationEntry.tsx | 73 +--- .../pages/Organization/employees/index.tsx | 1 + .../src/pages/Organization/index.test.tsx | 34 ++ dashboard/src/pages/Organization/index.tsx | 63 ++++ docs/org-capability-migration-map.json | 63 ++-- docs/org-capability-migration-map.md | 58 ++- docs/org-capability-migration-map.zh-CN.md | 58 ++- src/octop/api/openapi_meta.py | 2 +- src/octop/api/routers/org_module.py | 6 +- src/octop/api/routers/org_talent.py | 123 ++++++ src/octop/i18n/en.json | 6 + src/octop/i18n/zh.json | 6 + src/octop/modules/org_os/apply/apply.py | 9 +- src/octop/modules/org_os/apply/client.py | 2 + src/octop/modules/org_os/catalog.py | 28 +- src/octop/modules/org_os/notes.py | 12 + .../modules/org_os/org_chart/__init__.py | 4 + .../modules/org_os/org_chart/host_ingest.py | 158 ++++++++ src/octop/modules/org_os/org_chart/store.py | 357 ++++++++++++++++++ src/octop/modules/org_os/overview.py | 48 ++- tests/unit/test_openxyos_apply.py | 7 + tests/unit/test_org_chart.py | 29 ++ tests/unit/test_org_module.py | 16 + tests/unit/test_org_notes.py | 4 + tests/unit/test_org_talent.py | 88 +++++ tests/unit/test_org_workspace.py | 7 + 40 files changed, 1644 insertions(+), 231 deletions(-) create mode 100644 src/octop/api/routers/org_talent.py create mode 100644 src/octop/modules/org_os/org_chart/host_ingest.py create mode 100644 tests/unit/test_org_talent.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e30ce0c4..7050fa1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### 新增 +- Wave 1(宿主原生组织):人才市场落到宿主 `org_chart.sqlite`(`talent_pool`)与 `/api/org-module/talent*`;资产总线 apply 无 Node 也写入名册/人才;治理待处理暂停出现在组织首页与工作台总览;`/organization` 即使桌面集成也不再被 iframe 吞掉(原 App 仅作过渡链接);目录叠加 `delivery`/`host_path`。双身份边界与默认零 Node 不变。迁移图:人才市场 `managed_node_iframe` → `org_ui_slice`(13→12 iframe,10→11 slice)。 - 双身份边界(P1.3):工作室本机登录与组织房间登录分成两扇门。组织管理员不再被抬成宿主 `Role.ADMIN`;工作室访客不能拿宿主通行证写组织房间业务。界面与文档用「工作室 / 另一间房间 / 各自留白」说明两条路径。 - P1.2:`freeos org export-standalone` 默认 `--mode full` 写出可商业化 openXYOS **源码包**(`openxyos/` Apache-2.0 完整 App.tsx 树 + `slice/` MIT 宿主桥 + `modules.json` 对照清单)。`--mode slice` 保留原先 org-ui SPA + `FREEOS_UPSTREAM` 反代。工作室智能体对话不导出;组织沟通协作在完整树。说明见 `docs/org-export.md`。 - Organization P1.1(双向资产总线):`freeos org loop run` / `POST /api/org-module/loop/run` / `assets apply` 在托管组织运行时、`OPENXYOS_BASE_URL` 或 `{FREEOS_HOME}/org-os/runtime.json` 可用时,把资产包 ingest 进当前**组织工作区租户**(`remote_applied=true`),不再只写 `openxyos-mirror/`。无运行时地址时仍为镜像-only,且不拉起 Node。回流后同事仍登记为 `org-` 聊天智能体。FreeOS 工作室账号与组织房间身份分开。说明见 `docs/asset-loop.md`。 diff --git a/dashboard/src/api/modules/orgModule.ts b/dashboard/src/api/modules/orgModule.ts index 56339820..3d2465e9 100644 --- a/dashboard/src/api/modules/orgModule.ts +++ b/dashboard/src/api/modules/orgModule.ts @@ -7,6 +7,8 @@ export interface OrgCapability { description: string; description_zh: string; locked: boolean; + delivery?: string; + host_path?: string; } export interface OrgModuleStatus { @@ -44,6 +46,9 @@ export interface OrgPlaneCounts { skill_packages: number; mcp: number; tasks: number; + directory_employees?: number; + talent_available?: number; + pending_pauses?: number; } export interface OrgControlPlane { @@ -96,6 +101,11 @@ export interface OrgOverview { notes: string[]; catalog: OrgCapability[]; module_toggles?: Record; + governance?: { + pending_pauses?: number; + enabled?: boolean; + href?: string; + }; } export interface OrgSidecarStart { diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 953f6538..8d9e9d1a 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -10,7 +10,7 @@ "title": "Organization", "subtitle": "A dual loop: FreeOS does the work, openXYOS governs the org — they grow each other.", "heroTitle": "Organization", - "heroStory": "FreeOS does the work. openXYOS owns organization and governance. They grow each other.", + "heroStory": "FreeOS does the work. Organization capabilities live in this room of the studio. They grow each other.", "roomDoorHint": "Organization is another room you can furnish on its own: try, rehearse, and grow your workflows. It shares the studio with everyday chat, and each door keeps its own space.", "roomFrameTitle": "Organization room", "toggleLabel": "Enable organization module", @@ -176,6 +176,14 @@ "catalogToggleHint": "When off, the module disappears from the openXYOS preview sidebar.", "autoStartingSidecar": "Opening local openXYOS…", "glossary": "Employees: already assigned to a department. Colleagues: peers in the same organization (use this word in group chat). Experts / smart helpers: not yet placed in a department — manage them on Experts.", + "roomHint": "This is the organization room — you can arrange it on its own, next to everyday studio chat. The two ways in stay apart.", + "pauseBanner": "{{count}} governance pause(s) waiting for a human decision.", + "pauseBannerAction": "Open governance", + "originalAppHint": "Unmigrated App verticals (workflows, contracts, …) still use the original preview — a transition, not the home.", + "originalAppAction": "Open original App (transition)", + "catalogNative": "Host page", + "catalogOriginalApp": "Original App", + "metricPauses": "Pending pauses", "receiptTitle": "What just happened", "assembleReceipt": "Imported {{count}} employee(s) assigned to a department. In group chat call them colleagues, not experts.", "assembleNext": "Next: open Conversations and invite colleagues to a group. Unassigned helpers stay on Experts.", @@ -202,7 +210,7 @@ "orgChartBody": "Departments and people on the host org tree. This page lives in the host — no sidecar or second port.", "openOrgChart": "Open org chart", "employeesTitle": "Employees", - "employeesBody": "Directory of people and AI colleagues on the host org tree. Same store as the org chart — no sidecar or second port.", + "employeesBody": "Directory of people and AI colleagues, plus the host talent market. Same store as the org chart — no sidecar.", "openEmployees": "Open employees", "skillsTitle": "Skills", "skillsBody": "Organization skills are generated wrappers for org APIs. Agent Skills stay on FreeOS Personalization — no second skill runtime or sidecar.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 5304c1fa..9c0d870c 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -10,7 +10,7 @@ "title": "组织", "subtitle": "FreeOS 数据面与 openXYOS 控制面的双循环:干活与治理互相生长。", "heroTitle": "组织", - "heroStory": "FreeOS 负责干活,openXYOS 负责组织与治理,两者互相生长。", + "heroStory": "FreeOS 负责干活。组织能力在这间可单独布置的房间里,两者互相生长。", "roomDoorHint": "组织像工作室里另一间可独立布置的房间:试用、演练、长出自己的流程;与日常对话同在一个 FreeOS 里,又各自留白。", "roomFrameTitle": "组织房间", "toggleLabel": "启用组织模块", @@ -176,6 +176,14 @@ "catalogToggleHint": "关闭后,该模块会从右侧 openXYOS 预览的侧栏消失。", "autoStartingSidecar": "正在打开本机 openXYOS…", "glossary": "员工:已经编入某个部门。同事:同一组织里一起干活的人(群聊里这样叫)。专家 / 智能助手:还没编入部门的帮手,在「专家」页管理。", + "roomHint": "这里是组织房间:可以单独布置,和日常工作室同属一个 FreeOS,两套入口互不合并。", + "pauseBanner": "有 {{count}} 条治理暂停等待人工决定。", + "pauseBannerAction": "打开治理", + "originalAppHint": "尚未迁入的 App 垂类(流程、合同等)仍走原预览——只是过渡,不是首页。", + "originalAppAction": "打开原 App(过渡)", + "catalogNative": "宿主页", + "catalogOriginalApp": "原 App", + "metricPauses": "待处理暂停", "receiptTitle": "刚才的结果", "assembleReceipt": "已导入 {{count}} 名员工(已入编部门)。群聊里请称他们为同事,不要叫专家。", "assembleNext": "下一步:打开「对话」建群,把同事拉进来。还没入编的帮手在「专家」页。", @@ -202,7 +210,7 @@ "orgChartBody": "宿主内的部门与人员树。此页跑在宿主内,不需要边车或第二端口。", "openOrgChart": "打开组织架构", "employeesTitle": "员工目录", - "employeesBody": "宿主组织树上的人机员工名册。与组织架构共用同一份数据,不需要边车或第二端口。", + "employeesBody": "已入编的人机员工名册,以及宿主人才市场。与组织架构同一份数据,不需要边车。", "openEmployees": "打开员工目录", "skillsTitle": "技能", "skillsBody": "组织技能是调用组织 API 的生成包装。智能体真正执行的仍是「个性化」里的 Agent Skills,没有第二套运行时或边车。", diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index 86c048d8..cb2ff8d4 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -40,6 +40,19 @@ describe("createOrgApiClient", () => { ], }; } + if (path === "/org-module/talent") { + return { + success: true, + data: [ + { + id: 7, + name: "Policy Analyst", + talent_type: "ai", + status: "available", + }, + ], + }; + } if (path === "/org-module/governance/pauses") { return { pauses: [{ pause_id: "p1", status: "pending", tool_name: "delete" }], @@ -133,6 +146,7 @@ describe("createOrgApiClient", () => { expect(await client.announcements.unread()).toEqual({ count: 2 }); expect((await client.org.tree())[0]?.name).toBe("HQ"); expect((await client.employees.list())[0]?.name).toBe("Ada"); + expect((await client.talent.list())[0]?.name).toBe("Policy Analyst"); expect((await client.governance.pauses()).pauses[0]?.pause_id).toBe("p1"); expect((await client.skills.list()).skills[0]?.slug).toBe("org-employees"); expect((await client.knowledge.list()).bases[0]?.name).toBe("Policies"); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index 7657087e..77ec5fe4 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -149,6 +149,48 @@ export interface OrgEmployeesClient { remove(id: number): Promise; } +export interface OrgTalent { + id: number; + name: string; + talent_type: string; + skills?: string; + description?: string; + source?: string; + status: string; + agent_type?: string | null; + slug?: string | null; + avatar_emoji?: string; + category?: string; + employee_id?: number | null; +} + +export interface TalentStats { + total: number; + ai: number; + human: number; + byCategory: { category: string; count: number }[]; +} + +export interface TalentListParams { + type?: string; + status?: string; + search?: string; +} + +export interface OrgTalentRecruitResult { + talent: OrgTalent; + employee: OrgEmployee; + created: boolean; + colleague_slug?: string; +} + +export interface OrgTalentClient { + list(params?: TalentListParams): Promise; + get(id: number): Promise; + stats(): Promise; + recruit(id: number, departmentId?: number | null): Promise; +} + export type GovernancePauseStatus = | "pending" | "approved" @@ -508,6 +550,8 @@ export interface OrgCapability { description: string; description_zh: string; locked: boolean; + delivery?: string; + host_path?: string; } export interface OrgPrefs { @@ -642,6 +686,9 @@ export interface OrgWorkspaceFreeos { skill_packages?: number; mcp?: number; tasks?: number; + directory_employees?: number; + talent_available?: number; + pending_pauses?: number; } export interface OrgWorkspaceOpenxyos { @@ -674,6 +721,11 @@ export interface OrgWorkspaceOverview { catalog?: OrgCapability[]; module_toggles?: Record; notes?: string[]; + governance?: { + pending_pauses?: number; + enabled?: boolean; + href?: string; + }; } export interface OrgWorkspaceClient { @@ -684,6 +736,7 @@ export interface OrgApiClient { announcements: OrgAnnouncementsClient; org: OrgChartClient; employees: OrgEmployeesClient; + talent: OrgTalentClient; governance: OrgGovernanceClient; skills: OrgSkillsClient; knowledge: OrgKnowledgeClient; @@ -717,6 +770,7 @@ export function createOrgApiClient(opts: { settingsPrefix?: string; agentsPrefix?: string; workspacePrefix?: string; + talentPrefix?: string; }): OrgApiClient { const prefix = opts.announcementsPrefix ?? "/org-module/announcements"; const orgPrefix = opts.orgPrefix ?? "/org-module/org"; @@ -728,6 +782,7 @@ export function createOrgApiClient(opts: { const settingsPrefix = opts.settingsPrefix ?? "/org-module"; const agentsPrefix = opts.agentsPrefix ?? "/org-module"; const workspacePrefix = opts.workspacePrefix ?? "/org-module"; + const talentPrefix = opts.talentPrefix ?? "/org-module/talent"; const fetchJson = opts.fetchJson; const announcements: OrgAnnouncementsClient = { @@ -930,6 +985,43 @@ export function createOrgApiClient(opts: { }, }; + const talent: OrgTalentClient = { + async list(params = {}) { + const query = new URLSearchParams(); + if (params.type) query.set("type", params.type); + if (params.status) query.set("status", params.status); + if (params.search) query.set("search", params.search); + const suffix = query.toString() ? `?${query.toString()}` : ""; + return unwrap( + await fetchJson>(`${talentPrefix}${suffix}`), + ); + }, + async get(id) { + return unwrap( + await fetchJson>(`${talentPrefix}/${id}`), + ); + }, + async stats() { + return unwrap( + await fetchJson>(`${talentPrefix}/stats`), + ); + }, + async recruit(id, departmentId) { + return unwrap( + await fetchJson>( + `${talentPrefix}/${id}/recruit`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + departmentId == null ? {} : { department_id: departmentId }, + ), + }, + ), + ); + }, + }; + const governance: OrgGovernanceClient = { async pauses(status) { const query = status ? `?status=${encodeURIComponent(status)}` : ""; @@ -1279,6 +1371,7 @@ export function createOrgApiClient(opts: { announcements, org, employees, + talent, governance, skills, knowledge, diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index 87cb4a14..820566c9 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -22,6 +22,11 @@ export type { OrgEmployee, HostSkillPackage, OrgEmployeesClient, + OrgTalent, + OrgTalentClient, + OrgTalentRecruitResult, + TalentListParams, + TalentStats, OrgEnvelope, OrgGovernanceClient, OrgKnowledgeBase, diff --git a/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx b/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx index 5d8a235c..ce5f2998 100644 --- a/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx +++ b/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx @@ -79,4 +79,58 @@ describe("EmployeesPage", () => { ); }); }); + + it("recruits host talent without an iframe", async () => { + const client = mockClient(); + const talent = { + list: vi.fn(async () => [ + { + id: 3, + name: "Policy Analyst", + talent_type: "ai", + status: "available", + skills: "policy", + }, + ]), + get: vi.fn(), + stats: vi.fn(async () => ({ + total: 1, + ai: 1, + human: 0, + byCategory: [], + })), + recruit: vi.fn(async () => ({ + talent: { id: 3, name: "Policy Analyst", status: "recruited" }, + employee: { + id: 11, + name: "Policy Analyst", + role: "", + employee_type: "ai", + department_id: 1, + status: "active", + }, + created: true, + colleague_slug: "policy-analyst", + })), + }; + render( + , + ); + fireEvent.click(await screen.findByText("Talent market")); + expect(await screen.findByTestId("org-talent-card-3")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("org-talent-recruit-3")); + await waitFor(() => { + expect(talent.recruit).toHaveBeenCalledWith(3, 1); + }); + }); }); diff --git a/dashboard/src/org-ui/pages/employees/EmployeesPage.tsx b/dashboard/src/org-ui/pages/employees/EmployeesPage.tsx index 06130a8e..373e8de4 100644 --- a/dashboard/src/org-ui/pages/employees/EmployeesPage.tsx +++ b/dashboard/src/org-ui/pages/employees/EmployeesPage.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Button, Input, Modal, Select, Tag, Typography } from "antd"; +import { Button, Input, Modal, Select, Tabs, Tag, Typography } from "antd"; import { Plus, Search, Users } from "lucide-react"; import type { OrgDepartment, OrgEmployee, OrgEmployeesClient, + OrgTalent, + OrgTalentClient, } from "../../api/createClient"; import type { OrgLocale, OrgSession } from "../../shell"; import { employeeLabels } from "./labels"; @@ -12,6 +14,7 @@ import styles from "./EmployeesPage.module.css"; export interface EmployeesPageProps { client: OrgEmployeesClient; + talent?: OrgTalentClient; session: OrgSession; locale: OrgLocale; onOpenEmployee?: (id: number) => void; @@ -41,6 +44,7 @@ const EMPTY_FORM: EmpForm = { export function EmployeesPage({ client, + talent, session, locale, onOpenEmployee, @@ -55,6 +59,12 @@ export function EmployeesPage({ const [form, setForm] = useState(EMPTY_FORM); const [error, setError] = useState(""); const [stats, setStats] = useState({ total: 0, ai: 0, human: 0 }); + const [tab, setTab] = useState("directory"); + const [market, setMarket] = useState([]); + const [talentStats, setTalentStats] = useState({ total: 0, ai: 0, human: 0 }); + const [talentLoading, setTalentLoading] = useState(false); + const [talentError, setTalentError] = useState(""); + const [recruiting, setRecruiting] = useState(null); const fetchAll = useCallback(async () => { setLoading(true); @@ -82,10 +92,41 @@ export function EmployeesPage({ } }, [client, filterType]); + const fetchTalent = useCallback(async () => { + if (!talent) { + setMarket([]); + return; + } + setTalentLoading(true); + setTalentError(""); + try { + const [rows, nextStats] = await Promise.all([ + talent.list({ status: "available" }), + talent.stats(), + ]); + setMarket(rows); + setTalentStats({ + total: nextStats.total, + ai: nextStats.ai, + human: nextStats.human, + }); + } catch { + setMarket([]); + setTalentStats({ total: 0, ai: 0, human: 0 }); + setTalentError(labels.talentLoadFailed); + } finally { + setTalentLoading(false); + } + }, [talent, labels.talentLoadFailed]); + useEffect(() => { void fetchAll(); }, [fetchAll]); + useEffect(() => { + if (tab === "talent") void fetchTalent(); + }, [tab, fetchTalent]); + const query = search.trim().toLowerCase(); const visible = useMemo( () => @@ -135,30 +176,23 @@ export function EmployeesPage({ } }; - return ( -
-
-
- -
- - {labels.title} - -

{labels.subtitle}

-
-
- {session.isAdmin ? ( - - ) : null} -
+ const recruit = async (row: OrgTalent) => { + if (!talent) return; + setRecruiting(row.id); + setTalentError(""); + try { + const landed = await talent.recruit(row.id, departments[0]?.id ?? null); + await Promise.all([fetchTalent(), fetchAll()]); + if (landed.employee?.id) onOpenEmployee?.(landed.employee.id); + } catch { + setTalentError(labels.talentRecruitFailed); + } finally { + setRecruiting(null); + } + }; + const directoryPane = ( + <>
{stats.total}
@@ -250,6 +284,139 @@ export function EmployeesPage({ ))}
)} + + ); + + const talentPane = ( +
+

{labels.talentSubtitle}

+
+
+
{talentStats.total}
+
{labels.total}
+
+
+
{talentStats.ai}
+
{labels.typeAi}
+
+
+
{talentStats.human}
+
{labels.typeHuman}
+
+
+ {talentError ? ( +

+ {talentError} +

+ ) : null} + {talentLoading ? ( +

{labels.loading}

+ ) : market.length === 0 ? ( +
+

{labels.talentEmpty}

+
+ ) : ( +
+ {market.map((row) => ( +
+
+ + {row.avatar_emoji || "👤"} + +
+

+ {row.name}{" "} + + {row.talent_type === "ai" + ? labels.typeAi + : labels.typeHuman} + +

+

+ {row.category || row.agent_type || labels.noRole} + {row.source ? ` · ${row.source}` : ""} +

+
+
+ {row.skills ? ( +
+ {row.skills + .split(",") + .map((skill) => skill.trim()) + .filter(Boolean) + .slice(0, 4) + .map((skill) => ( + {skill} + ))} +
+ ) : null} + {session.isAdmin ? ( + + ) : null} +
+ ))} +
+ )} +
+ ); + + return ( +
+
+
+ +
+ + {labels.title} + +

{labels.subtitle}

+
+
+ {session.isAdmin && tab === "directory" ? ( + + ) : null} +
+ + {talent ? ( + + ) : ( + directoryPane + )} { screen.getByTestId("org-workspace-chat-boundary"), ).toBeInTheDocument(); expect(screen.getByTestId("org-workspace-metrics")).toHaveTextContent("2"); + expect(screen.getByTestId("org-workspace-pauses")).toBeInTheDocument(); + expect(screen.getByTestId("org-workspace-open-pauses")).toHaveAttribute( + "href", + "/organization/governance", + ); expect( screen.getByTestId("org-workspace-open-announcements"), ).toHaveAttribute("href", "/organization/announcements"); diff --git a/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx b/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx index 8eb52b5c..022cd97f 100644 --- a/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx +++ b/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx @@ -195,6 +195,12 @@ export function WorkspacePage({ const lastSync = overview?.last_sync || labels.neverSynced; const tenant = overview?.openxyos.tenant_id || "—"; + const pendingPauses = + overview?.governance?.pending_pauses ?? + overview?.freeos.pending_pauses ?? + 0; + const talentCount = + overview?.freeos.talent_available ?? overview?.org_surfaces?.talent; return (
@@ -208,6 +214,7 @@ export function WorkspacePage({

{labels.subtitle}

{labels.hostHint}

+

{labels.roomHint}

+ {pendingPauses > 0 ? ( +
+

+ {labels.pauseBanner.replace("{count}", String(pendingPauses))} +

+
+ +
+
+ ) : null} + {error ? (

{error} @@ -270,6 +293,14 @@ export function WorkspacePage({ {labels.modules} +

+ {metric(talentCount)} + {labels.talent} +
+
+ {metric(pendingPauses)} + {labels.pauses} +
diff --git a/dashboard/src/org-ui/pages/workspace/labels.ts b/dashboard/src/org-ui/pages/workspace/labels.ts index 505f1719..2d6a6b5b 100644 --- a/dashboard/src/org-ui/pages/workspace/labels.ts +++ b/dashboard/src/org-ui/pages/workspace/labels.ts @@ -22,6 +22,12 @@ const EN = { spawned: "Spawned agents", skills: "Org skills", modules: "Catalog modules", + talent: "Talent", + pauses: "Pending pauses", + pauseBanner: "{count} governance pause(s) waiting.", + pauseBannerAction: "Review pauses", + roomHint: + "This overview is the organization room. Everyday studio chat stays a separate space.", modulesTitle: "Open pages", modulesHint: "Same host pages Dashboard mounts under Organization. Chat is intentionally absent.", @@ -33,7 +39,7 @@ const EN = { organization: "Organization chart", organizationBody: "Departments and people on the host org tree.", employees: "Employees", - employeesBody: "Directory of people and AI colleagues.", + employeesBody: "Directory of people and AI colleagues, plus the host talent market.", skillsPage: "Skills", skillsBody: "Generated org-API skill wrappers. Agent Skills stay on FreeOS.", agents: "Agent Studio", @@ -70,6 +76,11 @@ const ZH: typeof EN = { spawned: "已注册智能体", skills: "组织技能", modules: "目录模块", + talent: "人才", + pauses: "待处理暂停", + pauseBanner: "有 {count} 条治理暂停待处理。", + pauseBannerAction: "去处理", + roomHint: "本页是组织房间总览。日常工作室对话仍是另一套空间。", modulesTitle: "已迁页面", modulesHint: "与 Dashboard Organization 下同一套宿主页。对话故意不在此列。", loading: "正在加载组织总览…", @@ -80,7 +91,7 @@ const ZH: typeof EN = { organization: "组织架构", organizationBody: "宿主组织树上的部门与人员。", employees: "员工目录", - employeesBody: "人机员工名册。", + employeesBody: "人机员工名册,以及宿主人才市场。", skillsPage: "技能", skillsBody: "组织 API 的生成包装。Agent Skills 仍在 FreeOS。", agents: "智能体定制", diff --git a/dashboard/src/pages/Organization/OrganizationEntry.test.tsx b/dashboard/src/pages/Organization/OrganizationEntry.test.tsx index eeac8f11..69446fbc 100644 --- a/dashboard/src/pages/Organization/OrganizationEntry.test.tsx +++ b/dashboard/src/pages/Organization/OrganizationEntry.test.tsx @@ -1,49 +1,101 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import OrganizationEntry from "./OrganizationEntry"; +import { orgModuleApi, type OrgOverview } from "../../api/modules/orgModule"; -const identityStatus = vi.fn(); +const overview: OrgOverview = { + enabled: true, + runtime: "in_host", + sidecar_optional: true, + sidecar_reachable: false, + sidecar_embed_ok: false, + sidecar_url: "http://127.0.0.1:3780", + start_available: false, + install_ready: false, + start_command: "", + home: "/tmp", + last_sync: null, + freeos: { + employees: 0, + employee_states: {}, + agents: 0, + spawned_colleagues: 0, + org_skills: 0, + skill_packages: 0, + mcp: 0, + tasks: 0, + }, + openxyos: { + reachable: false, + url: "http://127.0.0.1:3780", + detail: "", + modules: 12, + governance: true, + tenant_id: "default", + approvals: 0, + }, + last_loop: null, + notes: [], + catalog: [], +}; vi.mock("../../api/modules/orgModule", () => ({ orgModuleApi: { - identityStatus: (...args: unknown[]) => identityStatus(...args), + identityStatus: vi.fn(async () => ({ + integrated: true, + authority: "organization", + })), + overview: vi.fn(async () => overview), + setEnabled: vi.fn(), + startSidecar: vi.fn(), + restartSidecar: vi.fn(), + probeLivez: vi.fn(), + assemble: vi.fn(), + produce: vi.fn(), + pack: vi.fn(), + runLoop: vi.fn(), + setModules: vi.fn(), + downloadSource: vi.fn(), }, })); -vi.mock("./index", () => ({ - default: () =>
workbench
, +vi.mock("../../hooks/useServerTimezone", () => ({ + useServerTimezone: () => "UTC", })); -describe("OrganizationEntry dual doors", () => { - beforeEach(() => { - identityStatus.mockReset(); - }); +vi.mock("../../utils/desktopFolder", () => ({ + pickDesktopFolder: vi.fn(), + canPickDesktopFolder: () => false, +})); - it("keeps the in-host workbench when the room door is closed", async () => { - identityStatus.mockResolvedValue({ - integrated: false, - authority: "studio", - studio: "freeos", - room: null, - }); - render(); - expect(await screen.findByTestId("org-workbench")).toBeInTheDocument(); - }); +vi.mock("../../utils/antdMessage", () => ({ + message: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); - it("opens the organization room behind a soft door hint", async () => { - identityStatus.mockResolvedValue({ +describe("OrganizationEntry", () => { + beforeEach(() => { + vi.mocked(orgModuleApi.overview).mockResolvedValue(overview); + vi.mocked(orgModuleApi.identityStatus).mockResolvedValue({ integrated: true, - authority: "dual", - studio: "freeos", - room: "organization", - }); - render(); - expect(await screen.findByTestId("org-room-door-hint")).toBeInTheDocument(); - await waitFor(() => { - expect(document.querySelector("iframe")).toHaveAttribute( - "src", - "/organization-app/dashboard?freeos_embed=1", - ); + authority: "organization", }); }); + + it("keeps the native workbench as Organization home when desktop is integrated", async () => { + render( + + + } /> + + , + ); + expect( + await screen.findByTestId("org-native-workbench"), + ).toBeInTheDocument(); + expect( + await screen.findByTestId("org-original-app-hint"), + ).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + }); }); diff --git a/dashboard/src/pages/Organization/OrganizationEntry.tsx b/dashboard/src/pages/Organization/OrganizationEntry.tsx index e0b35b63..c8148b2f 100644 --- a/dashboard/src/pages/Organization/OrganizationEntry.tsx +++ b/dashboard/src/pages/Organization/OrganizationEntry.tsx @@ -1,76 +1,9 @@ -import { useEffect, useState } from "react"; -import { Spin } from "antd"; -import { useTranslation } from "react-i18next"; -import { orgModuleApi } from "../../api/modules/orgModule"; import OrganizationPage from "./index"; /** - * Studio nav stays in the dashboard shell. The integrated room is another - * door in the same house — it keeps its own guestbook. + * Organization home is the native workbench. Desktop managed Node + iframe + * remains a transitional preview for unmigrated App verticals, not this route. */ export default function OrganizationEntry() { - const { t } = useTranslation(); - const [integrated, setIntegrated] = useState(null); - - useEffect(() => { - let active = true; - void orgModuleApi - .identityStatus() - .then((status) => { - if (!active) return; - setIntegrated(status.integrated); - }) - .catch(() => { - if (active) setIntegrated(false); - }); - return () => { - active = false; - }; - }, []); - - if (integrated === false) return ; - if (integrated === true) { - return ( -
-

- {t("organization.roomDoorHint")} -

-