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:
7
.changeset/fn-8494-replan-active-glow.md
Normal file
7
.changeset/fn-8494-replan-active-glow.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user