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
This commit is contained in:
gsxdsm
2026-06-21 11:47:32 -07:00
parent 2f7905386f
commit dcf1ab1164
4 changed files with 149 additions and 4 deletions

View File

@@ -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.
<!-- FNXC:CommandCenter 2026-06-21-07:07: FN-6722 requires the GitHub area to expose a resolved-issue detail list from local task-store analytics only, with exact close timestamps flagged when reconciliation populated `sourceIssueClosedAt` and approximation called out otherwise. -->
- **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.

View File

@@ -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<AsyncState<OrgTreeNode[]>>({ status: "loading", data: null, error: null });
const [executorStatsState, setExecutorStatsState] = useState<AsyncState<ExecutorStats>>({ status: "loading", data: null, error: null });
const orgChartViewportRef = useRef<HTMLDivElement | null>(null);
const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0);
const { data, isLoading, error } = useAnalyticsArea<TeamAnalytics>("/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<typeof setTimeout> | undefined;
@@ -275,6 +297,20 @@ export function TeamArea({ range, projectId }: { range: DateRange; projectId?: s
return <span className="cc-sort-caret">{sortDir === 1 ? "▲" : "▼"}</span>;
}
/*
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
<h3>{t("commandCenter.controls.orgChart.title", "Agent org chart")}</h3>
</div>
</div>
<div className="cc-team-org-scroll" aria-live="polite">
<div className="cc-team-org-scroll" data-layout={orgChartLayoutMode} ref={orgChartViewportRef} aria-live="polite">
{orgTreeState.status === "loading" ? (
<p className="cc-team-muted"><LoadingSpinner label={t("commandCenter.controls.orgChart.loading", "Loading org chart…")} /></p>
) : orgTreeState.status === "error" ? (

View File

@@ -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(<TeamArea range={range7d} projectId="project-a" />);
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(<TeamArea range={range7d} projectId={projectId} />);
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(<TeamArea range={range7d} projectId="project-loading" />);
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(<TeamArea range={range7d} projectId="project-error" />);
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(<TeamArea range={range7d} projectId="project-empty" />);
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(<TeamArea range={range7d} projectId="project-single" />);
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([

View File

@@ -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);
}
}