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:
@@ -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.
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user