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([
|
it.each([
|
||||||
{ status: "executing", column: "in-progress" as const, label: "executing" },
|
{ status: "executing", column: "in-progress" as const, label: "executing" },
|
||||||
{ status: "merging-fix", column: "in-review" as const, label: "Merging fixes…" },
|
{ 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 }) => {
|
])("renders agent-active tasks with static highlight styling for $status", ({ status, column, label }) => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
createMockTask({
|
createMockTask({
|
||||||
@@ -5378,6 +5380,8 @@ describe("ListView - Bulk Selection", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
{ status: "executing", column: "in-progress" as const },
|
{ status: "executing", column: "in-progress" as const },
|
||||||
{ status: "merging-fix", column: "in-review" 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 }) => {
|
])("applies agent-active class to mobile cards for active states (%s)", ({ status, column }) => {
|
||||||
mockMobileViewport();
|
mockMobileViewport();
|
||||||
|
|
||||||
|
|||||||
@@ -2651,6 +2651,37 @@ describe("TaskCard", () => {
|
|||||||
expect(container.querySelector(".card-status-badge")).toBeNull();
|
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
|
* FNXC:ReleaseAuthorizationGate 2026-07-09-00:00: the triage release-authorization
|
||||||
* gate was removed. A legacy release-authorization hold now renders the generic
|
* gate was removed. A legacy release-authorization hold now renders the generic
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ describe("isTaskAgentActive", () => {
|
|||||||
expect(isTaskAgentActive(taskWithRunningWorkflowStep())).toBe(true);
|
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", () => {
|
it("does not treat a status-null task without a running item as active", () => {
|
||||||
expect(isTaskAgentActive(makeTask())).toBe(false);
|
expect(isTaskAgentActive(makeTask())).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -64,6 +70,17 @@ describe("isTaskAgentActive", () => {
|
|||||||
}))).toBe(false);
|
}))).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([
|
it.each([
|
||||||
["queued task status", taskWithRunningWorkflowStep({ status: "queued" }), {}],
|
["queued task status", taskWithRunningWorkflowStep({ status: "queued" }), {}],
|
||||||
["paused status", taskWithRunningWorkflowStep({ status: "paused" }), {}],
|
["paused status", taskWithRunningWorkflowStep({ status: "paused" }), {}],
|
||||||
@@ -85,6 +102,12 @@ describe("isTaskAgentActive", () => {
|
|||||||
["archived column with fresh planner log", makeTask({ column: "archived", recentAgentActivityAt: new Date().toISOString() }), {}],
|
["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 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() }), {}],
|
["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) => {
|
] as const)("rejects %s before running workflow activity", (_name, task, options) => {
|
||||||
expect(isTaskAgentActive(task, options)).toBe(false);
|
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:
|
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.
|
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.
|
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.
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isReplanning = status === "needs-replan";
|
||||||
const recentPlannerActivityAtMs = Date.parse(task.recentAgentActivityAt ?? "");
|
const recentPlannerActivityAtMs = Date.parse(task.recentAgentActivityAt ?? "");
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const hasFreshPlannerActivity = task.column === "triage"
|
const hasFreshPlannerActivity = (task.column === "triage" || (task.column === "todo" && isReplanning))
|
||||||
&& Number.isFinite(recentPlannerActivityAtMs)
|
&& Number.isFinite(recentPlannerActivityAtMs)
|
||||||
&& nowMs - recentPlannerActivityAtMs >= 0
|
&& nowMs - recentPlannerActivityAtMs >= 0
|
||||||
&& nowMs - recentPlannerActivityAtMs <= RECENT_PLANNER_ACTIVITY_WINDOW_MS;
|
&& nowMs - recentPlannerActivityAtMs <= RECENT_PLANNER_ACTIVITY_WINDOW_MS;
|
||||||
|
|
||||||
return task.column === "in-progress" ||
|
return task.column === "in-progress" ||
|
||||||
ACTIVE_STATUSES.has(status ?? "") ||
|
ACTIVE_STATUSES.has(status ?? "") ||
|
||||||
|
isReplanning ||
|
||||||
hasFreshPlannerActivity ||
|
hasFreshPlannerActivity ||
|
||||||
getUnifiedTaskProgress(task).items.some((item) => item.status === "running");
|
getUnifiedTaskProgress(task).items.some((item) => item.status === "running");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user