From dcf1ab1164ca8d6aa4a60ea4d604cd49b908d10c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 11:47:32 -0700 Subject: [PATCH] FN-6870: render wide team org charts horizontally Render the Command Center Team org chart horizontally when its container has room. - Measure the Team org-chart viewport and reuse the shared Agents org-chart layout resolver. - Add horizontal and vertical data-layout styling for Team org-chart roots and children. - Cover wide, narrow, unmeasured, loading, error, empty, and single-root Team org-chart states. - Document the Team org-chart horizontal layout behavior. Files changed: docs/dashboard-guide.md | 2 +- .../components/command-center/areas/TeamArea.tsx | 38 ++++++++++- .../command-center/areas/__tests__/areas.test.tsx | 77 ++++++++++++++++++++++ .../app/components/command-center/areas/areas.css | 36 +++++++++- 4 files changed, 149 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-6870 Fusion-Task-Lineage: b023788a-a353-4b10-ad68-a661e72e5be6 --- docs/dashboard-guide.md | 2 +- .../command-center/areas/TeamArea.tsx | 38 ++++++++- .../areas/__tests__/areas.test.tsx | 77 +++++++++++++++++++ .../components/command-center/areas/areas.css | 36 ++++++++- 4 files changed, 149 insertions(+), 4 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 8b01261393..7592175174 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -689,7 +689,7 @@ Features: - **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint. - **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users. - **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called. -- **Team** shows the read-only agent org chart, heartbeat pause/resume backed by the existing `enginePaused` setting, a per-agent analytics table, tokens-by-agent and tasks-done-by-agent charts, and a real token-share pie from the same per-agent token totals. The org chart is styled by Command Center's Team CSS, not lazy Agents view CSS, and org nodes show only agent names so role/title description/meta text does not clutter Team operations. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users. +- **Team** shows the read-only agent org chart, heartbeat pause/resume backed by the existing `enginePaused` setting, a per-agent analytics table, tokens-by-agent and tasks-done-by-agent charts, and a real token-share pie from the same per-agent token totals. The org chart is styled by Command Center's Team CSS, not lazy Agents view CSS, auto-switches to a horizontal top-down tree when the container is wide enough using the same breakpoint resolver as the full Agents view, and otherwise keeps the vertical nested list inside the scrollable org-chart container. Org nodes show only agent names so role/title description/meta text does not clutter Team operations. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users. - **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown. - **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, a by-repository bar breakdown, and a **Resolved issues** detail list. Resolved rows include the Fusion task, repository, source issue number, optional issue link, resolved timestamp, and whether that timestamp is exact (`sourceIssueClosedAt`) or the documented `updatedAt` approximation; missing issue URLs render as plain text rather than empty anchors or click targets. The same resolved rows are available from the GitHub analytics payload as `resolved` and from the CSV export. diff --git a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx index d5d9f5f633..df681f920f 100644 --- a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx @@ -14,6 +14,7 @@ import type { DateRange } from "../DateRangePicker"; import { Bar, type BarDatum } from "../charts/Bar"; import { Sparkline } from "../charts/Sparkline"; import { PieChart } from "../charts/recharts"; +import { resolveOrgChartLayoutMode, type OrgChartLayoutMode } from "../../agentsOrgChartLayout"; import { AreaShell } from "./AreaShell"; import { useAnalyticsArea } from "./useAnalyticsArea"; import { formatCost, formatCount } from "./areaShared"; @@ -154,6 +155,8 @@ export function TeamArea({ range, projectId }: { range: DateRange; projectId?: s } = useAppSettings(projectId); const [orgTreeState, setOrgTreeState] = useState>({ status: "loading", data: null, error: null }); const [executorStatsState, setExecutorStatsState] = useState>({ status: "loading", data: null, error: null }); + const orgChartViewportRef = useRef(null); + const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0); const { data, isLoading, error } = useAnalyticsArea("/command-center/team", range, { pollMs: TEAM_LIVE_REFRESH_MS, }); @@ -182,6 +185,25 @@ export function TeamArea({ range, projectId }: { range: DateRange; projectId?: s }; }, [projectId, t]); + useEffect(() => { + const viewport = orgChartViewportRef.current; + if (!viewport) return; + + const updateWidth = () => { + setOrgChartViewportWidth(viewport.clientWidth); + }; + + updateWidth(); + const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(updateWidth) : null; + resizeObserver?.observe(viewport); + window.addEventListener("resize", updateWidth); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener("resize", updateWidth); + }; + }, [orgTreeState.status]); + useEffect(() => { let cancelled = false; let timeoutId: ReturnType | undefined; @@ -275,6 +297,20 @@ export function TeamArea({ range, projectId }: { range: DateRange; projectId?: s return {sortDir === 1 ? "▲" : "▼"}; } + /* + FNXC:CommandCenter 2026-06-21-00:00: + Team org charts should become top-down horizontal trees only when the visible org container is wide enough; use the shared Agents view layout resolver so both surfaces agree on breakpoints and fallback to the established vertical list for unmeasured multi-root charts. + */ + const orgChartLayoutMode: OrgChartLayoutMode = useMemo(() => { + if (orgTreeState.status !== "loaded") return "vertical"; + if (orgChartViewportWidth <= 0 && orgTreeState.data.length > 1) return "vertical"; + return resolveOrgChartLayoutMode({ + tree: orgTreeState.data, + availableWidth: orgChartViewportWidth, + preference: "auto", + }); + }, [orgChartViewportWidth, orgTreeState]); + const effectiveGlobalPaused = executorStatsState.data?.globalPause ?? globalPaused; const effectiveEnginePaused = executorStatsState.data?.enginePaused ?? enginePaused; const lastActivityLabel = formatLastActivity( @@ -291,7 +327,7 @@ export function TeamArea({ range, projectId }: { range: DateRange; projectId?: s

{t("commandCenter.controls.orgChart.title", "Agent org chart")}

-
+
{orgTreeState.status === "loading" ? (

) : orgTreeState.status === "error" ? ( diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index 41427425eb..3135cd5b15 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -131,6 +131,10 @@ function githubFixture() { }; } +function installElementClientWidth(width: number) { + return vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width); +} + function agentNode(id: string, name: string, children: OrgTreeNode[] = [], title = "Team Lead"): OrgTreeNode { return { agent: { @@ -1005,6 +1009,79 @@ describe("ProductivityArea", () => { }); describe("TeamArea", () => { + it("applies horizontal org-chart layout when the measured container is wide enough", async () => { + const widthSpy = installElementClientWidth(1_400); + apiMock.mockResolvedValueOnce(emptyTeamFixture()); + fetchOrgTreeMock.mockResolvedValueOnce([ + agentNode("agent-root-a", "Root A", [ + agentNode("agent-child-a", "Child A", [agentNode("agent-grandchild-a", "Grandchild A")]), + agentNode("agent-child-b", "Child B"), + ]), + agentNode("agent-root-b", "Root B"), + ]); + + render(); + + const orgSection = await screen.findByTestId("cc-team-org-chart"); + const orgScroll = orgSection.querySelector(".cc-team-org-scroll"); + await waitFor(() => expect(orgScroll).toHaveAttribute("data-layout", "horizontal")); + expect(orgSection.querySelectorAll(".cc-team-org-card")).toHaveLength(5); + for (const name of ["Root A", "Child A", "Grandchild A", "Child B", "Root B"]) { + expect(within(orgSection).getByText(name)).toBeTruthy(); + } + widthSpy.mockRestore(); + }); + + it("keeps multi-root org charts vertical for narrow and zero-width containers", async () => { + for (const [width, projectId] of [[320, "project-narrow"], [0, "project-zero"]] as const) { + const widthSpy = installElementClientWidth(width); + apiMock.mockResolvedValueOnce(emptyTeamFixture()); + fetchOrgTreeMock.mockResolvedValueOnce([ + agentNode(`${projectId}-root-a`, `${projectId} Root A`, [agentNode(`${projectId}-child`, `${projectId} Child`)]), + agentNode(`${projectId}-root-b`, `${projectId} Root B`), + ]); + + const { unmount } = render(); + const orgSection = await screen.findByTestId("cc-team-org-chart"); + const orgScroll = orgSection.querySelector(".cc-team-org-scroll"); + await waitFor(() => expect(orgScroll).toHaveAttribute("data-layout", "vertical")); + expect(orgSection.querySelectorAll(".cc-team-org-card")).toHaveLength(3); + unmount(); + widthSpy.mockRestore(); + } + }); + + it("keeps loading, error, empty, and single-root org-chart states stable while measuring width", async () => { + const widthSpy = installElementClientWidth(0); + apiMock.mockResolvedValueOnce(emptyTeamFixture()); + fetchOrgTreeMock.mockImplementationOnce(() => new Promise(() => undefined)); + const loading = render(); + const loadingOrg = await screen.findByTestId("cc-team-org-chart"); + expect(within(loadingOrg).getByText("Loading org chart…")).toBeTruthy(); + expect(loadingOrg.querySelector(".cc-team-org-scroll")).toHaveAttribute("data-layout", "vertical"); + loading.unmount(); + + apiMock.mockResolvedValueOnce(emptyTeamFixture()); + fetchOrgTreeMock.mockRejectedValueOnce(new Error("org failed")); + const error = render(); + expect(await within(await screen.findByTestId("cc-team-org-chart")).findByRole("alert")).toHaveTextContent("org failed"); + error.unmount(); + + apiMock.mockResolvedValueOnce(emptyTeamFixture()); + fetchOrgTreeMock.mockResolvedValueOnce([]); + const empty = render(); + expect(await within(await screen.findByTestId("cc-team-org-chart")).findByText("No agents are reporting in yet.")).toBeTruthy(); + empty.unmount(); + + apiMock.mockResolvedValueOnce(emptyTeamFixture()); + fetchOrgTreeMock.mockResolvedValueOnce([agentNode("agent-single", "Single Root")]); + render(); + const singleOrg = await screen.findByTestId("cc-team-org-chart"); + await waitFor(() => expect(singleOrg.querySelector(".cc-team-org-scroll")).toHaveAttribute("data-layout", "horizontal")); + expect(singleOrg.querySelectorAll(".cc-team-org-card")).toHaveLength(1); + widthSpy.mockRestore(); + }); + it("renders relocated org chart and heartbeat outside analytics gating", async () => { apiMock.mockResolvedValueOnce(emptyTeamFixture()); fetchOrgTreeMock.mockResolvedValueOnce([ diff --git a/packages/dashboard/app/components/command-center/areas/areas.css b/packages/dashboard/app/components/command-center/areas/areas.css index 87350a6a3f..26210820c9 100644 --- a/packages/dashboard/app/components/command-center/areas/areas.css +++ b/packages/dashboard/app/components/command-center/areas/areas.css @@ -320,24 +320,50 @@ Team owns the Agent org chart and Heartbeat control. Org nodes must be self-styl .cc-team-org-roots, .cc-team-org-children { display: flex; - flex-direction: column; gap: var(--space-sm); margin: 0; padding: 0; list-style: none; } -.cc-team-org-children { +.cc-team-org-scroll[data-layout="vertical"] .cc-team-org-roots, +.cc-team-org-scroll[data-layout="vertical"] .cc-team-org-children { + flex-direction: column; +} + +.cc-team-org-scroll[data-layout="vertical"] .cc-team-org-children { margin-block-start: var(--space-sm); margin-inline-start: var(--space-xl); padding-inline-start: var(--space-md); border-inline-start: thin solid var(--border-subtle); } +/* +FNXC:CommandCenter 2026-06-21-00:00: +The Team tab org chart should use a horizontal top-down tree when the shared Agents-view width resolver says the container can fit it, while vertical connector/indent styles remain scoped to vertical mode so they do not leave visual shells in horizontal mode. +*/ +.cc-team-org-scroll[data-layout="horizontal"] .cc-team-org-roots, +.cc-team-org-scroll[data-layout="horizontal"] .cc-team-org-children { + flex-direction: row; + align-items: flex-start; + justify-content: center; + gap: var(--space-md); +} + +.cc-team-org-scroll[data-layout="horizontal"] .cc-team-org-children { + margin-block-start: var(--space-md); +} + .cc-team-org-item { min-inline-size: max-content; } +.cc-team-org-scroll[data-layout="horizontal"] .cc-team-org-item { + display: flex; + flex-direction: column; + align-items: center; +} + .cc-team-org-card { display: inline-flex; min-inline-size: calc(var(--space-2xl) * 5); @@ -503,4 +529,10 @@ Tablet Command Center areas share the FN-6679 overflow fix with the shell: area .cc-team-org-scroll { max-block-size: calc(var(--space-2xl) * 8); } + + .cc-team-org-scroll[data-layout="horizontal"] .cc-team-org-roots, + .cc-team-org-scroll[data-layout="horizontal"] .cc-team-org-children { + justify-content: flex-start; + gap: var(--space-sm); + } }