From 30a11ac919220c95ac685a4f7ccc35a49879ce79 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 3 Jul 2026 00:38:01 -0700 Subject: [PATCH] FN-7461: count footer queues across task states Correct the dashboard footer counter contract so active task pressure matches board state coverage. - Count triage and planning lanes as queued while excluding done, archived, and inactive custom lanes. - Treat only actionable blockedBy values as blocked tasks and keep running/stuck scoped to in-progress work. - Cover every visible footer counter, background/overlap segments, and the intentional absence of a Done footer count in regression tests. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7461-footer-counters.md | 7 ++ packages/dashboard/app/api/legacy.ts | 6 +- .../__tests__/ExecutorStatusBar.test.tsx | 86 +++++++++++++++++++++- .../app/hooks/__tests__/useExecutorStats.test.ts | 59 ++++++++------- packages/dashboard/app/hooks/useExecutorStats.ts | 24 +++++- 5 files changed, 150 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-7461 Fusion-Task-Lineage: a1eceb98-76b2-4820-95c4-888ad61b4162 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7461-footer-counters.md | 7 ++ packages/dashboard/app/api/legacy.ts | 6 +- .../__tests__/ExecutorStatusBar.test.tsx | 86 ++++++++++++++++++- .../hooks/__tests__/useExecutorStats.test.ts | 59 +++++++------ .../dashboard/app/hooks/useExecutorStats.ts | 24 +++++- 5 files changed, 150 insertions(+), 32 deletions(-) create mode 100644 .changeset/fn-7461-footer-counters.md diff --git a/.changeset/fn-7461-footer-counters.md b/.changeset/fn-7461-footer-counters.md new file mode 100644 index 0000000000..93d7161ae6 --- /dev/null +++ b/.changeset/fn-7461-footer-counters.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Count planning tasks correctly in the dashboard footer queue metric. +category: fix +dev: Footer counter tests now cover queued, running, stuck, blocked, review, overlap, background AI, and Done absence. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index e4e10fe98d..5f82f06326 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6933,7 +6933,9 @@ export type ExecutorState = "idle" | "running" | "paused" | "stopped"; * * Counts (runningTaskCount, blockedTaskCount, queuedTaskCount, inReviewCount, stuckTaskCount) * are derived client-side from the same tasks array shared with the board, ensuring - * the footer counts always match the column counts displayed on screen. + * the footer counts always match the active work states displayed on screen. Queued covers + * todo plus planning/triage work; Done is intentionally not exposed unless a footer Done + * segment is added. * The API returns settings-based values (globalPause, enginePaused, maxConcurrent) and * lastActivityAt from the activity log. * @@ -6950,7 +6952,7 @@ export interface ExecutorStats { blockedTaskCount: number; /** Number of "in-progress" tasks with no activity for > 10 minutes */ stuckTaskCount: number; - /** Number of tasks in "todo" column */ + /** Number of tasks in "todo" plus planning/triage work states */ queuedTaskCount: number; /** Number of tasks in "in-review" column */ inReviewCount: number; diff --git a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx index 03b578a8f0..0dd7ebc83d 100644 --- a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx +++ b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import fs from "fs"; import path from "path"; @@ -42,7 +42,7 @@ vi.mock("../EngineControlMenu", async () => { }); import { useExecutorStats } from "../../hooks/useExecutorStats"; -import type { ExecutorStats } from "../../api"; +import type { AiSessionSummary, ExecutorStats } from "../../api"; const mockUseExecutorStats = useExecutorStats as ReturnType; const executorStatusBarCss = fs.readFileSync(path.join(__dirname, "../ExecutorStatusBar.css"), "utf-8"); @@ -81,6 +81,32 @@ function makeTask(id: string, column: string, overrides: Record }; } +function getSegmentByLabel(label: string): HTMLElement { + const labelElement = screen + .getAllByText(label) + .find((element) => element.classList.contains("executor-status-bar__label")); + expect(labelElement).toBeTruthy(); + const segment = labelElement?.closest(".executor-status-bar__segment"); + expect(segment).toBeTruthy(); + return segment as HTMLElement; +} + +function expectSegmentCount(label: string, count: string): void { + expect(within(getSegmentByLabel(label)).getByText(count)).toBeInTheDocument(); +} + +function makeBackgroundSession(id: string, status: AiSessionSummary["status"]): AiSessionSummary { + return { + id, + type: "planning", + status, + title: `Background ${id}`, + projectId: "project-1", + lockedByTab: null, + updatedAt: "2026-07-03T12:00:00.000Z", + }; +} + describe("ExecutorStatusBar", () => { const defaultStats: ExecutorStats = { runningTaskCount: 2, @@ -118,9 +144,65 @@ describe("ExecutorStatusBar", () => { expect(statusBar).toHaveTextContent("Blocked"); expect(statusBar).toHaveTextContent("Queued"); expect(statusBar).toHaveTextContent("In Review"); + expect(statusBar).not.toHaveTextContent("Done"); expect(statusBar).not.toHaveTextContent("Escalated"); }); + it.each(["desktop", "tablet"] as const)("associates every visible footer count with its label on %s", (viewportMode) => { + viewportModeMock.value = viewportMode; + vi.mocked(mockUseExecutorStats).mockReturnValue({ + stats: { + ...defaultStats, + queuedTaskCount: 9, + runningTaskCount: 2, + maxConcurrent: 4, + stuckTaskCount: 1, + blockedTaskCount: 2, + inReviewCount: 1, + }, + loading: false, + error: null, + refresh: vi.fn(), + }); + const tasks = [ + makeTask("FN-010", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }), + makeTask("FN-101", "todo", { blockedBy: "FN-010" }), + makeTask("FN-102", "todo", { blockedBy: "FN-010" }), + makeTask("FN-103", "todo", { blockedBy: "FN-010" }), + makeTask("FN-104", "todo", { blockedBy: "FN-010" }), + makeTask("FN-105", "todo", { blockedBy: "FN-010" }), + ]; + + render( + , + ); + + const statusBar = screen.getByRole("status"); + expectSegmentCount("Queued", "9"); + expectSegmentCount("Running", "2"); + expect(within(getSegmentByLabel("Running")).getByText("4")).toHaveClass("executor-status-bar__max"); + expectSegmentCount("Stuck", "1"); + expectSegmentCount("Blocked", "2"); + expectSegmentCount("In Review", "1"); + expect(statusBar).toHaveTextContent("Overlap queue"); + expect(statusBar).toHaveTextContent("FN-010 ยท 5 todo"); + expect(statusBar).toHaveTextContent("AI 2"); + expect(statusBar).not.toHaveTextContent("Done"); + expect(statusBar.firstElementChild).not.toHaveClass("executor-status-bar__divider"); + expect(statusBar.lastElementChild).toHaveClass("executor-status-bar__segment--engine-controls"); + }); + it("shows overlap bottleneck summary with stable tie-break ordering", () => { const tasks = [ makeTask("FN-010", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }), diff --git a/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts b/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts index 95f8fe9993..30e4007265 100644 --- a/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts @@ -752,45 +752,52 @@ describe("useExecutorStats", () => { }); describe("board-sync regression", () => { - it("derives counts from the same tasks array the board uses", async () => { - // Simulate a full board: 2 triage, 3 todo (1 blocked), 2 in-progress, 1 in-review, 4 done + it("derives the complete footer count matrix from the same tasks array the board uses", async () => { + const now = new Date("2026-07-03T12:00:00.000Z").getTime(); + const staleUpdatedAt = new Date(now - 11 * 60 * 1000).toISOString(); + const freshUpdatedAt = new Date(now - 2 * 60 * 1000).toISOString(); const tasks: Task[] = [ createMockTask("FN-001", "triage"), - createMockTask("FN-002", "triage"), - createMockTask("FN-003", "todo"), - { ...createMockTask("FN-004", "todo"), blockedBy: "FN-010" }, - createMockTask("FN-005", "todo"), - createMockTask("FN-006", "in-progress"), - createMockTask("FN-007", "in-progress"), - createMockTask("FN-008", "in-review"), - createMockTask("FN-009", "done"), - createMockTask("FN-010", "done"), - createMockTask("FN-011", "done"), - createMockTask("FN-012", "done"), + createMockTask("FN-002", "todo"), + { ...createMockTask("FN-003", "in-progress"), updatedAt: staleUpdatedAt }, + { ...createMockTask("FN-004", "in-progress"), updatedAt: freshUpdatedAt }, + createMockTask("FN-005", "in-review"), + { ...createMockTask("FN-006", "done"), status: "running" } as Task, + createMockTask("FN-007", "archived"), + { ...createMockTask("FN-008", "todo"), blockedBy: "FN-006" }, + { ...createMockTask("FN-009", "todo"), dependencies: ["FN-006"] }, + { ...createMockTask("FN-010", "todo"), blockedBy: ["FN-006", "FN-006"] } as unknown as Task, + { ...createMockTask("FN-011", "todo"), blockedBy: "" }, + { ...createMockTask("FN-012", "todo"), blockedBy: [] } as unknown as Task, + { ...createMockTask("FN-013", "todo"), blockedBy: null } as unknown as Task, + { ...createMockTask("FN-014", "custom-column" as Task["column"]) }, + { ...createMockTask("FN-015", "triage"), status: "planning" } as Task, + { ...createMockTask("FN-016", "custom-planning" as Task["column"]), status: "planning" } as Task, ]; - const { result } = renderHook(() => useExecutorStats(tasks)); + const { result } = renderHook(() => useExecutorStats(tasks, undefined, 10 * 60 * 1000, now)); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); - // These must match the column counts shown on the board - expect(result.current.stats.runningTaskCount).toBe(2); // in-progress - expect(result.current.stats.queuedTaskCount).toBe(3); // todo - expect(result.current.stats.inReviewCount).toBe(1); // in-review - expect(result.current.stats.blockedTaskCount).toBe(1); // blockedBy set - expect(result.current.stats.stuckTaskCount).toBe(0); // all recent - - // Verify the executor state reflects running tasks + expect(result.current.stats.queuedTaskCount).toBe(10); // triage/planning + todo, no done/archived/non-planning custom + expect(result.current.stats.runningTaskCount).toBe(2); // in-progress only + expect(result.current.stats.stuckTaskCount).toBe(1); // stuck is an in-progress subset + expect(result.current.stats.blockedTaskCount).toBe(2); // actionable string/array blockedBy only + expect(result.current.stats.inReviewCount).toBe(1); // in-review only + expect("doneTaskCount" in result.current.stats).toBe(false); expect(result.current.stats.executorState).toBe("running"); }); - it("does not count triage, done, or archived tasks in any footer metric", async () => { + it("counts planning/triage as queued but excludes done, archived, and unknown columns", async () => { const tasks: Task[] = [ createMockTask("FN-001", "triage"), - createMockTask("FN-002", "done"), - createMockTask("FN-003", "archived"), + { ...createMockTask("FN-002", "triage"), status: "planning" } as Task, + createMockTask("FN-003", "done"), + createMockTask("FN-004", "archived"), + { ...createMockTask("FN-005", "custom-column" as Task["column"]) }, + { ...createMockTask("FN-006", "custom-planning" as Task["column"]), status: "planning" } as Task, ]; const { result } = renderHook(() => useExecutorStats(tasks)); @@ -800,7 +807,7 @@ describe("useExecutorStats", () => { }); expect(result.current.stats.runningTaskCount).toBe(0); - expect(result.current.stats.queuedTaskCount).toBe(0); + expect(result.current.stats.queuedTaskCount).toBe(3); expect(result.current.stats.inReviewCount).toBe(0); expect(result.current.stats.blockedTaskCount).toBe(0); expect(result.current.stats.stuckTaskCount).toBe(0); diff --git a/packages/dashboard/app/hooks/useExecutorStats.ts b/packages/dashboard/app/hooks/useExecutorStats.ts index 2daf6fae8f..52dbbc402c 100644 --- a/packages/dashboard/app/hooks/useExecutorStats.ts +++ b/packages/dashboard/app/hooks/useExecutorStats.ts @@ -57,6 +57,9 @@ function deriveExecutorState( /** * Derive statistics from the task list. + * + * FNXC:ExecutorStatusBar 2026-07-03-00:18: + * Footer task counters must mirror the board's operator-facing active work states: Queued includes todo plus planning/triage work, Running and Stuck stay scoped to in-progress execution, Done remains absent from the footer contract unless a labeled Done segment is introduced, and archived/completed/non-planning custom lanes never inflate active pressure counts. */ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): Pick< ExecutorStats, @@ -77,15 +80,20 @@ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFe } break; case "todo": + case "triage": queuedTaskCount++; break; case "in-review": inReviewCount++; break; + default: + if (task.status === "planning" && !isTerminalOrActiveTaskColumn(task.column)) { + queuedTaskCount++; + } + break; } - // Count tasks with blockedBy set - if (task.blockedBy && task.blockedBy.length > 0) { + if (hasActionableBlockedBy(task.blockedBy)) { blockedTaskCount++; } } @@ -99,6 +107,18 @@ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFe }; } +function isTerminalOrActiveTaskColumn(column: Task["column"]): boolean { + return column === "in-progress" || column === "in-review" || column === "done" || column === "archived"; +} + +function hasActionableBlockedBy(blockedBy: Task["blockedBy"] | string[] | null): boolean { + if (Array.isArray(blockedBy)) { + return blockedBy.some((id) => typeof id === "string" && id.trim().length > 0); + } + + return typeof blockedBy === "string" && blockedBy.trim().length > 0; +} + /** * Hook for aggregating executor statistics for the status bar. *