diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index e2aee6e597..f5b041fe2d 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -22,6 +22,7 @@ import { useViewportMode } from "../hooks/useViewportMode"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection"; import { getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress"; +import { isTaskAgentActive } from "../utils/taskActivity"; import { getTaskStatusBadgeLabel } from "../utils/taskStatusBadgeLabel"; import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval"; import { useConfirm } from "../hooks/useConfirm"; @@ -48,21 +49,6 @@ function columnColor(column: ColumnId): string { return (COLUMN_COLOR_MAP as Record)[column] ?? "var(--accent)"; } -/* -FNXC:MergeQueue 2026-07-15-10:40: -List view agent-active styling must cover AI-merge reviewing/landing so rows stay live while the merger owns the pump. -*/ -const ACTIVE_STATUSES = new Set([ - "planning", - "researching", - "executing", - "finalizing", - "merging", - "merging-pr", - "merging-fix", - "reviewing", - "landing", -]); const LIST_TOUCH_CONTEXT_MENU_DELAY_MS = 550; const LIST_TOUCH_MOVE_THRESHOLD = 10; const LIST_CONTEXT_MENU_VIEWPORT_MARGIN = 8; @@ -2678,12 +2664,7 @@ export function ListView({ const isFailed = !isDoneColumn && task.status === "failed"; const isPaused = !isDoneColumn && task.paused === true; const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); - const isAgentActive = - !globalPaused && - !isFailed && - !isPaused && - !isStuckState && - (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); + const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState }); const hasStatus = typeof visualStatus === "string" && visualStatus.trim().length > 0; const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task); const planReviewRunning = isPlanReviewRunning(task); @@ -2751,7 +2732,7 @@ export function ListView({ : getTaskStatusLabel(visualStatus ?? "", t)} ) : null} - {planReviewRunning && ( + {planReviewRunning && isAgentActive && ( /* FNXC:TaskCardPlanReviewBadge 2026-07-11-12:10: Grouped ListView cards must show the same active Plan Review "Reviewing" badge as TaskCard so board and list surfaces remain visually equivalent while the `plan-review` workflow step is running. @@ -2896,12 +2877,7 @@ export function ListView({ const isFailed = !isDoneColumn && task.status === "failed"; const isPaused = !isDoneColumn && task.paused === true; const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); - const isAgentActive = - !globalPaused && - !isFailed && - !isPaused && - !isStuckState && - (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); + const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState }); const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task); const planReviewRunning = isPlanReviewRunning(task); const isDragging = draggingTaskId === task.id; @@ -2982,7 +2958,7 @@ export function ListView({ ) : ( - )} - {planReviewRunning && ( + {planReviewRunning && isAgentActive && ( /* FNXC:TaskCardPlanReviewBadge 2026-07-11-12:11: Ungrouped ListView table rows must render the same Reviewing badge from the shared predicate; this second status render path is easy to miss and must stay in parity with grouped rows. diff --git a/packages/dashboard/app/components/RoutingTab.tsx b/packages/dashboard/app/components/RoutingTab.tsx index 776684a03e..ed061eb257 100644 --- a/packages/dashboard/app/components/RoutingTab.tsx +++ b/packages/dashboard/app/components/RoutingTab.tsx @@ -7,6 +7,7 @@ import { fetchNodes, updateTask } from "../api"; import type { NodeInfo } from "../api"; import type { ToastType } from "../hooks/useToast"; import { NodeHealthDot } from "./NodeHealthDot"; +import { ACTIVE_STATUSES } from "../utils/taskActivity"; interface RoutingTabProps { task: Task | TaskDetail; @@ -26,22 +27,6 @@ function getRoutingPolicyLabel(policy: RoutingSettings["unavailableNodePolicy"] return t("routing.policyLabel.notConfigured", "Not configured"); } -/* -FNXC:MergeQueue 2026-07-15-10:40: -Routing tab active-status styling includes AI-merge reviewing/landing for parity with TaskCard. -*/ -const ACTIVE_STATUSES = new Set([ - "planning", - "researching", - "executing", - "finalizing", - "merging", - "merging-pr", - "merging-fix", - "reviewing", - "landing", -]); - function isUnhealthy(status: NodeInfo["status"] | undefined): boolean { return status !== undefined && status !== "online"; } diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 5e92e6f388..7dfdc733d1 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -40,6 +40,7 @@ import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inR import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy"; import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy"; import { getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress"; +import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity"; import { getPrBadgeModifierClass } from "../utils/prBadgeClass"; import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming"; import { getTaskStatusBadgeLabel } from "../utils/taskStatusBadgeLabel"; @@ -263,22 +264,9 @@ function isAgentCreatedTask(task: Task): boolean { // (which are not members and correctly resolve to false). const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); -/* -FNXC:MergeQueue 2026-07-15-10:40: -AI merge is live for most of its window under status reviewing (clean-room review) and landing (advance main / cleanup), not only merging*. Keep card pulse, merge timer, and status badge on for the full pipeline so operators always see a Merging badge on the single-flight owner. -*/ -const ACTIVE_STATUSES = new Set([ - "planning", - "researching", - "executing", - "finalizing", - "merging", - "merging-pr", - "merging-fix", - "reviewing", - "landing", -]); -const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix", "reviewing", "landing"]); +const ACTIVE_MERGE_STATUSES = new Set( + [...ACTIVE_STATUSES].filter((status) => ["merging", "merging-pr", "merging-fix", "reviewing", "landing"].includes(status)), +); const COLUMN_PROGRESS_COLOR_MAP: Record = { triage: "var(--triage)", @@ -1335,7 +1323,7 @@ function TaskCardComponent({ const isPlanReviewReplanCapApproval = isReviewBudgetExhaustedApproval(task); const isAwaitingInput = task.status === "awaiting-user-input"; const isArchived = task.column === "archived"; - const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && !isAwaitingInput && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); + const isAgentActive = isTaskAgentActive(task, { globalPaused, queued, isStuck }); // Native HTML5 drag is desktop-mouse only — it doesn't move cards via touch. // On touch-primary devices the `draggable` attribute still arms the browser's // touch-drag heuristic, which intermittently hijacks horizontal swipes meant @@ -2931,7 +2919,7 @@ function TaskCardComponent({ || showOversightBadge; const hasHeaderBadges = Boolean(isPaused) || Boolean(!isPaused && visualStatus && visualStatus !== "queued") - || planReviewRunning + || (planReviewRunning && isAgentActive) || Boolean(!isPaused && task.column === "todo" && !visualStatus && (task.steps?.length ?? 0) > 0) || Boolean(hasInReviewStall && stallCopy) || cliWaitingOnInput @@ -3049,7 +3037,7 @@ function TaskCardComponent({ )} {!isPaused && visualStatus && visualStatus !== "queued" && ( )} - {planReviewRunning && ( + {planReviewRunning && isAgentActive && ( /* FNXC:TaskCardPlanReviewBadge 2026-07-11-12:06: The Reviewing badge is additive to the normal header status badge so operators can distinguish "planning" from active Plan Review without hiding paused/stuck/status affordances. diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 978bc43088..1f54c0d7da 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -1992,61 +1992,71 @@ describe("ListView", () => { } }); - it("shows the Reviewing badge in the desktop table status cell while Plan Review runs", () => { + it("keeps the desktop border and Reviewing badge active for a status-null running Plan Review", () => { const tasks = [ createMockTask({ id: "FN-7831", - status: "planning", + status: null as any, enabledWorkflowSteps: ["plan-review"], - workflowStepResults: [ - { - workflowStepId: "plan-review", - workflowStepName: "Plan Review", - status: "pending", - startedAt: "2026-07-11T12:00:00.000Z", - }, - ], + workflowStepResults: [{ + workflowStepId: "plan-review", + workflowStepName: "Plan Review", + status: "pending", + startedAt: "2026-07-11T12:00:00.000Z", + }], } as Partial), ]; renderListView({ tasks }); - const row = screen.getByText("FN-7831").closest("tr"); - expect(row).not.toBeNull(); - expect(within(row as HTMLElement).getByText("Reviewing")).toBeInTheDocument(); - expect(within(row as HTMLElement).getByText("planning")).toBeInTheDocument(); + const row = screen.getByText("FN-7831").closest("tr") as HTMLElement; + expect(row.className).toContain("agent-active"); + const badge = within(row).getByText("Reviewing"); + expect(badge.className).toContain("pulsing"); }); - it("shows the Reviewing badge in grouped mobile cards while Plan Review runs", () => { + it("keeps the mobile border and Reviewing badge active for a status-null running Plan Review", () => { const matchMediaSpy = mockMobileViewport(); try { const tasks = [ createMockTask({ id: "FN-7831", - status: "planning", + status: null as any, enabledWorkflowSteps: ["plan-review"], - workflowStepResults: [ - { - workflowStepId: "plan-review", - workflowStepName: "Plan Review", - status: "pending", - startedAt: "2026-07-11T12:00:00.000Z", - }, - ], + workflowStepResults: [{ + workflowStepId: "plan-review", + workflowStepName: "Plan Review", + status: "pending", + startedAt: "2026-07-11T12:00:00.000Z", + }], } as Partial), ]; renderListView({ tasks }); - const card = screen.getByText("FN-7831").closest(".list-card"); - expect(card).not.toBeNull(); - expect(within(card as HTMLElement).getByText("Reviewing")).toBeInTheDocument(); - expect(within(card as HTMLElement).getByText("planning")).toBeInTheDocument(); + const card = screen.getByText("FN-7831").closest(".list-card") as HTMLElement; + expect(card.className).toContain("agent-active"); + expect(within(card).getByText("Reviewing").className).toContain("pulsing"); } finally { matchMediaSpy.mockRestore(); } }); + it("turns off the desktop border and Reviewing pulse when globally paused", () => { + const tasks = [createMockTask({ + id: "FN-8055-paused", + status: null as any, + enabledWorkflowSteps: ["plan-review"], + workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending", startedAt: "2026-07-16T00:00:00.000Z" }], + } as Partial)]; + + renderListView({ tasks, globalPaused: true }); + + const row = screen.getByText("FN-8055-paused").closest("tr") as HTMLElement; + expect(row.className).not.toContain("agent-active"); + expect(within(row).queryByText("Reviewing")).toBeNull(); + }); + it("does not show the Reviewing badge after Plan Review completes", () => { const tasks = [ createMockTask({ diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 50813edcef..e57e872a78 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -2220,6 +2220,50 @@ describe("TaskCard", () => { } }); + it("keeps the card border and Reviewing badge in agreement for a status-null running Plan Review", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".card")?.className).toContain("agent-active"); + expect(container.querySelector('[data-testid="card-reviewing-FN-8055"]')?.className).toContain("pulsing"); + }); + + it("turns off both border and Reviewing pulse when the render queue gate is active", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".card")?.className).not.toContain("agent-active"); + expect(container.querySelector('[data-testid="card-reviewing-FN-8055-queued"]')).toBeNull(); + }); + it("renders the status badge after the card ID in DOM order", () => { const { container } = render( = {}): Task { + return { + id: "FN-8055", + title: "Activity fixture", + description: "Activity fixture", + column: "triage", + status: null, + steps: [], + enabledWorkflowSteps: [], + workflowStepResults: [], + createdAt: "2026-07-16T00:00:00.000Z", + updatedAt: "2026-07-16T00:00:00.000Z", + ...overrides, + } as Task; +} + +function taskWithRunningWorkflowStep(overrides: Partial = {}): Task { + return makeTask({ + enabledWorkflowSteps: ["plan-review"], + workflowStepResults: [{ + workflowStepId: "plan-review", + workflowStepName: "Plan Review", + status: "pending", + startedAt: "2026-07-16T00:00:00.000Z", + }], + ...overrides, + }); +} + +describe("isTaskAgentActive", () => { + it("uses the canonical set for every active phase", () => { + expect([...ACTIVE_STATUSES]).toEqual([ + "planning", "researching", "executing", "finalizing", "merging", "merging-pr", "merging-fix", "reviewing", "landing", + ]); + for (const status of ACTIVE_STATUSES) { + expect(isTaskAgentActive(makeTask({ status }))).toBe(true); + } + }); + + it("recognizes an in-progress task and status-null running workflow step", () => { + expect(isTaskAgentActive(makeTask({ column: "in-progress" }))).toBe(true); + expect(isTaskAgentActive(taskWithRunningWorkflowStep())).toBe(true); + }); + + it("does not treat a status-null task without a running item as active", () => { + expect(isTaskAgentActive(makeTask())).toBe(false); + }); + + it.each([ + ["queued task status", taskWithRunningWorkflowStep({ status: "queued" }), {}], + ["paused status", taskWithRunningWorkflowStep({ status: "paused" }), {}], + ["paused task", taskWithRunningWorkflowStep({ paused: true }), {}], + ["failed status", taskWithRunningWorkflowStep({ status: "failed" }), {}], + ["stuck-killed status", taskWithRunningWorkflowStep({ column: "in-progress", status: "stuck-killed" }), {}], + ["awaiting approval", taskWithRunningWorkflowStep({ status: "awaiting-approval" }), {}], + ["awaiting user input", taskWithRunningWorkflowStep({ status: "awaiting-user-input" }), {}], + ["done status", taskWithRunningWorkflowStep({ status: "done" }), {}], + ["done column", taskWithRunningWorkflowStep({ column: "done" }), {}], + ["archived column with merging status", taskWithRunningWorkflowStep({ column: "archived", status: "merging" }), {}], + ["archived column with running workflow", taskWithRunningWorkflowStep({ column: "archived" }), {}], + ["render queue", taskWithRunningWorkflowStep(), { queued: true }], + ["derived stuck", taskWithRunningWorkflowStep(), { isStuck: true }], + ["global pause", taskWithRunningWorkflowStep(), { globalPaused: true }], + ] as const)("rejects %s before running workflow activity", (_name, task, options) => { + expect(isTaskAgentActive(task, options)).toBe(false); + }); +}); diff --git a/packages/dashboard/app/utils/taskActivity.ts b/packages/dashboard/app/utils/taskActivity.ts new file mode 100644 index 0000000000..990c3bf992 --- /dev/null +++ b/packages/dashboard/app/utils/taskActivity.ts @@ -0,0 +1,59 @@ +import type { Task } from "@fusion/core"; +import { getUnifiedTaskProgress } from "./taskProgress"; + +/** The shared status vocabulary for active task phases and lock/model policy. */ +export const ACTIVE_STATUSES = new Set([ + "planning", + "researching", + "executing", + "finalizing", + "merging", + "merging-pr", + "merging-fix", + "reviewing", + "landing", +]); + +export interface TaskAgentActivityOptions { + globalPaused?: boolean; + queued?: boolean; + isStuck?: boolean; +} + +/* +FNXC:TaskActivity 2026-07-16-00:00: +FN-8055 makes the agent-active border and pulsing badges represent the same ground truth: an agent is working now. Reject render-context global pause, queue, and derived freshness-stuck gates before checking activity, then combine the engine's column-aware active window with canonical phase statuses and the running unified workflow item that drives progress badges. + +Stuck-killed and both terminal columns are never active, even when stale execution status or workflow-step data remains on the task. + +Model-resolution and routing locks intentionally import only ACTIVE_STATUSES and retain their status-or-in-progress policy; using this rendering predicate there would change lock behavior during status-null workflow steps. +*/ +export function isTaskAgentActive( + task: Pick, + options: TaskAgentActivityOptions = {}, +): boolean { + const status = task.status; + + if ( + options.globalPaused === true || + options.queued === true || + options.isStuck === true || + status === "queued" || + status === "stuck-killed" || + task.paused === true || + task.userPaused === true || + status === "paused" || + status === "failed" || + status === "awaiting-approval" || + status === "awaiting-user-input" || + task.column === "done" || + task.column === "archived" || + status === "done" + ) { + return false; + } + + return task.column === "in-progress" || + ACTIVE_STATUSES.has(status ?? "") || + getUnifiedTaskProgress(task).items.some((item) => item.status === "running"); +}