diff --git a/.changeset/fn-8494-replan-active-glow.md b/.changeset/fn-8494-replan-active-glow.md
new file mode 100644
index 0000000000..7d75e9c7c9
--- /dev/null
+++ b/.changeset/fn-8494-replan-active-glow.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Keep task-card active glow during replan and revise while agents work.
+category: fix
+dev: isTaskAgentActive treats needs-replan (and plan-in-place replan freshness) as agent-active for board/list chrome; lock policy documented in taskActivity FNXC.
diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx
index 6e83783a76..21dd81c0a2 100644
--- a/packages/dashboard/app/components/__tests__/ListView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx
@@ -2248,6 +2248,8 @@ describe("ListView", () => {
it.each([
{ status: "executing", column: "in-progress" as const, label: "executing" },
{ status: "merging-fix", column: "in-review" as const, label: "Merging fixes…" },
+ { status: "needs-replan", column: "triage" as const, label: "Revising" },
+ { status: "needs-replan", column: "todo" as const, label: "Revising" },
])("renders agent-active tasks with static highlight styling for $status", ({ status, column, label }) => {
const tasks = [
createMockTask({
@@ -5378,6 +5380,8 @@ describe("ListView - Bulk Selection", () => {
it.each([
{ status: "executing", column: "in-progress" as const },
{ status: "merging-fix", column: "in-review" as const },
+ { status: "needs-replan", column: "triage" as const },
+ { status: "needs-replan", column: "todo" as const },
])("applies agent-active class to mobile cards for active states (%s)", ({ status, column }) => {
mockMobileViewport();
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index df64e5f13d..c1dcaea267 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -2651,6 +2651,37 @@ describe("TaskCard", () => {
expect(container.querySelector(".card-status-badge")).toBeNull();
});
+ it("keeps board replan cards glowing and their status badge pulsing", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector(".card")).toHaveClass("agent-active");
+ expect(screen.getByText("Revising")).toHaveClass("card-status-badge", "pulsing");
+ });
+
+ it.each([
+ ["global pause", { globalPaused: true }, {}],
+ ["render queue", { queued: true }, {}],
+ ["task pause", {}, { paused: true }],
+ ])("suppresses board replan activity during $name", (_name, props, taskOverrides) => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector(".card")).not.toHaveClass("agent-active");
+ expect(container.querySelector(".card-status-badge")).not.toHaveClass("pulsing");
+ });
+
/*
* FNXC:ReleaseAuthorizationGate 2026-07-09-00:00: the triage release-authorization
* gate was removed. A legacy release-authorization hold now renders the generic
diff --git a/packages/dashboard/app/utils/__tests__/taskActivity.test.ts b/packages/dashboard/app/utils/__tests__/taskActivity.test.ts
index e07cbe2799..a02b34588b 100644
--- a/packages/dashboard/app/utils/__tests__/taskActivity.test.ts
+++ b/packages/dashboard/app/utils/__tests__/taskActivity.test.ts
@@ -48,6 +48,12 @@ describe("isTaskAgentActive", () => {
expect(isTaskAgentActive(taskWithRunningWorkflowStep())).toBe(true);
});
+ it("keeps durable replan cards active without changing lock statuses", () => {
+ expect(ACTIVE_STATUSES.has("needs-replan")).toBe(false);
+ expect(isTaskAgentActive(makeTask({ status: "needs-replan", column: "triage" }))).toBe(true);
+ expect(isTaskAgentActive(makeTask({ status: "needs-replan", column: "todo" }))).toBe(true);
+ });
+
it("does not treat a status-null task without a running item as active", () => {
expect(isTaskAgentActive(makeTask())).toBe(false);
});
@@ -64,6 +70,17 @@ describe("isTaskAgentActive", () => {
}))).toBe(false);
});
+ it("extends fresh planner activity to plan-in-place todo replans", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-07-22T09:25:30.000Z"));
+
+ expect(isTaskAgentActive(makeTask({
+ column: "todo",
+ status: "needs-replan",
+ recentAgentActivityAt: "2026-07-22T09:25:00.000Z",
+ }))).toBe(true);
+ });
+
it.each([
["queued task status", taskWithRunningWorkflowStep({ status: "queued" }), {}],
["paused status", taskWithRunningWorkflowStep({ status: "paused" }), {}],
@@ -85,6 +102,12 @@ describe("isTaskAgentActive", () => {
["archived column with fresh planner log", makeTask({ column: "archived", recentAgentActivityAt: new Date().toISOString() }), {}],
["awaiting approval with fresh planner log", makeTask({ status: "awaiting-approval", recentAgentActivityAt: new Date().toISOString() }), {}],
["awaiting user input with fresh planner log", makeTask({ status: "awaiting-user-input", recentAgentActivityAt: new Date().toISOString() }), {}],
+ ["queued replan", makeTask({ status: "needs-replan", recentAgentActivityAt: new Date().toISOString() }), { queued: true }],
+ ["derived stuck replan", makeTask({ status: "needs-replan", recentAgentActivityAt: new Date().toISOString() }), { isStuck: true }],
+ ["global pause replan", makeTask({ status: "needs-replan", recentAgentActivityAt: new Date().toISOString() }), { globalPaused: true }],
+ ["paused replan", makeTask({ status: "needs-replan", paused: true, recentAgentActivityAt: new Date().toISOString() }), {}],
+ ["failed replan", makeTask({ status: "failed", recentAgentActivityAt: new Date().toISOString() }), {}],
+ ["done-column replan", makeTask({ column: "done", status: "needs-replan", recentAgentActivityAt: new Date().toISOString() }), {}],
] 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
index ef76c30c1e..47bc2281a8 100644
--- a/packages/dashboard/app/utils/taskActivity.ts
+++ b/packages/dashboard/app/utils/taskActivity.ts
@@ -29,6 +29,9 @@ FN-8055 makes the agent-active border and pulsing badges represent the same grou
FNXC:TaskActivity 2026-07-28-12:00:
FN-8300 also honors a bounded, client-only fresh planner-log timestamp for triage cards. The log stream can arrive before the authoritative planning-status row; this render-only fallback closes that window without changing routing/model locks.
+FNXC:TaskActivity 2026-07-22-09:25:
+FN-8494 requires cards parked in the engine's durable `needs-replan` planning stage to keep their activity chrome on both triage and plan-in-place todo lanes. This is rendering-only: do not add `needs-replan` to ACTIVE_STATUSES, because model and routing pickers use that set as a long-lived lock policy while this predicate only describes live operator chrome. Extend the bounded fresh-log window to the todo replan lane so an incoming planner log remains represented consistently there.
+
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.
@@ -58,15 +61,17 @@ export function isTaskAgentActive(
return false;
}
+ const isReplanning = status === "needs-replan";
const recentPlannerActivityAtMs = Date.parse(task.recentAgentActivityAt ?? "");
const nowMs = Date.now();
- const hasFreshPlannerActivity = task.column === "triage"
+ const hasFreshPlannerActivity = (task.column === "triage" || (task.column === "todo" && isReplanning))
&& Number.isFinite(recentPlannerActivityAtMs)
&& nowMs - recentPlannerActivityAtMs >= 0
&& nowMs - recentPlannerActivityAtMs <= RECENT_PLANNER_ACTIVITY_WINDOW_MS;
return task.column === "in-progress" ||
ACTIVE_STATUSES.has(status ?? "") ||
+ isReplanning ||
hasFreshPlannerActivity ||
getUnifiedTaskProgress(task).items.some((item) => item.status === "running");
}