FN-8300: show planner activity on status-null cards

Keep planning cards visibly active while fresh planner logs precede authoritative task status updates.

- Track bounded client-only planner activity from fresh triage log events
- Render matching pulsing Planning badges in board and list card views
- Cover transient activity, authoritative clearing, and inactive edge cases

Files changed:
 docs/dashboard-guide.md                            |  2 +
 packages/core/src/types.ts                         |  6 +++
 packages/dashboard/app/components/ListView.tsx     | 26 ++++++++---
 packages/dashboard/app/components/TaskCard.tsx     | 20 +++++---
 .../app/components/__tests__/ListView.test.tsx     | 38 +++++++++++++++
 .../app/components/__tests__/TaskCard.test.tsx     | 19 +++++++-
 .../dashboard/app/hooks/__tests__/useTasks.test.ts | 54 ++++++++++++++++++++++
 packages/dashboard/app/hooks/useTasks.ts           | 44 +++++++++++++-----
 .../app/utils/__tests__/taskActivity.test.ts       | 22 ++++++++-
 packages/dashboard/app/utils/taskActivity.ts       | 15 +++++-
 10 files changed, 219 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-8300

Fusion-Task-Lineage: e12f1277-5628-45a1-b731-54310027540e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 12:13:20 -07:00
parent 3568a86a66
commit 9debeaa951
10 changed files with 219 additions and 27 deletions

View File

@@ -224,6 +224,8 @@ Features:
- Inline quick entry creation
- The quick-entry GitHub icon is a per-task tracking override: leave it untouched to use the project default, turn it on to opt the next task into tracking when the default is off, or turn it off to opt the next task out when the default is on.
- PR/issue badges with live updates
- Planning cards and List rows/cards show the same active border and pulsing **Planning** badge when fresh planner activity reaches the live log stream, including the brief status-null transition before the authoritative task row refreshes. The transient indicator clears on that authoritative refresh, so completed planning does not remain active.
<!-- FNXC:TaskActivity 2026-07-28-12:00: FN-8300 requires visual card activity to agree with fresh planner logs during status-null planning transitions; Board and List reuse their existing active affordances. -->
- GitLab tracking badges on task cards for linked GitLab project issues, group issues, and merge requests; stale GitLab metadata uses a warning-colored badge while GitHub badges remain unchanged.
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata
- Task card header meta badges group priority and fast mode in the header; priority badges include the shared urgency glyph/color language (low blue/info, high amber/warning, urgent red/error) while agent-created provenance renders in a dedicated bottom-left row ahead of workflow identity so the ID/status/actions header does not wrap on narrow cards. Agent labels prefer `sourceMetadata.agentName` over raw agent IDs.

View File

@@ -1443,6 +1443,12 @@ export interface Task {
*/
customFields?: Record<string, unknown>;
status?: string;
/**
* FNXC:TaskActivity 2026-07-28-12:00:
* Dashboard-only signal from a fresh planner agent-log SSE entry. It is never
* persisted or sent to the server; authoritative task updates clear it.
*/
recentAgentActivityAt?: string;
/** ID of the in-progress task whose file scope overlaps with this task,
* causing the scheduler to defer it. Set when the scheduler queues
* the task due to file-scope overlap; cleared (set to `undefined`)

View File

@@ -2669,9 +2669,13 @@ export function ListView({
const isPaused = !isDoneColumn && task.paused === true;
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState });
// FNXC:TaskStatusBadge 2026-07-16-12:00: FN-8170 keeps mobile and table list status rendering aligned with TaskCard through the shared Todo/In Progress planning suppression predicate.
const hasStatus = typeof visualStatus === "string"
&& visualStatus.trim().length > 0
// FNXC:TaskStatusBadge 2026-07-28-12:00: FN-8300 renders the same transient Planning badge as TaskCard so fresh planner logs never make grouped-list cards appear idle.
const isTransientPlannerActive = task.column === "triage"
&& !visualStatus
&& Boolean(task.recentAgentActivityAt)
&& isAgentActive;
const hasStatus = (typeof visualStatus === "string" && visualStatus.trim().length > 0
|| isTransientPlannerActive)
&& !shouldSuppressPlanningStatusBadge({ status: visualStatus, column: task.column });
const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task);
const planReviewRunning = isPlanReviewRunning(task);
@@ -2732,11 +2736,14 @@ export function ListView({
<span
className={`list-status-badge list-status-badge--${task.column}${isReviewBudgetExhausted ? " list-status-badge--review-budget-exhausted" : ""}${isFailed ? " failed" : ""}${isAgentActive ? " pulsing" : ""}`}
title={isReviewBudgetExhausted ? t("tasks.awaitingApprovalPlanReviewReplanCapTitle", "Plan Review requested revisions repeatedly without converging. Approve the current plan to proceed, or reject to regenerate it.") : undefined}
aria-label={isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : undefined}
data-testid={isReviewBudgetExhausted ? `list-review-budget-exhausted-${task.id}` : undefined}
>
{isReviewBudgetExhausted
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
: getTaskStatusLabel(visualStatus ?? "", t)}
: isTransientPlannerActive
? t("tasks.statusPlanning", "Planning")
: getTaskStatusLabel(visualStatus ?? "", t)}
</span>
) : null}
{planReviewRunning && isAgentActive && (
@@ -2886,7 +2893,11 @@ export function ListView({
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState });
const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task);
const showStatusBadge = Boolean(visualStatus)
const isTransientPlannerActive = task.column === "triage"
&& !visualStatus
&& Boolean(task.recentAgentActivityAt)
&& isAgentActive;
const showStatusBadge = (Boolean(visualStatus) || isTransientPlannerActive)
&& !shouldSuppressPlanningStatusBadge({ status: visualStatus, column: task.column });
const planReviewRunning = isPlanReviewRunning(task);
const isDragging = draggingTaskId === task.id;
@@ -2958,11 +2969,14 @@ export function ListView({
isAgentActive ? " pulsing" : ""
}`}
title={isReviewBudgetExhausted ? t("tasks.awaitingApprovalPlanReviewReplanCapTitle", "Plan Review requested revisions repeatedly without converging. Approve the current plan to proceed, or reject to regenerate it.") : undefined}
aria-label={isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : undefined}
data-testid={isReviewBudgetExhausted ? `list-review-budget-exhausted-${task.id}` : undefined}
>
{isReviewBudgetExhausted
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
: getTaskStatusLabel(visualStatus ?? "", t)}
: isTransientPlannerActive
? t("tasks.statusPlanning", "Planning")
: getTaskStatusLabel(visualStatus ?? "", t)}
</span>
) : (
<span className="list-status-badge">-</span>

View File

@@ -780,6 +780,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.updatedAt === nextTask.updatedAt &&
previousTask.createdAt === nextTask.createdAt &&
previousTask.status === nextTask.status &&
previousTask.recentAgentActivityAt === nextTask.recentAgentActivityAt &&
previousTask.priority === nextTask.priority &&
previousTask.executionMode === nextTask.executionMode &&
previousTask.paused === nextTask.paused &&
@@ -2884,11 +2885,15 @@ function TaskCardComponent({
* card. The oversight-level badge (`showOversightBadge`) is untouched.
*/
/*
FNXC:TaskStatusBadge 2026-07-16-12:00:
FN-8170 shares this predicate with ListView so stale planning status never produces a Todo/In Progress board badge or its otherwise-empty header wrapper.
FNXC:TaskStatusBadge 2026-07-28-12:00:
FN-8300 keeps Planning cards visually consistent with their fresh planner-log timeline: a transient client signal renders the existing pulsing Planning badge even while the authoritative status is null. ListView uses the same condition on both render paths.
*/
const isTransientPlannerActive = task.column === "triage"
&& !visualStatus
&& Boolean(task.recentAgentActivityAt)
&& isAgentActive;
const showStatusBadge = !isPaused
&& Boolean(visualStatus)
&& (Boolean(visualStatus) || isTransientPlannerActive)
&& visualStatus !== "queued"
&& !shouldSuppressPlanningStatusBadge({ status: visualStatus, column: task.column });
const hasCardMetaBadges = showPriorityBadge
@@ -3046,6 +3051,7 @@ function TaskCardComponent({
)
: undefined
}
aria-label={isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : undefined}
data-testid={isAwaitingApproval ? `card-awaiting-approval-${task.id}` : undefined}
data-awaiting-approval-reason={isAwaitingApproval ? (task.awaitingApprovalReason ?? "manual") : undefined}
>
@@ -3057,9 +3063,11 @@ function TaskCardComponent({
? t("tasks.awaitingApproval", "Awaiting Approval")
: isAwaitingInput
? t("tasks.needsInput", "Needs input")
: visualStatus === "merging-fix"
? t("tasks.statusMergingFix", "Merging fixes…")
: getTaskStatusLabel(visualStatus!, t)}
: isTransientPlannerActive
? t("tasks.statusPlanning", "Planning")
: visualStatus === "merging-fix"
? t("tasks.statusMergingFix", "Merging fixes…")
: getTaskStatusLabel(visualStatus!, t)}
</span>
)}
{planReviewRunning && isAgentActive && (

View File

@@ -460,6 +460,44 @@ describe("ListView", () => {
viewportSpy.mockRestore();
});
it("renders the active Planning badge for a fresh status-null triage card in grouped mobile cards", () => {
const viewportSpy = mockMobileViewport();
try {
renderListView({
tasks: [createMockTask({
id: "FN-8300-mobile",
status: null as any,
recentAgentActivityAt: new Date().toISOString(),
})],
});
const card = screen.getByText("FN-8300-mobile").closest(".list-card") as HTMLElement;
expect(card).toHaveClass("agent-active");
expect(within(card).getByLabelText("Planning")).toHaveClass("list-status-badge", "pulsing");
} finally {
viewportSpy.mockRestore();
}
});
it("renders the active Planning badge for a fresh status-null triage card in desktop table rows", () => {
const viewportSpy = mockDesktopViewport();
try {
renderListView({
tasks: [createMockTask({
id: "FN-8300-desktop",
status: null as any,
recentAgentActivityAt: new Date().toISOString(),
})],
});
const row = screen.getByText("FN-8300-desktop").closest("tr") as HTMLElement;
expect(row).toHaveClass("agent-active");
expect(within(row).getByLabelText("Planning")).toHaveClass("list-status-badge", "pulsing");
} finally {
viewportSpy.mockRestore();
}
});
it("falls back malformed task columns to Planning group instead of crashing", () => {
const malformedTask = {
...createMockTask({ id: "FN-404" }),

View File

@@ -2456,10 +2456,25 @@ describe("TaskCard", () => {
expect(headerBadges.contains(badge)).toBe(true);
});
it("does not render a status badge when task.status is falsy", () => {
it("renders an active Planning badge when a status-null triage card has fresh planner activity", () => {
const recentAgentActivityAt = new Date().toISOString();
const { container } = render(
<TaskCard task={makeTask({ status: undefined as any })} onOpenDetail={noop} addToast={noop} />,
<TaskCard
task={makeTask({ column: "triage", status: null as any, recentAgentActivityAt })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card")).toHaveClass("agent-active");
expect(screen.getByLabelText("Planning")).toHaveClass("card-status-badge", "pulsing");
});
it("does not render a status badge when a status-null triage card has no fresh planner activity", () => {
const { container } = render(
<TaskCard task={makeTask({ column: "triage", status: undefined as any })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card")).not.toHaveClass("agent-active");
expect(container.querySelector(".card-status-badge")).toBeNull();
});

View File

@@ -3676,6 +3676,60 @@ describe("useTasks", () => {
vi.useRealTimers();
});
it("marks fresh planner logs transiently active and clears the signal on an authoritative update", async () => {
const initialTask = createMockTask({
column: "triage",
status: null,
updatedAt: "2026-07-28T12:00:00.000Z",
});
mockFetchTasks.mockResolvedValueOnce([initialTask]);
const { result } = renderHook(() => useTasks());
await waitFor(() => expect(result.current.tasks).toHaveLength(1));
act(() => {
MockEventSource.instances[0]._emit("agent:log", {
taskId: initialTask.id,
timestamp: "2026-07-28T12:00:01.000Z",
type: "tool",
agent: "triage",
});
});
expect(result.current.tasks[0]?.recentAgentActivityAt).toBe("2026-07-28T12:00:01.000Z");
act(() => {
MockEventSource.instances[0]._emit("task:updated", {
...initialTask,
updatedAt: "2026-07-28T12:00:02.000Z",
});
});
expect(result.current.tasks[0]?.recentAgentActivityAt).toBeUndefined();
});
it("keeps clearing in-review stalls when a fresh agent log arrives", async () => {
const initialTask = createMockTask({
column: "in-review",
inReviewStall: {
code: "merge-blocker",
reason: "Merge is blocked",
observedAt: "2026-07-28T12:00:00.000Z",
},
updatedAt: "2026-07-28T12:00:00.000Z",
});
mockFetchTasks.mockResolvedValueOnce([initialTask]);
const { result } = renderHook(() => useTasks());
await waitFor(() => expect(result.current.tasks).toHaveLength(1));
act(() => {
MockEventSource.instances[0]._emit("agent:log", {
taskId: initialTask.id,
timestamp: "2026-07-28T12:00:01.000Z",
type: "text",
agent: "reviewer",
});
});
expect(result.current.tasks[0]?.inReviewStall).toBeUndefined();
});
it("does not trigger onReconnect refetch after sseEnabled flips to false", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const { rerender } = renderHook(

View File

@@ -32,17 +32,17 @@ function filterActiveTasks(tasks: Task[]): Task[] {
type AgentLogActivityEvent = Pick<AgentLogEntry, "taskId" | "timestamp" | "type" | "agent">;
function clearInReviewStallForFreshAgentLog(task: Task, entry: AgentLogActivityEvent): Task {
if (task.id !== entry.taskId || task.column !== "in-review") return task;
function hasFreshAgentLog(task: Task, entry: AgentLogActivityEvent): boolean {
if (task.id !== entry.taskId) return false;
const logTimestampMs = Date.parse(entry.timestamp);
const taskUpdatedAtMs = Date.parse(task.updatedAt);
if (
Number.isFinite(logTimestampMs) &&
Number.isFinite(taskUpdatedAtMs) &&
logTimestampMs <= taskUpdatedAtMs
) {
return task;
}
return Number.isFinite(logTimestampMs)
&& Number.isFinite(taskUpdatedAtMs)
&& logTimestampMs > taskUpdatedAtMs;
}
function clearInReviewStallForFreshAgentLog(task: Task, entry: AgentLogActivityEvent): Task {
if (task.column !== "in-review" || !hasFreshAgentLog(task, entry)) return task;
if (!task.inReviewStall && !task.inReviewStalled && !task.stalledReview) return task;
/*
@@ -57,6 +57,27 @@ function clearInReviewStallForFreshAgentLog(task: Task, entry: AgentLogActivityE
};
}
function addRecentPlannerActivityForFreshAgentLog(task: Task, entry: AgentLogActivityEvent): Task {
if (
task.column !== "triage"
|| task.status === "planning"
|| entry.agent !== "triage"
|| !hasFreshAgentLog(task, entry)
) {
return task;
}
/*
FNXC:TaskActivity 2026-07-28-12:00:
A Planning card's border and pulsing badge must agree with the live planner
timeline. A fresh triage log can arrive before its status row, so retain this
client-only render signal until an authoritative task update replaces the row.
*/
return task.recentAgentActivityAt === entry.timestamp
? task
: { ...task, recentAgentActivityAt: entry.timestamp };
}
/**
* Compare two ISO timestamp strings.
* Returns positive if a is newer than b, negative if b is newer, 0 if equal.
@@ -687,8 +708,9 @@ export function useTasks(options?: UseTasksOptions) {
let changed = false;
const next = prev.map((task) => {
const cleared = clearInReviewStallForFreshAgentLog(task, entry);
if (cleared !== task) changed = true;
return cleared;
const updated = addRecentPlannerActivityForFreshAgentLog(cleared, entry);
if (updated !== task) changed = true;
return updated;
});
return changed ? next : prev;
});

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { ACTIVE_STATUSES, isTaskAgentActive } from "../taskActivity";
@@ -32,6 +32,8 @@ function taskWithRunningWorkflowStep(overrides: Partial<Task> = {}): Task {
}
describe("isTaskAgentActive", () => {
afterEach(() => vi.useRealTimers());
it("uses the canonical set for every active phase", () => {
expect([...ACTIVE_STATUSES]).toEqual([
"planning", "researching", "executing", "finalizing", "merging", "merging-pr", "merging-fix", "reviewing", "landing",
@@ -50,6 +52,18 @@ describe("isTaskAgentActive", () => {
expect(isTaskAgentActive(makeTask())).toBe(false);
});
it("uses a fresh client-only planner log signal for a status-null triage card", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-28T12:00:30.000Z"));
expect(isTaskAgentActive(makeTask({
recentAgentActivityAt: "2026-07-28T12:00:00.000Z",
}))).toBe(true);
expect(isTaskAgentActive(makeTask({
recentAgentActivityAt: "2026-07-28T11:59:00.000Z",
}))).toBe(false);
});
it.each([
["queued task status", taskWithRunningWorkflowStep({ status: "queued" }), {}],
["paused status", taskWithRunningWorkflowStep({ status: "paused" }), {}],
@@ -65,6 +79,12 @@ describe("isTaskAgentActive", () => {
["render queue", taskWithRunningWorkflowStep(), { queued: true }],
["derived stuck", taskWithRunningWorkflowStep(), { isStuck: true }],
["global pause", taskWithRunningWorkflowStep(), { globalPaused: true }],
["failed status with fresh planner log", makeTask({ status: "failed", recentAgentActivityAt: new Date().toISOString() }), {}],
["paused task with fresh planner log", makeTask({ paused: true, recentAgentActivityAt: new Date().toISOString() }), {}],
["done column with fresh planner log", makeTask({ column: "done", 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 user input with fresh planner log", makeTask({ status: "awaiting-user-input", recentAgentActivityAt: new Date().toISOString() }), {}],
] as const)("rejects %s before running workflow activity", (_name, task, options) => {
expect(isTaskAgentActive(task, options)).toBe(false);
});

View File

@@ -14,6 +14,8 @@ export const ACTIVE_STATUSES = new Set([
"landing",
]);
export const RECENT_PLANNER_ACTIVITY_WINDOW_MS = 60_000;
export interface TaskAgentActivityOptions {
globalPaused?: boolean;
queued?: boolean;
@@ -24,12 +26,15 @@ export interface TaskAgentActivityOptions {
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.
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.
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">,
task: Pick<Task, "column" | "status" | "paused" | "userPaused" | "steps" | "enabledWorkflowSteps" | "workflowStepResults" | "recentAgentActivityAt">,
options: TaskAgentActivityOptions = {},
): boolean {
const status = task.status;
@@ -53,7 +58,15 @@ export function isTaskAgentActive(
return false;
}
const recentPlannerActivityAtMs = Date.parse(task.recentAgentActivityAt ?? "");
const nowMs = Date.now();
const hasFreshPlannerActivity = task.column === "triage"
&& Number.isFinite(recentPlannerActivityAtMs)
&& nowMs - recentPlannerActivityAtMs >= 0
&& nowMs - recentPlannerActivityAtMs <= RECENT_PLANNER_ACTIVITY_WINDOW_MS;
return task.column === "in-progress" ||
ACTIVE_STATUSES.has(status ?? "") ||
hasFreshPlannerActivity ||
getUnifiedTaskProgress(task).items.some((item) => item.status === "running");
}