diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0c397daf4f..190f845810 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -702,7 +702,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, 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 taller scrollable org-chart container. Parent agents draw connector lines to child agents in both horizontal and vertical Team layouts across desktop and mobile breakpoints. 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 taller scrollable org-chart container. The org-chart scroll container supports mouse click-and-drag panning while touch devices keep native scrolling. Parent agents draw connector lines to child agents in both horizontal and vertical Team layouts across desktop and mobile breakpoints. 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 df681f920f..b9dd690df3 100644 --- a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx @@ -2,7 +2,8 @@ FNXC:CommandCenter 2026-06-18-16:57: Team tab shows each agent's tokens/cost/files-changed/tasks-completed with live status and bar charts, reusing existing analytics primitives; GitHub-issue per-agent stats are FN-6653, not here. */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from "react"; import { useTranslation } from "react-i18next"; import { Pause, Play } from "lucide-react"; import type { CostResult, OrgTreeNode, TeamAgentSummary, TeamAnalytics } from "@fusion/core"; @@ -21,6 +22,7 @@ import { formatCost, formatCount } from "./areaShared"; const TEAM_LIVE_REFRESH_MS = 15_000; const EXECUTOR_STATUS_POLL_MS = 10_000; +const ORG_CHART_DRAG_THRESHOLD = 4; type SortKey = "agent" | "tokens" | "cost" | "filesChanged" | "tasksCompleted" | "tasksInProgress"; type AsyncState = @@ -35,6 +37,15 @@ type ExecutorStats = { lastActivityAt?: string; }; +type OrgChartDragState = { + pointerId: number; + startX: number; + startY: number; + startScrollLeft: number; + startScrollTop: number; + isPanning: boolean; +}; + function costSortValue(cost: CostResult): number { return cost.unavailable || cost.usd === null ? -1 : cost.usd; } @@ -156,6 +167,9 @@ export function TeamArea({ range, projectId }: { range: DateRange; projectId?: s 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 orgChartDragStateRef = useRef(null); + const orgChartDidPanRef = useRef(false); + const [isOrgChartDragging, setIsOrgChartDragging] = useState(false); const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0); const { data, isLoading, error } = useAnalyticsArea("/command-center/team", range, { pollMs: TEAM_LIVE_REFRESH_MS, @@ -297,6 +311,59 @@ export function TeamArea({ range, projectId }: { range: DateRange; projectId?: s return {sortDir === 1 ? "▲" : "▼"}; } + /* + FNXC:CommandCenter 2026-06-21-00:00: + FN-6885 requires the Team-tab agent org chart to support mouse/pen click-and-drag panning along whichever native scroll axis overflows. Ignore touch pointers so mobile keeps native momentum scrolling, and only activate after the drag threshold so ordinary org-node clicks remain intact. + */ + const endOrgChartDrag = useCallback((event: ReactPointerEvent) => { + const dragState = orgChartDragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + } + orgChartDragStateRef.current = null; + setIsOrgChartDragging(false); + }, []); + + const handleOrgChartPointerDown = useCallback((event: ReactPointerEvent) => { + if (event.pointerType === "touch" || event.button !== 0) return; + const viewport = event.currentTarget; + orgChartDidPanRef.current = false; + orgChartDragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startScrollLeft: viewport.scrollLeft, + startScrollTop: viewport.scrollTop, + isPanning: false, + }; + viewport.setPointerCapture?.(event.pointerId); + }, []); + + const handleOrgChartPointerMove = useCallback((event: ReactPointerEvent) => { + const dragState = orgChartDragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + if (!dragState.isPanning && Math.hypot(deltaX, deltaY) < ORG_CHART_DRAG_THRESHOLD) return; + if (!dragState.isPanning) { + dragState.isPanning = true; + orgChartDidPanRef.current = true; + setIsOrgChartDragging(true); + } + event.preventDefault(); + const viewport = event.currentTarget; + viewport.scrollLeft = dragState.startScrollLeft - deltaX; + viewport.scrollTop = dragState.startScrollTop - deltaY; + }, []); + + const handleOrgChartClickCapture = useCallback((event: ReactMouseEvent) => { + if (!orgChartDidPanRef.current) return; + orgChartDidPanRef.current = false; + event.preventDefault(); + event.stopPropagation(); + }, []); + /* 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. @@ -327,7 +394,18 @@ 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__/TeamArea.orgchart-pan.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/TeamArea.orgchart-pan.test.tsx new file mode 100644 index 0000000000..89e7334862 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/__tests__/TeamArea.orgchart-pan.test.tsx @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import type { OrgTreeNode } from "@fusion/core"; +import { TeamArea } from "../TeamArea"; +import type { DateRange } from "../DateRangePicker"; + +const mocks = vi.hoisted(() => ({ + fetchOrgTree: vi.fn(), + fetchExecutorStats: vi.fn(), + toggleEnginePause: vi.fn(), + useAnalyticsArea: vi.fn(), + resolveOrgChartLayoutMode: vi.fn(), +})); + +vi.mock("../../../../api/legacy", () => ({ + fetchOrgTree: mocks.fetchOrgTree, + fetchExecutorStats: mocks.fetchExecutorStats, +})); + +vi.mock("../../../../hooks/useAppSettings", () => ({ + useAppSettings: () => ({ + globalPaused: false, + enginePaused: false, + toggleEnginePause: mocks.toggleEnginePause, + }), +})); + +vi.mock("../useAnalyticsArea", () => ({ + useAnalyticsArea: mocks.useAnalyticsArea, +})); + +vi.mock("../../../agentsOrgChartLayout", () => ({ + resolveOrgChartLayoutMode: mocks.resolveOrgChartLayoutMode, +})); + +const range: DateRange = { from: "2026-06-08", to: null, preset: "7d" }; + +function agentNode(id: string, name: string, children: OrgTreeNode[] = []): OrgTreeNode { + return { + agent: { + id, + name, + role: "executor", + state: "idle", + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:00.000Z", + metadata: {}, + }, + children, + }; +} + +const orgTree: OrgTreeNode[] = [ + agentNode("root", "Root Agent", [ + agentNode("left", "Left Agent", [agentNode("left-child", "Left Child")]), + agentNode("right", "Right Agent", [agentNode("right-child", "Right Child")]), + ]), +]; + +function teamAnalyticsFixture() { + return { + from: null, + to: null, + totals: { + tokens: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 }, + cost: { usd: null, unavailable: false, stale: false }, + filesChanged: 0, + tasksCompleted: 0, + tasksInProgress: 0, + tasksInReview: 0, + }, + agents: [], + }; +} + +function renderTeamArea(layout: "horizontal" | "vertical") { + mocks.resolveOrgChartLayoutMode.mockReturnValue(layout); + render(); +} + +async function findOrgViewport() { + await screen.findByText("Root Agent"); + const viewport = document.querySelector(".cc-team-org-scroll") as HTMLDivElement | null; + expect(viewport).toBeInTheDocument(); + return viewport!; +} + +function makeScrollable(viewport: HTMLDivElement, scroll: { left?: number; top?: number } = {}) { + Object.defineProperties(viewport, { + clientWidth: { configurable: true, value: 320 }, + scrollWidth: { configurable: true, value: 960 }, + clientHeight: { configurable: true, value: 180 }, + scrollHeight: { configurable: true, value: 720 }, + }); + viewport.scrollLeft = scroll.left ?? 0; + viewport.scrollTop = scroll.top ?? 0; +} + +describe("TeamArea org chart drag panning", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("ResizeObserver", class { + observe() {} + disconnect() {} + }); + mocks.fetchOrgTree.mockResolvedValue(orgTree); + mocks.fetchExecutorStats.mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }); + mocks.useAnalyticsArea.mockReturnValue({ data: teamAnalyticsFixture(), isLoading: false, error: null }); + }); + + it("pans horizontal layout by mutating native scrollLeft during a mouse drag", async () => { + renderTeamArea("horizontal"); + const viewport = await findOrgViewport(); + makeScrollable(viewport, { left: 100, top: 40 }); + + fireEvent.pointerDown(viewport, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 100, clientY: 40 }); + fireEvent.pointerMove(viewport, { pointerId: 1, pointerType: "mouse", clientX: 60, clientY: 40 }); + fireEvent.pointerUp(viewport, { pointerId: 1, pointerType: "mouse", clientX: 60, clientY: 40 }); + + expect(viewport).toHaveAttribute("data-layout", "horizontal"); + expect(viewport.scrollLeft).toBe(140); + expect(viewport.scrollTop).toBe(40); + }); + + it("pans vertical layout by mutating native scrollTop during a mouse drag", async () => { + renderTeamArea("vertical"); + const viewport = await findOrgViewport(); + makeScrollable(viewport, { left: 30, top: 80 }); + + fireEvent.pointerDown(viewport, { pointerId: 2, pointerType: "mouse", button: 0, clientX: 50, clientY: 100 }); + fireEvent.pointerMove(viewport, { pointerId: 2, pointerType: "mouse", clientX: 50, clientY: 50 }); + fireEvent.pointerUp(viewport, { pointerId: 2, pointerType: "mouse", clientX: 50, clientY: 50 }); + + expect(viewport).toHaveAttribute("data-layout", "vertical"); + expect(viewport.scrollLeft).toBe(30); + expect(viewport.scrollTop).toBe(130); + }); + + it("leaves touch pointer sequences on the native scrolling path", async () => { + renderTeamArea("horizontal"); + const viewport = await findOrgViewport(); + makeScrollable(viewport, { left: 100, top: 20 }); + + fireEvent.pointerDown(viewport, { pointerId: 3, pointerType: "touch", button: 0, clientX: 100, clientY: 40 }); + fireEvent.pointerMove(viewport, { pointerId: 3, pointerType: "touch", clientX: 20, clientY: 40 }); + fireEvent.pointerUp(viewport, { pointerId: 3, pointerType: "touch", clientX: 20, clientY: 40 }); + + expect(viewport.scrollLeft).toBe(100); + expect(viewport.scrollTop).toBe(20); + expect(viewport).not.toHaveClass("is-dragging"); + }); + + it("does not treat a pure node click as a pan or swallow the node click", async () => { + renderTeamArea("horizontal"); + const viewport = await findOrgViewport(); + makeScrollable(viewport, { left: 55, top: 25 }); + const card = screen.getByText("Root Agent").closest(".cc-team-org-card") as HTMLDivElement | null; + expect(card).toBeInTheDocument(); + const clickHandler = vi.fn(); + card!.addEventListener("click", clickHandler); + + fireEvent.pointerDown(card!, { pointerId: 4, pointerType: "mouse", button: 0, clientX: 120, clientY: 60 }); + fireEvent.pointerUp(viewport, { pointerId: 4, pointerType: "mouse", clientX: 120, clientY: 60 }); + fireEvent.click(card!); + + expect(viewport.scrollLeft).toBe(55); + expect(viewport.scrollTop).toBe(25); + expect(clickHandler).toHaveBeenCalledTimes(1); + await waitFor(() => expect(viewport).not.toHaveClass("is-dragging")); + }); +}); diff --git a/packages/dashboard/app/components/command-center/areas/areas.css b/packages/dashboard/app/components/command-center/areas/areas.css index 7aa610c956..3f196c6c16 100644 --- a/packages/dashboard/app/components/command-center/areas/areas.css +++ b/packages/dashboard/app/components/command-center/areas/areas.css @@ -311,10 +311,20 @@ Team owns the Agent org chart and Heartbeat control. Org nodes must be self-styl gap: var(--space-xs); } +/* +FNXC:CommandCenter 2026-06-21-00:00: +FN-6885 adds a grab affordance to the Team org-chart scroll owner. Keep native overflow scrolling as the single pan mechanism and switch to non-selecting grabbing only while the mouse/pen drag handler is actively scrolling. +*/ .cc-team-org-scroll { max-block-size: calc(var(--space-2xl) * 13); overflow: auto; overscroll-behavior: contain; + cursor: grab; +} + +.cc-team-org-scroll.is-dragging { + cursor: grabbing; + user-select: none; } .cc-team-org-roots,