FN-8055: align active-agent indicators with task activity
Keep board and list activity styling synchronized with actual agent work. - Centralize task activity detection across cards, lists, routing, and model-resolution surfaces. - Show running workflow activity while suppressing stale, paused, stuck-killed, and terminal task states. - Cover status-null workflow activity and terminal-state regressions. Files changed: packages/dashboard/app/components/ListView.tsx | 34 ++--------- packages/dashboard/app/components/RoutingTab.tsx | 17 +----- packages/dashboard/app/components/TaskCard.tsx | 28 +++------ .../app/components/__tests__/ListView.test.tsx | 66 +++++++++++--------- .../app/components/__tests__/TaskCard.test.tsx | 44 ++++++++++++++ .../app/components/effective-model-resolution.ts | 18 +----- .../app/utils/__tests__/taskActivity.test.ts | 71 ++++++++++++++++++++++ packages/dashboard/app/utils/taskActivity.ts | 59 ++++++++++++++++++ 8 files changed, 228 insertions(+), 109 deletions(-) Fusion-Task-Id: FN-8055 Fusion-Task-Lineage: 9ce38d60-06c8-4050-83f4-263e2f765c86 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -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<string, string>)[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)}
|
||||
</span>
|
||||
) : 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({
|
||||
) : (
|
||||
<span className="list-status-badge">-</span>
|
||||
)}
|
||||
{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.
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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<ColumnId> = new Set<ColumnId>(["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<Column, string> = {
|
||||
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" && (
|
||||
<span
|
||||
className={`card-status-badge card-status-badge--${task.column}${isAwaitingApproval ? " awaiting-approval" : ""}${isPlanReviewReplanCapApproval ? " awaiting-approval--plan-review-replan-cap" : ""}${isAwaitingInput ? " awaiting-input" : ""}${ACTIVE_STATUSES.has(visualStatus) ? " pulsing" : ""}${isFailed ? " failed" : ""}${isStuck ? " stuck" : ""}`}
|
||||
className={`card-status-badge card-status-badge--${task.column}${isAwaitingApproval ? " awaiting-approval" : ""}${isPlanReviewReplanCapApproval ? " awaiting-approval--plan-review-replan-cap" : ""}${isAwaitingInput ? " awaiting-input" : ""}${isAgentActive ? " pulsing" : ""}${isFailed ? " failed" : ""}${isStuck ? " stuck" : ""}`}
|
||||
title={
|
||||
isPlanReviewReplanCapApproval
|
||||
? t(
|
||||
@@ -3079,7 +3067,7 @@ function TaskCardComponent({
|
||||
: getTaskStatusLabel(visualStatus, t)}
|
||||
</span>
|
||||
)}
|
||||
{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.
|
||||
|
||||
@@ -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<Task>),
|
||||
];
|
||||
|
||||
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<Task>),
|
||||
];
|
||||
|
||||
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<Task>)];
|
||||
|
||||
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({
|
||||
|
||||
@@ -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(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-8055",
|
||||
column: "triage",
|
||||
status: null as any,
|
||||
enabledWorkflowSteps: ["plan-review"],
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "plan-review",
|
||||
workflowStepName: "Plan Review",
|
||||
status: "pending",
|
||||
startedAt: "2026-07-16T00:00:00.000Z",
|
||||
}],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-8055-queued",
|
||||
column: "triage",
|
||||
status: null as any,
|
||||
enabledWorkflowSteps: ["plan-review"],
|
||||
workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending", startedAt: "2026-07-16T00:00:00.000Z" }],
|
||||
})}
|
||||
queued
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskCard
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import type { Agent, AgentLogEntry, ResolvedModelSelection, Settings, Task, TaskDetail } from "@fusion/core";
|
||||
import { resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
|
||||
import { ACTIVE_STATUSES } from "../utils/taskActivity";
|
||||
|
||||
export type ModelSelection = ResolvedModelSelection;
|
||||
|
||||
/*
|
||||
FNXC:MergeQueue 2026-07-15-10:40:
|
||||
Treat AI-merge reviewing/landing as active so model/resolution surfaces and cards stay in the live-agent visual state while the merger owns the pump.
|
||||
*/
|
||||
export const ACTIVE_STATUSES = new Set([
|
||||
"planning",
|
||||
"researching",
|
||||
"executing",
|
||||
"finalizing",
|
||||
"merging",
|
||||
"merging-pr",
|
||||
"merging-fix",
|
||||
"reviewing",
|
||||
"landing",
|
||||
]);
|
||||
export { ACTIVE_STATUSES };
|
||||
|
||||
const STRING_OBJECT_TAG = "[object String]";
|
||||
|
||||
|
||||
71
packages/dashboard/app/utils/__tests__/taskActivity.test.ts
Normal file
71
packages/dashboard/app/utils/__tests__/taskActivity.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { ACTIVE_STATUSES, isTaskAgentActive } from "../taskActivity";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): 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> = {}): 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);
|
||||
});
|
||||
});
|
||||
59
packages/dashboard/app/utils/taskActivity.ts
Normal file
59
packages/dashboard/app/utils/taskActivity.ts
Normal file
@@ -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<Task, "column" | "status" | "paused" | "userPaused" | "steps" | "enabledWorkflowSteps" | "workflowStepResults">,
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user