From 16193db5752b536cab5492fe391b5241bd958003 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 12:06:28 +0000 Subject: [PATCH 1/2] feat(org): host-native organization wave 2 Deepen announcements readers, task attachments, org-chart reporting lines/import, catalog delivery UX, and inline governance approve on the workbench and workspace without expanding Node. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + dashboard/src/api/modules/orgModule.ts | 22 ++ dashboard/src/locales/en.json | 6 + dashboard/src/locales/zh.json | 6 + dashboard/src/org-ui/api/createClient.test.ts | 60 +++++- dashboard/src/org-ui/api/createClient.ts | 109 ++++++++++ dashboard/src/org-ui/index.ts | 2 + .../announcements/AnnouncementPage.test.tsx | 75 +++++-- .../pages/announcements/AnnouncementPage.tsx | 31 ++- .../src/org-ui/pages/announcements/labels.ts | 4 + .../org-ui/pages/org/OrgChartPage.test.tsx | 39 +++- .../src/org-ui/pages/org/OrgChartPage.tsx | 195 +++++++++++++++++- dashboard/src/org-ui/pages/org/labels.ts | 31 +++ .../pages/settings/SettingsPage.module.css | 14 ++ .../pages/settings/SettingsPage.test.tsx | 10 + .../org-ui/pages/settings/SettingsPage.tsx | 11 + dashboard/src/org-ui/pages/settings/labels.ts | 7 + .../pages/tasks/TaskDetailPage.test.tsx | 39 ++++ .../src/org-ui/pages/tasks/TaskDetailPage.tsx | 81 ++++++++ dashboard/src/org-ui/pages/tasks/labels.ts | 8 + .../pages/workspace/WorkspacePage.module.css | 20 ++ .../pages/workspace/WorkspacePage.test.tsx | 35 +++- .../org-ui/pages/workspace/WorkspacePage.tsx | 63 +++++- .../src/org-ui/pages/workspace/labels.ts | 12 ++ .../Organization/Organization.module.less | 21 ++ .../src/pages/Organization/index.test.tsx | 25 +++ dashboard/src/pages/Organization/index.tsx | 65 ++++++ .../src/pages/Organization/tasks/detail.tsx | 4 +- docs/org-capability-migration-map.json | 16 +- docs/org-capability-migration-map.md | 34 +-- docs/org-capability-migration-map.zh-CN.md | 34 +-- src/octop/api/routers/org_announcements.py | 35 +++- src/octop/api/routers/org_chart.py | 42 +++- src/octop/api/routers/org_settings.py | 4 +- src/octop/api/routers/org_tasks.py | 124 ++++++++++- src/octop/i18n/en.json | 7 +- src/octop/i18n/zh.json | 7 +- .../modules/org_os/announcements/store.py | 65 +++++- src/octop/modules/org_os/notes.py | 7 + src/octop/modules/org_os/org_chart/store.py | 151 +++++++++++++- src/octop/modules/org_os/overview.py | 10 + src/octop/modules/org_os/tasks/store.py | 160 ++++++++++++++ tests/unit/test_org_announcements.py | 10 + tests/unit/test_org_chart.py | 57 +++++ tests/unit/test_org_governance.py | 21 ++ tests/unit/test_org_module.py | 3 + tests/unit/test_org_notes.py | 2 + tests/unit/test_org_settings.py | 5 + tests/unit/test_org_tasks.py | 48 +++++ 49 files changed, 1736 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7050fa1b..f5389196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### 新增 +- Wave 2(宿主原生组织深度):公告已读名单、任务附件(`{FREEOS_HOME}/org/task-files`)、架构图汇报线/部门类型/JSON 导入、工作台与总览内联批准宿主治理暂停、目录启用展示 `delivery`/`host_path`。不扩展永久 Node,不删除 `ManagedOrganizationRuntime`。双身份工作室 vs 组织房间不变。迁移图计数不变(9 native / 11 slice / 12 iframe)。 - 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`。 diff --git a/dashboard/src/api/modules/orgModule.ts b/dashboard/src/api/modules/orgModule.ts index 3d2465e9..9102e56b 100644 --- a/dashboard/src/api/modules/orgModule.ts +++ b/dashboard/src/api/modules/orgModule.ts @@ -105,9 +105,20 @@ export interface OrgOverview { pending_pauses?: number; enabled?: boolean; href?: string; + pauses?: OrgGovernancePause[]; }; } +export interface OrgGovernancePause { + pause_id: string; + tool_name: string; + category?: string; + action?: string; + reason?: string; + status?: string; + created_at?: number; +} + export interface OrgSidecarStart { started: boolean; already: boolean; @@ -233,4 +244,15 @@ export const orgModuleApi = { body: JSON.stringify({ dest }), }, ), + resolvePause: (pauseId: string, approve: boolean) => + request<{ + status: string; + execute: boolean; + blocked: boolean; + reason: string; + }>("/org-module/governance/resolve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pause_id: pauseId, approve }), + }), }; diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 8d9e9d1a..db2e6faf 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -179,6 +179,12 @@ "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", + "pauseApprove": "Approve", + "pauseReject": "Reject", + "pauseTool": "Tool", + "pauseReason": "Reason", + "pauseResolveFailed": "Could not resolve this pause", + "pauseResolved": "Pause updated", "originalAppHint": "Unmigrated App verticals (workflows, contracts, …) still use the original preview — a transition, not the home.", "originalAppAction": "Open original App (transition)", "catalogNative": "Host page", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 9c0d870c..3c05196f 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -179,6 +179,12 @@ "roomHint": "这里是组织房间:可以单独布置,和日常工作室同属一个 FreeOS,两套入口互不合并。", "pauseBanner": "有 {{count}} 条治理暂停等待人工决定。", "pauseBannerAction": "打开治理", + "pauseApprove": "批准", + "pauseReject": "驳回", + "pauseTool": "工具", + "pauseReason": "原因", + "pauseResolveFailed": "无法处理该暂停", + "pauseResolved": "暂停已更新", "originalAppHint": "尚未迁入的 App 垂类(流程、合同等)仍走原预览——只是过渡,不是首页。", "originalAppAction": "打开原 App(过渡)", "catalogNative": "宿主页", diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index cb2ff8d4..3f8d6398 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -15,7 +15,33 @@ describe("createOrgApiClient", () => { }, }; } - if (path === "/org-module/announcements/action/unread") { + if (path === "/org-module/announcements/1/readers") { + return { + success: true, + data: { + readers: [ + { user_id: 1, user_name: "Ada", read_at: "2026-09-19T00:00:00Z" }, + ], + count: 1, + }, + }; + } + if (path === "/org-module/org/import") { + return { + success: true, + data: { + departments: { created: 1, updated: 0, skipped: 0 }, + reporting_lines: { applied: 0, skipped: 0 }, + tree: [], + }, + }; + } + if (path === "/org-module/tasks/3/attachments") { + return { + success: true, + data: [{ id: 11, filename: "notes.txt", size_bytes: 12 }], + }; + } return { success: true, data: { count: 2 } }; } if (path === "/org-module/org/tree") { @@ -144,13 +170,23 @@ describe("createOrgApiClient", () => { expect(listed.total).toBe(1); expect(listed.list[0]?.title).toBe("Hi"); expect(await client.announcements.unread()).toEqual({ count: 2 }); + expect((await client.announcements.readers(1)).readers[0]?.user_name).toBe( + "Ada", + ); expect((await client.org.tree())[0]?.name).toBe("HQ"); + expect( + (await client.org.importChart({ departments: [{ name: "HQ" }] })) + .departments.created, + ).toBe(1); 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"); expect((await client.tasks.list())[0]?.title).toBe("Ship Tasks slice"); + expect((await client.tasks.listAttachments(3))[0]?.filename).toBe( + "notes.txt", + ); expect((await client.reflections.list())[0]?.failure_reasons).toBe( "const dead zone", ); @@ -175,6 +211,28 @@ describe("createOrgApiClient", () => { expect(fetchJson).toHaveBeenCalled(); }); + it("uploads and downloads task attachments without JSON Content-Type", async () => { + const uploadForm = vi.fn(async () => ({ + success: true, + data: { id: 12, filename: "brief.txt", size_bytes: 4 }, + })); + const fetchBlob = vi.fn(async () => new Blob(["ok"])); + const client = createOrgApiClient({ + fetchJson: async () => ({ success: true, data: {} }), + uploadForm, + fetchBlob, + }); + const file = new File(["ok"], "brief.txt", { type: "text/plain" }); + expect((await client.tasks.uploadAttachment(3, file)).filename).toBe( + "brief.txt", + ); + expect(uploadForm).toHaveBeenCalled(); + expect(await client.tasks.downloadAttachment(3, 12)).toBeInstanceOf(Blob); + expect(fetchBlob).toHaveBeenCalledWith( + "/org-module/tasks/3/attachments/12/file", + ); + }); + it("throws the envelope error", async () => { const client = createOrgApiClient({ fetchJson: async () => ({ success: false, error: "Permission denied" }), diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index c9b36dd8..cb4a424e 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -43,6 +43,12 @@ export interface OrgEnvelope { error?: string; } +export interface AnnouncementReader { + user_id: number; + user_name: string; + read_at: string; +} + export interface OrgAnnouncementsClient { list(params: { page: number; @@ -51,6 +57,7 @@ export interface OrgAnnouncementsClient { search?: string; }): Promise; get(id: number): Promise; + readers(id: number): Promise<{ readers: AnnouncementReader[]; count: number }>; unread(): Promise<{ count: number }>; markRead(id: number): Promise; markAllRead(): Promise<{ marked: number }>; @@ -74,6 +81,8 @@ export interface OrgEmployee { department_name?: string | null; status: string; is_online?: boolean; + reports_to?: number | null; + reports_to_name?: string | null; created_at?: string; updated_at?: string | null; } @@ -111,6 +120,7 @@ export interface EmployeeWrite { skills?: string; avatar_emoji?: string; status?: string; + reports_to?: number | null; } export interface OrgChartClient { @@ -122,6 +132,14 @@ export interface OrgChartClient { createEmployee(body: EmployeeWrite): Promise; updateEmployee(id: number, body: EmployeeWrite): Promise; removeEmployee(id: number): Promise; + importChart(body: { + departments?: Array>; + reporting_lines?: Array>; + }): Promise<{ + departments: { created: number; updated: number; skipped: number }; + reporting_lines: { applied: number; skipped: number }; + tree: OrgDepartment[]; + }>; } export interface EmployeeListParams { @@ -428,8 +446,10 @@ export interface OrgTask { subtask_count?: number; subtask_done?: number; comment_count?: number; + attachment_count?: number; subtasks?: OrgTaskSubtask[]; comments?: OrgTaskComment[]; + attachments?: OrgTaskAttachment[]; } export interface OrgTaskSubtask { @@ -450,6 +470,18 @@ export interface OrgTaskComment { created_at: string; } +export interface OrgTaskAttachment { + id: number; + task_id: number; + filename: string; + stored_name?: string; + media_type?: string; + size_bytes: number; + uploaded_by?: number | null; + uploader_name?: string; + created_at: string; +} + export interface OrgTaskStats { total: number; todo: number; @@ -488,6 +520,10 @@ export interface OrgTasksClient { ): Promise; removeSubtask(id: number, subtaskId: number): Promise; addComment(id: number, content: string): Promise; + listAttachments(id: number): Promise; + uploadAttachment(id: number, file: File): Promise; + downloadAttachment(id: number, attachmentId: number): Promise; + removeAttachment(id: number, attachmentId: number): Promise; } export type OrgReflectionType = @@ -728,11 +764,14 @@ export interface OrgWorkspaceOverview { pending_pauses?: number; enabled?: boolean; href?: string; + pauses?: GovernancePause[]; }; } export interface OrgWorkspaceClient { overview(): Promise; + pauses(status?: string): Promise; + resolve(pauseId: string, approve: boolean): Promise; } export interface OrgApiClient { @@ -763,6 +802,8 @@ async function unwrap(payload: OrgEnvelope): Promise { */ export function createOrgApiClient(opts: { fetchJson: OrgFetcher; + uploadForm?: (path: string, body: FormData) => Promise; + fetchBlob?: (path: string) => Promise; announcementsPrefix?: string; orgPrefix?: string; governancePrefix?: string; @@ -807,6 +848,13 @@ export function createOrgApiClient(opts: { await fetchJson>(`${prefix}/${id}`), ); }, + async readers(id) { + return unwrap( + await fetchJson< + OrgEnvelope<{ readers: AnnouncementReader[]; count: number }> + >(`${prefix}/${id}/readers`), + ); + }, async unread() { return unwrap( await fetchJson>( @@ -942,6 +990,25 @@ export function createOrgApiClient(opts: { ), ); }, + async importChart(body) { + return unwrap( + await fetchJson< + OrgEnvelope<{ + departments: { + created: number; + updated: number; + skipped: number; + }; + reporting_lines: { applied: number; skipped: number }; + tree: OrgDepartment[]; + }> + >(`${orgPrefix}/import`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + }, }; const employees: OrgEmployeesClient = { @@ -1219,6 +1286,42 @@ export function createOrgApiClient(opts: { ), ); }, + async listAttachments(id) { + return unwrap( + await fetchJson>( + `${tasksPrefix}/${id}/attachments`, + ), + ); + }, + async uploadAttachment(id, file) { + if (!opts.uploadForm) { + throw new Error("upload is not available"); + } + const body = new FormData(); + body.append("upload", file); + return unwrap( + await opts.uploadForm>( + `${tasksPrefix}/${id}/attachments`, + body, + ), + ); + }, + async downloadAttachment(id, attachmentId) { + if (!opts.fetchBlob) { + throw new Error("download is not available"); + } + return opts.fetchBlob( + `${tasksPrefix}/${id}/attachments/${attachmentId}/file`, + ); + }, + async removeAttachment(id, attachmentId) { + await unwrap( + await fetchJson>( + `${tasksPrefix}/${id}/attachments/${attachmentId}`, + { method: "DELETE" }, + ), + ); + }, }; const reflections: OrgReflectionsClient = { @@ -1368,6 +1471,12 @@ export function createOrgApiClient(opts: { } throw new Error(raw?.error || "request failed"); }, + async pauses(status) { + return governance.pauses(status); + }, + async resolve(pauseId, approve) { + return governance.resolve(pauseId, approve); + }, }; return { diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index 820566c9..519a2acf 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -4,6 +4,7 @@ export { createOrgApiClient } from "./api/createClient"; export type { Announcement, AnnouncementListData, + AnnouncementReader, AnnouncementWrite, DepartmentWrite, EmployeeListParams, @@ -39,6 +40,7 @@ export type { OrgKnowledgeStats, OrgKnowledgeWrite, OrgTask, + OrgTaskAttachment, OrgTaskComment, OrgTaskListParams, OrgTaskPriority, diff --git a/dashboard/src/org-ui/pages/announcements/AnnouncementPage.test.tsx b/dashboard/src/org-ui/pages/announcements/AnnouncementPage.test.tsx index 3ba49ae8..25a7a8b5 100644 --- a/dashboard/src/org-ui/pages/announcements/AnnouncementPage.test.tsx +++ b/dashboard/src/org-ui/pages/announcements/AnnouncementPage.test.tsx @@ -7,6 +7,7 @@ function mockClient(): OrgAnnouncementsClient { return { list: vi.fn(async () => ({ list: [], total: 0, page: 1, limit: 10 })), get: vi.fn(), + readers: vi.fn(async () => ({ readers: [], count: 0 })), unread: vi.fn(async () => ({ count: 0 })), markRead: vi.fn(), markAllRead: vi.fn(async () => ({ marked: 0 })), @@ -27,20 +28,18 @@ function mockClient(): OrgAnnouncementsClient { }; } +const admin = { + userId: 1, + displayName: "Ada", + role: "admin", + isAdmin: true, +}; + describe("AnnouncementPage", () => { it("renders the shared page without an iframe", async () => { const client = mockClient(); render( - , + , ); expect( await screen.findByTestId("org-ui-announcements"), @@ -54,16 +53,7 @@ describe("AnnouncementPage", () => { it("lets an admin publish through the injected client", async () => { const client = mockClient(); render( - , + , ); fireEvent.click(await screen.findByTestId("org-announcements-publish")); fireEvent.change(screen.getByTestId("org-announcement-title"), { @@ -79,4 +69,49 @@ describe("AnnouncementPage", () => { ); }); }); + + it("shows the reader log when opening an announcement", async () => { + const client = mockClient(); + vi.mocked(client.list).mockResolvedValue({ + list: [ + { + id: 4, + title: "Hello", + content: "World", + type: "notice", + priority: "normal", + is_pinned: 0, + is_read: false, + published_at: "2026-09-19T00:00:00Z", + expires_at: null, + }, + ], + total: 1, + page: 1, + limit: 10, + }); + vi.mocked(client.get).mockResolvedValue({ + id: 4, + title: "Hello", + content: "World", + type: "notice", + priority: "normal", + is_pinned: 0, + is_read: true, + published_at: "2026-09-19T00:00:00Z", + expires_at: null, + }); + vi.mocked(client.readers).mockResolvedValue({ + readers: [ + { user_id: 1, user_name: "Ada", read_at: "2026-09-19T00:00:00Z" }, + ], + count: 1, + }); + render(); + fireEvent.click(await screen.findByTestId("org-announcement-4")); + expect(await screen.findByTestId("org-announcement-readers")).toHaveTextContent( + "Ada", + ); + expect(client.readers).toHaveBeenCalledWith(4); + }); }); diff --git a/dashboard/src/org-ui/pages/announcements/AnnouncementPage.tsx b/dashboard/src/org-ui/pages/announcements/AnnouncementPage.tsx index fcc3ae69..d9d80fdb 100644 --- a/dashboard/src/org-ui/pages/announcements/AnnouncementPage.tsx +++ b/dashboard/src/org-ui/pages/announcements/AnnouncementPage.tsx @@ -66,6 +66,9 @@ export function AnnouncementPage({ const [loading, setLoading] = useState(true); const [unreadCount, setUnreadCount] = useState(0); const [detail, setDetail] = useState(null); + const [readers, setReaders] = useState< + Array<{ user_id: number; user_name: string; read_at: string }> + >([]); const [showForm, setShowForm] = useState(false); const [editId, setEditId] = useState(null); const [form, setForm] = useState({ @@ -115,6 +118,12 @@ export function AnnouncementPage({ try { const next = await client.get(row.id); setDetail(next); + try { + const log = await client.readers(row.id); + setReaders(log.readers); + } catch { + setReaders([]); + } setItems((prev) => prev.map((item) => item.id === row.id ? { ...item, is_read: true } : item, @@ -363,7 +372,10 @@ export function AnnouncementPage({ open={Boolean(detail)} title={detail?.title} footer={null} - onCancel={() => setDetail(null)} + onCancel={() => { + setDetail(null); + setReaders([]); + }} width={720} > {detail ? ( @@ -385,6 +397,23 @@ export function AnnouncementPage({ ) : null}
{detail.content}
+
+ + {labels.readers} ({readers.length}) + + {readers.length === 0 ? ( +

{labels.noReaders}

+ ) : ( +
    + {readers.map((reader) => ( +
  • + {reader.user_name || `#${reader.user_id}`} ·{" "} + {format(reader.read_at)} +
  • + ))} +
+ )} +
) : null} diff --git a/dashboard/src/org-ui/pages/announcements/labels.ts b/dashboard/src/org-ui/pages/announcements/labels.ts index 52a9029d..d251f3eb 100644 --- a/dashboard/src/org-ui/pages/announcements/labels.ts +++ b/dashboard/src/org-ui/pages/announcements/labels.ts @@ -36,6 +36,8 @@ const EN = { confirmDelete: "Delete this announcement?", editShort: "Edit", deleteShort: "Delete", + readers: "Readers", + noReaders: "Nobody has opened this yet.", }; const ZH: typeof EN = { @@ -74,6 +76,8 @@ const ZH: typeof EN = { confirmDelete: "确定删除该公告?", editShort: "编辑", deleteShort: "删除", + readers: "已读名单", + noReaders: "还没有人打开过这条公告。", }; export type AnnouncementLabels = typeof EN; diff --git a/dashboard/src/org-ui/pages/org/OrgChartPage.test.tsx b/dashboard/src/org-ui/pages/org/OrgChartPage.test.tsx index 44b3af42..3b4d8022 100644 --- a/dashboard/src/org-ui/pages/org/OrgChartPage.test.tsx +++ b/dashboard/src/org-ui/pages/org/OrgChartPage.test.tsx @@ -20,6 +20,11 @@ function mockClient(): OrgChartClient { createEmployee: vi.fn(), updateEmployee: vi.fn(), removeEmployee: vi.fn(), + importChart: vi.fn(async () => ({ + departments: { created: 1, updated: 0, skipped: 0 }, + reporting_lines: { applied: 0, skipped: 0 }, + tree: [], + })), }; } @@ -66,7 +71,39 @@ describe("OrgChartPage", () => { fireEvent.click(screen.getByTestId("org-chart-dept-submit")); await waitFor(() => { expect(client.createDepartment).toHaveBeenCalledWith( - expect.objectContaining({ name: "HQ", parent_id: null }), + expect.objectContaining({ + name: "HQ", + parent_id: null, + function_type: "functional", + }), + ); + }); + }); + + it("imports departments through the injected client", async () => { + const client = mockClient(); + render( + , + ); + fireEvent.click(await screen.findByTestId("org-chart-import")); + fireEvent.change(screen.getByTestId("org-chart-import-departments"), { + target: { value: '[{"name":"HQ"}]' }, + }); + fireEvent.click(screen.getByTestId("org-chart-import-submit")); + await waitFor(() => { + expect(client.importChart).toHaveBeenCalledWith( + expect.objectContaining({ + departments: [{ name: "HQ" }], + }), ); }); }); diff --git a/dashboard/src/org-ui/pages/org/OrgChartPage.tsx b/dashboard/src/org-ui/pages/org/OrgChartPage.tsx index a692cd63..7e36b80d 100644 --- a/dashboard/src/org-ui/pages/org/OrgChartPage.tsx +++ b/dashboard/src/org-ui/pages/org/OrgChartPage.tsx @@ -22,6 +22,7 @@ type DeptForm = { name: string; description: string; parent_id: number | null; + function_type: string; }; type EmpForm = { @@ -31,6 +32,7 @@ type EmpForm = { employee_type: string; avatar_emoji: string; department_id: number; + reports_to: number | null; }; function countEmployees(dept: OrgDepartment): number { @@ -53,6 +55,34 @@ function flattenDepartments(nodes: OrgDepartment[]): OrgDepartment[] { return out; } +function flattenEmployees(nodes: OrgDepartment[]): OrgEmployee[] { + const out: OrgEmployee[] = []; + for (const dept of flattenDepartments(nodes)) { + out.push(...(dept.employees ?? [])); + } + return out; +} + +function functionTypeLabel( + value: string | undefined, + labels: ReturnType, +): string { + switch (value) { + case "regional": + return labels.functionRegional; + case "branch": + return labels.functionBranch; + case "project": + return labels.functionProject; + case "site": + return labels.functionSite; + case "dispatched": + return labels.functionDispatched; + default: + return labels.functionFunctional; + } +} + function matchesQuery(dept: OrgDepartment, query: string): boolean { if (!query) return true; const q = query.toLowerCase(); @@ -99,6 +129,7 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { name: "", description: "", parent_id: null, + function_type: "functional", }); const [empForm, setEmpForm] = useState({ name: "", @@ -107,8 +138,14 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { employee_type: "human", avatar_emoji: "👤", department_id: 0, + reports_to: null, }); const [error, setError] = useState(""); + const [importOpen, setImportOpen] = useState(false); + const [importDepartments, setImportDepartments] = useState( + '[{"name":"HQ"},{"name":"Eng","parent":"HQ"}]', + ); + const [importLines, setImportLines] = useState("[]"); const fetchTree = useCallback(async () => { setLoading(true); @@ -130,6 +167,15 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { [tree], ); const flatDepts = useMemo(() => flattenDepartments(tree), [tree]); + const allPeople = useMemo(() => flattenEmployees(tree), [tree]); + const functionOptions = [ + { value: "functional", label: labels.functionFunctional }, + { value: "regional", label: labels.functionRegional }, + { value: "branch", label: labels.functionBranch }, + { value: "project", label: labels.functionProject }, + { value: "site", label: labels.functionSite }, + { value: "dispatched", label: labels.functionDispatched }, + ]; const query = search.trim(); const filtered = useMemo( () => tree.filter((node) => matchesQuery(node, query)), @@ -138,7 +184,12 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { const openCreateDept = (parentId: number | null) => { setError(""); - setDeptForm({ name: "", description: "", parent_id: parentId }); + setDeptForm({ + name: "", + description: "", + parent_id: parentId, + function_type: "functional", + }); setDeptModal({ mode: "create", parentId }); }; @@ -148,6 +199,7 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { name: dept.name, description: dept.description || "", parent_id: dept.parent_id, + function_type: dept.function_type || "functional", }); setDeptModal({ mode: "edit", dept }); }; @@ -161,6 +213,7 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { employee_type: "human", avatar_emoji: "👤", department_id: departmentId, + reports_to: null, }); setEmpModal({ mode: "create", departmentId }); }; @@ -174,6 +227,7 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { employee_type: emp.employee_type || "human", avatar_emoji: emp.avatar_emoji || "👤", department_id: emp.department_id, + reports_to: emp.reports_to ?? null, }); setEmpModal({ mode: "edit", emp }); setDetail(null); @@ -191,12 +245,14 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { name, description: deptForm.description, parent_id: deptForm.parent_id, + function_type: deptForm.function_type, }); } else { await client.createDepartment({ name, description: deptForm.description, parent_id: deptForm.parent_id, + function_type: deptForm.function_type, }); } setDeptModal(null); @@ -221,6 +277,7 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { employee_type: empForm.employee_type, avatar_emoji: empForm.avatar_emoji, department_id: empForm.department_id, + reports_to: empForm.reports_to, }); } else { await client.createEmployee({ @@ -230,6 +287,7 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { employee_type: empForm.employee_type, avatar_emoji: empForm.avatar_emoji, department_id: empForm.department_id, + reports_to: empForm.reports_to, }); } setEmpModal(null); @@ -264,6 +322,32 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { } }; + const submitImport = async () => { + try { + const departments = JSON.parse(importDepartments || "[]") as unknown; + const reportingLines = JSON.parse(importLines || "[]") as unknown; + if (!Array.isArray(departments) || !Array.isArray(reportingLines)) { + setError(labels.importInvalid); + return; + } + await client.importChart({ + departments: departments as Array>, + reporting_lines: reportingLines as Array>, + }); + setImportOpen(false); + setError(""); + await fetchTree(); + } catch (err) { + setError( + err instanceof SyntaxError + ? labels.importInvalid + : err instanceof Error + ? err.message + : labels.importInvalid, + ); + } + }; + const renderNode = (dept: OrgDepartment) => { if (query && !matchesQuery(dept, query)) return null; const people = dept.employees ?? []; @@ -278,6 +362,7 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { {dept.name} + {functionTypeLabel(dept.function_type, labels)} {countEmployees(dept)} {session.isAdmin ? ( @@ -356,14 +441,25 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { {session.isAdmin ? ( - + + + + ) : null} @@ -407,6 +503,13 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { {detail.employee_type === "ai" ? labels.typeAi : labels.typeHuman} +
+ {labels.fieldReportsTo}:{" "} + {detail.reports_to_name || + allPeople.find((person) => person.id === detail.reports_to) + ?.name || + labels.noManager} +
{detail.description ? (

{detail.description}

) : null} @@ -480,6 +583,20 @@ export function OrgChartPage({ client, session, locale }: OrgChartPageProps) { .map((dept) => ({ value: dept.id, label: dept.name }))} /> +
+ + {labels.fieldFunction} + + + setEmpForm({ ...empForm, reports_to: value ?? null }) + } + options={allPeople + .filter( + (person) => + person.id !== + (empModal?.mode === "edit" ? empModal.emp.id : -1), + ) + .map((person) => ({ value: person.id, label: person.name }))} + data-testid="org-chart-reports-to" + /> +
+ {error ? ( + {error} + ) : null} +
+ + + setImportOpen(false)} + onOk={() => void submitImport()} + okText={labels.importSubmit} + okButtonProps={{ "data-testid": "org-chart-import-submit" }} + > + + {labels.importHint} +
+ + {labels.importDepartments} + + setImportDepartments(event.target.value)} + data-testid="org-chart-import-departments" + /> +
+
+ + {labels.importLines} + + setImportLines(event.target.value)} + data-testid="org-chart-import-lines" + /> +
{error ? ( {error} ) : null} diff --git a/dashboard/src/org-ui/pages/org/labels.ts b/dashboard/src/org-ui/pages/org/labels.ts index e9615fb6..9c96720e 100644 --- a/dashboard/src/org-ui/pages/org/labels.ts +++ b/dashboard/src/org-ui/pages/org/labels.ts @@ -30,6 +30,22 @@ const EN = { required: "Name is required", noMatch: "No people match this search", root: "Root", + fieldFunction: "Department type", + functionFunctional: "Functional", + functionRegional: "Regional", + functionBranch: "Branch", + functionProject: "Project", + functionSite: "Site / lab", + functionDispatched: "Dispatched", + fieldReportsTo: "Reports to", + noManager: "No manager", + importChart: "Import chart", + importHint: + "Paste departments and reporting lines. Names must match people already on this chart.", + importDepartments: "Departments JSON", + importLines: "Reporting lines JSON", + importSubmit: "Import", + importInvalid: "Departments and reporting lines must be JSON arrays.", }; const ZH: typeof EN = { @@ -62,6 +78,21 @@ const ZH: typeof EN = { required: "名称不能为空", noMatch: "没有匹配的人员", root: "根节点", + fieldFunction: "部门类型", + functionFunctional: "职能", + functionRegional: "区域", + functionBranch: "分公司", + functionProject: "项目", + functionSite: "现场 / 实验室", + functionDispatched: "派出", + fieldReportsTo: "汇报对象", + noManager: "无上级", + importChart: "导入架构", + importHint: "粘贴部门与汇报线。汇报线里的姓名需要已经在本图中。", + importDepartments: "部门 JSON", + importLines: "汇报线 JSON", + importSubmit: "导入", + importInvalid: "部门和汇报线必须是 JSON 数组。", }; export type OrgChartLabels = typeof EN; diff --git a/dashboard/src/org-ui/pages/settings/SettingsPage.module.css b/dashboard/src/org-ui/pages/settings/SettingsPage.module.css index 130fb7d3..42494ae9 100644 --- a/dashboard/src/org-ui/pages/settings/SettingsPage.module.css +++ b/dashboard/src/org-ui/pages/settings/SettingsPage.module.css @@ -145,6 +145,20 @@ line-height: 1.4; } +.moduleTags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.modulePath { + font-size: 11px; + color: var(--fn-text-secondary, #667085); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + .empty { text-align: center; padding: 48px 12px; diff --git a/dashboard/src/org-ui/pages/settings/SettingsPage.test.tsx b/dashboard/src/org-ui/pages/settings/SettingsPage.test.tsx index ba080ae5..4ce3b969 100644 --- a/dashboard/src/org-ui/pages/settings/SettingsPage.test.tsx +++ b/dashboard/src/org-ui/pages/settings/SettingsPage.test.tsx @@ -15,6 +15,8 @@ function mockClient(): OrgSettingsClient { description: "Tenant, model, module, and user settings.", description_zh: "租户、模型、模块与用户设置", locked: true, + delivery: "org_ui_slice", + host_path: "/organization/settings", }, { key: "chat", @@ -23,6 +25,8 @@ function mockClient(): OrgSettingsClient { description: "Human-agent chat.", description_zh: "人机协作", locked: false, + delivery: "managed_node_iframe", + host_path: "", }, ], modules: { settings: true, chat: true }, @@ -65,6 +69,12 @@ describe("SettingsPage", () => { ); expect(screen.getByTestId("org-settings-name")).toHaveValue("Acme"); expect(screen.getByText("Always on")).toBeInTheDocument(); + expect(screen.getByTestId("org-settings-delivery-settings")).toHaveTextContent( + "Host page", + ); + expect(screen.getByTestId("org-settings-delivery-chat")).toHaveTextContent( + "Original App", + ); expect(document.querySelector("iframe")).toBeNull(); expect(screen.queryByText("API Key")).toBeNull(); expect(client.snapshot).toHaveBeenCalled(); diff --git a/dashboard/src/org-ui/pages/settings/SettingsPage.tsx b/dashboard/src/org-ui/pages/settings/SettingsPage.tsx index 6b0e085e..d0504844 100644 --- a/dashboard/src/org-ui/pages/settings/SettingsPage.tsx +++ b/dashboard/src/org-ui/pages/settings/SettingsPage.tsx @@ -216,9 +216,12 @@ export function SettingsPage({

{labels.modulesTitle}

{labels.modulesHint}

+

{labels.deliveryHint}

{catalog.map((row) => { const on = toggles[row.key] !== false; + const hostPage = + row.delivery !== "managed_node_iframe" && Boolean(row.host_path); return (
{capLabel(row)}
{capDesc(row)}
+
+ + {hostPage ? labels.deliveryHost : labels.deliveryIframe} + + {row.host_path ? ( + {row.host_path} + ) : null} +
{row.locked ? ( {labels.locked} diff --git a/dashboard/src/org-ui/pages/settings/labels.ts b/dashboard/src/org-ui/pages/settings/labels.ts index 36485549..86949e34 100644 --- a/dashboard/src/org-ui/pages/settings/labels.ts +++ b/dashboard/src/org-ui/pages/settings/labels.ts @@ -33,6 +33,10 @@ const EN = { loadFailed: "Could not load organization settings", saveFailed: "Could not save", readOnly: "Only administrators can change organization settings.", + deliveryHost: "Host page", + deliveryIframe: "Original App", + deliveryHint: + "Host pages stay in this room. Original App still needs the transition iframe.", }; const ZH: typeof EN = { @@ -64,6 +68,9 @@ const ZH: typeof EN = { loadFailed: "无法加载组织设置", saveFailed: "保存失败", readOnly: "只有管理员可以更改组织设置。", + deliveryHost: "宿主页", + deliveryIframe: "原 App", + deliveryHint: "宿主页留在这间房间。原 App 仍走过渡 iframe。", }; export type SettingsLabels = typeof EN; diff --git a/dashboard/src/org-ui/pages/tasks/TaskDetailPage.test.tsx b/dashboard/src/org-ui/pages/tasks/TaskDetailPage.test.tsx index 8a3b5a75..79345cc3 100644 --- a/dashboard/src/org-ui/pages/tasks/TaskDetailPage.test.tsx +++ b/dashboard/src/org-ui/pages/tasks/TaskDetailPage.test.tsx @@ -19,6 +19,15 @@ function mockClient(): OrgTasksClient { { id: 1, task_id: 3, title: "Draft", completed: 0, sort_order: 1 }, ], comments: [], + attachments: [ + { + id: 11, + task_id: 3, + filename: "notes.txt", + size_bytes: 12, + created_at: "2026-09-19T00:00:00Z", + }, + ], })), create: vi.fn(), update: vi.fn(async (_id, body) => ({ @@ -55,6 +64,16 @@ function mockClient(): OrgTasksClient { comment_type: "user", created_at: "2026-09-19T00:00:00Z", })), + listAttachments: vi.fn(async () => []), + uploadAttachment: vi.fn(async () => ({ + id: 12, + task_id: 3, + filename: "brief.txt", + size_bytes: 4, + created_at: "2026-09-19T00:00:00Z", + })), + downloadAttachment: vi.fn(async () => new Blob(["ok"])), + removeAttachment: vi.fn(), }; } @@ -86,6 +105,8 @@ describe("TaskDetailPage", () => { ).toBeInTheDocument(); expect(document.querySelector("iframe")).toBeNull(); expect(client.get).toHaveBeenCalledWith(3); + expect(screen.getByTestId("org-task-attachment-input")).toBeInTheDocument(); + expect(screen.getByText("notes.txt (12 B)")).toBeInTheDocument(); }); it("saves edits and adds a comment through the injected client", async () => { @@ -117,4 +138,22 @@ describe("TaskDetailPage", () => { expect(client.addComment).toHaveBeenCalledWith(3, "Looks good"); }); }); + + it("uploads an attachment through the injected client", async () => { + const client = mockClient(); + render( + , + ); + const input = await screen.findByTestId("org-task-attachment-input"); + const file = new File(["ok"], "brief.txt", { type: "text/plain" }); + fireEvent.change(input, { target: { files: [file] } }); + await waitFor(() => { + expect(client.uploadAttachment).toHaveBeenCalledWith(3, file); + }); + }); }); diff --git a/dashboard/src/org-ui/pages/tasks/TaskDetailPage.tsx b/dashboard/src/org-ui/pages/tasks/TaskDetailPage.tsx index 323f8ca6..3dac87ab 100644 --- a/dashboard/src/org-ui/pages/tasks/TaskDetailPage.tsx +++ b/dashboard/src/org-ui/pages/tasks/TaskDetailPage.tsx @@ -40,6 +40,7 @@ export function TaskDetailPage({ const [newSubtask, setNewSubtask] = useState(""); const [newComment, setNewComment] = useState(""); const [error, setError] = useState(""); + const [uploading, setUploading] = useState(false); const statusLabel = (status: string) => ({ @@ -156,6 +157,41 @@ export function TaskDetailPage({ } }; + const uploadFile = async (file: File) => { + setUploading(true); + try { + await client.uploadAttachment(taskId, file); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } finally { + setUploading(false); + } + }; + + const downloadFile = async (attachmentId: number, filename: string) => { + try { + const blob = await client.downloadAttachment(taskId, attachmentId); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + const removeFile = async (attachmentId: number) => { + try { + await client.removeAttachment(taskId, attachmentId); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + if (loading) { return (
@@ -362,6 +398,51 @@ export function TaskDetailPage({
+ +
+ + {labels.attachments} ({(task.attachments || []).length}) + + {(task.attachments || []).length === 0 ? ( +

{labels.noAttachments}

+ ) : ( + (task.attachments || []).map((file) => ( +
+ + {file.filename} ({file.size_bytes} B) + + + +
+ )) + )} + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void uploadFile(file); + }} + /> + {uploading ? ( +

{labels.addAttachment}

+ ) : null} +
diff --git a/dashboard/src/org-ui/pages/tasks/labels.ts b/dashboard/src/org-ui/pages/tasks/labels.ts index 3d7d8038..bb0ad43e 100644 --- a/dashboard/src/org-ui/pages/tasks/labels.ts +++ b/dashboard/src/org-ui/pages/tasks/labels.ts @@ -49,6 +49,10 @@ const EN = { comments: "Comments", addComment: "Add a comment…", commentHint: "Human notes on this work item. This is not agent chat.", + attachments: "Attachments", + addAttachment: "Attach a file", + noAttachments: "No attachments yet.", + download: "Download", createdBy: "Created by", createdAt: "Created", unknown: "Unknown", @@ -106,6 +110,10 @@ const ZH: typeof EN = { comments: "评论", addComment: "添加评论…", commentHint: "这是工作项上的人工备注,不是智能体对话。", + attachments: "附件", + addAttachment: "添加附件", + noAttachments: "还没有附件。", + download: "下载", createdBy: "创建者", createdAt: "创建时间", unknown: "未知", diff --git a/dashboard/src/org-ui/pages/workspace/WorkspacePage.module.css b/dashboard/src/org-ui/pages/workspace/WorkspacePage.module.css index a016ccf2..6978db67 100644 --- a/dashboard/src/org-ui/pages/workspace/WorkspacePage.module.css +++ b/dashboard/src/org-ui/pages/workspace/WorkspacePage.module.css @@ -165,3 +165,23 @@ padding: 48px 12px; color: var(--fn-text-secondary, #667085); } + +.pauseList { + display: flex; + flex-direction: column; + gap: 10px; +} + +.pauseRow { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.pauseMeta { + font-size: 13px; + color: var(--fn-text-secondary, #667085); + line-height: 1.4; +} diff --git a/dashboard/src/org-ui/pages/workspace/WorkspacePage.test.tsx b/dashboard/src/org-ui/pages/workspace/WorkspacePage.test.tsx index 64660811..7ea315e3 100644 --- a/dashboard/src/org-ui/pages/workspace/WorkspacePage.test.tsx +++ b/dashboard/src/org-ui/pages/workspace/WorkspacePage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { WorkspacePage } from "./WorkspacePage"; import type { OrgWorkspaceClient } from "../../api/createClient"; @@ -44,8 +44,31 @@ function mockClient(): OrgWorkspaceClient { pending_pauses: 1, enabled: true, href: "/organization/governance", + pauses: [ + { + pause_id: "p1", + tool_name: "shell", + category: "outbound", + action: "exec", + actor_id: "agent-1", + tenant_id: "acme", + args_digest: "abc", + reason: "high risk", + status: "pending", + created_at: 1, + resolved_at: null, + ttl_seconds: 600, + }, + ], }, })), + pauses: vi.fn(async () => ({ pauses: [], enabled: true })), + resolve: vi.fn(async () => ({ + status: "approved", + execute: true, + blocked: false, + reason: "ok", + })), }; } @@ -78,6 +101,7 @@ describe("WorkspacePage", () => { "href", "/organization/governance", ); + expect(screen.getByTestId("org-workspace-approve-p1")).toBeInTheDocument(); expect( screen.getByTestId("org-workspace-open-announcements"), ).toHaveAttribute("href", "/organization/announcements"); @@ -101,4 +125,13 @@ describe("WorkspacePage", () => { screen.getByTestId("org-workspace-module-employees"), ).toBeInTheDocument(); }); + + it("approves a pending pause from the workspace banner", async () => { + const client = mockClient(); + render(); + fireEvent.click(await screen.findByTestId("org-workspace-approve-p1")); + await waitFor(() => { + expect(client.resolve).toHaveBeenCalledWith("p1", true); + }); + }); }); diff --git a/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx b/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx index 022cd97f..d28daed5 100644 --- a/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx +++ b/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Tag, Typography } from "antd"; import { LayoutDashboard } from "lucide-react"; import type { + GovernancePause, OrgCapability, OrgWorkspaceClient, OrgWorkspaceOverview, @@ -90,9 +91,10 @@ export function WorkspacePage({ const [overview, setOverview] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [resolving, setResolving] = useState(""); - const fetchAll = useCallback(async () => { - setLoading(true); + const fetchAll = useCallback(async (quiet = false) => { + if (!quiet) setLoading(true); setError(""); try { setOverview(await client.overview()); @@ -100,14 +102,28 @@ export function WorkspacePage({ setOverview(null); setError(labels.loadFailed); } finally { - setLoading(false); + if (!quiet) setLoading(false); } }, [client, labels.loadFailed]); useEffect(() => { - void fetchAll(); + void fetchAll(false); }, [fetchAll]); + const resolvePause = async (pauseId: string, approve: boolean) => { + if (!session.isAdmin) return; + setResolving(pauseId); + setError(""); + try { + await client.resolve(pauseId, approve); + await fetchAll(true); + } catch { + setError(labels.pauseResolveFailed); + } finally { + setResolving(""); + } + }; + const cards: ModuleCard[] = useMemo( () => [ { @@ -199,6 +215,7 @@ export function WorkspacePage({ overview?.governance?.pending_pauses ?? overview?.freeos.pending_pauses ?? 0; + const pauseRows: GovernancePause[] = overview?.governance?.pauses ?? []; const talentCount = overview?.freeos.talent_available ?? overview?.org_surfaces?.talent; @@ -239,6 +256,44 @@ export function WorkspacePage({

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

+ {pauseRows.length ? ( +
+ {pauseRows.map((row) => ( +
+
+ {labels.pauseTool}: {row.tool_name || row.action || "—"} + {row.reason ? ` · ${labels.pauseReason}: ${row.reason}` : ""} +
+ {session.isAdmin ? ( +
+ + +
+ ) : null} +
+ ))} +
+ ) : null}
+ + + ) : null} +
+ ))} +
+ ) : null}