FN-8494: keep task cards active while replanning

Keep task-card activity chrome visible throughout durable and fresh replanning states.

- Treat needs-replan tasks as agent-active on triage and todo lanes without changing lock policy.
- Cover board, list, mobile, pause, and freshness behaviors with regression tests.
- Add a patch changeset for the replanning activity indicator.

Files changed:
 .changeset/fn-8494-replan-active-glow.md           |  7 +++++
 .../app/components/__tests__/ListView.test.tsx     |  4 +++
 .../app/components/__tests__/TaskCard.test.tsx     | 31 ++++++++++++++++++++++
 .../app/utils/__tests__/taskActivity.test.ts       | 23 ++++++++++++++++
 packages/dashboard/app/utils/taskActivity.ts       |  7 ++++-
 5 files changed, 71 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-8494
Fusion-Task-Lineage: a910a22a-cff7-423f-82ee-359830beb104
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-22 13:56:53 -07:00
parent d5df6fc635
commit b6135f4bd5
5 changed files with 71 additions and 1 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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(
<TaskCard
task={makeTask({ id: "FN-8494-board", column: "triage", status: "needs-replan" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard
task={makeTask({ id: `FN-8494-${_name}`, column: "triage", status: "needs-replan", ...taskOverrides })}
onOpenDetail={noop}
addToast={noop}
{...props}
/>,
);
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

View File

@@ -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);
});

View File

@@ -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");
}