FN-8798: synchronize planning status across task views

Keep board, list, and task detail planning indicators consistent with fresh lifecycle state.

- preserve authoritative task snapshots across SSE and detail refreshes
- render transient replan planning only for fresh, unpaused planner activity
- pass global pause state to every task-detail host and cover status convergence

Files changed:
 packages/dashboard/app/App.tsx                     |   2 +
 packages/dashboard/app/components/AppModals.tsx    |   3 +
 packages/dashboard/app/components/ListView.tsx     |  11 +-
 packages/dashboard/app/components/TaskCard.tsx     |   7 +-
 .../dashboard/app/components/TaskDetailModal.tsx   |  27 ++-
 .../app/components/__tests__/AppModals.test.tsx    | 201 +++++++++++++++++++--
 .../app/components/__tests__/ListView.test.tsx     |  31 ++++
 .../app/components/__tests__/TaskCard.test.tsx     |  17 ++
 .../app/components/dashboard/MainContent.tsx       |   1 +
 .../__tests__/useTasks-hydration-freshness.test.ts |  58 +++++-
 .../dashboard/app/hooks/__tests__/useTasks.test.ts |  29 ++-
 packages/dashboard/app/hooks/useTasks.ts           |  51 +++++-
 .../utils/__tests__/taskStatusBadgeLabel.test.ts   |  22 ++-
 .../dashboard/app/utils/taskStatusBadgeLabel.ts    |  36 ++++
 14 files changed, 453 insertions(+), 43 deletions(-)

Fusion-Task-Id: FN-8798

Fusion-Task-Lineage: 7defae4e-fb39-4d2c-943a-ef6c1a164bfc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-04 21:57:00 -07:00
parent 6a5e1b0145
commit fa3df0f6bd
14 changed files with 453 additions and 43 deletions

View File

@@ -2190,6 +2190,7 @@ function AppInner() {
initialTab={initialTab}
projectId={currentProject?.id}
tasks={tasks}
globalPaused={globalPaused}
active={popupVisible}
embedded
onOpenDetail={popOutTaskDetailForCurrentView}
@@ -2215,6 +2216,7 @@ function AppInner() {
projectId={currentProject?.id}
tasks={tasks}
columnFlagsByTaskId={footerColumnFlagsByTaskId}
globalPaused={globalPaused}
projects={projects}
currentProject={currentProject}
addToast={addToast}

View File

@@ -46,6 +46,7 @@ interface AppModalsProps {
tasks: Task[];
/* Per-task lifecycle traits, forwarded to Task Detail's blocker fan-out. */
columnFlagsByTaskId?: ReadonlyMap<string, BlockerFanoutColumnFlags>;
globalPaused?: boolean;
projects: ProjectInfo[];
currentProject: ProjectInfo | null;
addToast: (message: string, type?: ToastType) => void;
@@ -108,6 +109,7 @@ export function AppModals({
projectId,
tasks,
columnFlagsByTaskId,
globalPaused = false,
projects,
currentProject,
addToast,
@@ -313,6 +315,7 @@ export function AppModals({
projectId={projectId}
tasks={tasks}
columnFlagsByTaskId={columnFlagsByTaskId}
globalPaused={globalPaused}
onClose={closeDetailWithNav}
onOpenDetail={openDetailTaskWithNav}
mobileHeaderMode={modalManager.detailTaskOrigin === "list-mobile" ? "back" : "close"}

View File

@@ -24,7 +24,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
import { getRunningOptionalGateBadge, getRunningWorkflowStepLabel, getUnifiedTaskProgress } from "../utils/taskProgress";
import { isTaskAgentActive } from "../utils/taskActivity";
import { getTaskStatusBadgeLabel, hasTaskStatusBadge , type TaskStatusBadgeContext} from "../utils/taskStatusBadgeLabel";
import { getTaskStatusBadgeLabel, hasTaskStatusBadge, isTaskPlanningActive, type TaskStatusBadgeContext } from "../utils/taskStatusBadgeLabel";
import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval";
import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
@@ -2468,7 +2468,7 @@ export function ListView({
if (!previous || previous.id !== detail.id) {
return previous;
}
return mergeTaskSnapshot(previous, detail);
return mergeTaskSnapshot(previous, detail, { fullSnapshot: true });
});
})
.catch(() => {
@@ -3126,6 +3126,7 @@ export function ListView({
&& !visualStatus
&& Boolean(task.recentAgentActivityAt)
&& isAgentActive;
const isLivePlanning = isTaskPlanningActive(task, { globalPaused });
const hasStatus = (hasTaskStatusBadge(visualStatus) && visualStatus !== "queued")
|| isTransientPlannerActive;
const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task);
@@ -3148,7 +3149,7 @@ export function ListView({
*/
const statusBadgeLabel = isReviewBudgetExhausted
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
: isTransientPlannerActive
: isLivePlanning || isTransientPlannerActive
? t("tasks.statusPlanning", "Planning")
: getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null });
const hasDependencies = Boolean(task.dependencies && task.dependencies.length > 0);
@@ -3398,6 +3399,7 @@ export function ListView({
&& !visualStatus
&& Boolean(task.recentAgentActivityAt)
&& isAgentActive;
const isLivePlanning = isTaskPlanningActive(task, { globalPaused });
const showStatusBadge = (hasTaskStatusBadge(visualStatus) && visualStatus !== "queued")
|| isTransientPlannerActive;
/*
@@ -3415,7 +3417,7 @@ export function ListView({
// gate badge — see the grouped-card render path above.
const statusBadgeLabel = isReviewBudgetExhausted
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
: isTransientPlannerActive
: isLivePlanning || isTransientPlannerActive
? t("tasks.statusPlanning", "Planning")
: getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null });
const isDragging = draggingTaskId === task.id;
@@ -3629,6 +3631,7 @@ export function ListView({
task={selectedTaskSnapshot}
projectId={projectId}
tasks={tasks}
globalPaused={globalPaused}
embedded
onRequestClose={closeEmbeddedTaskDetail}
onOpenDetail={handleEmbeddedOpenDetail}

View File

@@ -51,7 +51,7 @@ import { getRunningOptionalGateBadge, getRunningWorkflowStepLabel, getUnifiedTas
import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity";
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
import { getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
import { getTaskStatusBadgeLabel, type TaskStatusBadgeContext, hasTaskStatusBadge } from "../utils/taskStatusBadgeLabel";
import { getTaskStatusBadgeLabel, type TaskStatusBadgeContext, hasTaskStatusBadge, isTaskPlanningActive } from "../utils/taskStatusBadgeLabel";
import { isReviewBudgetExhaustedApproval, isTaskAwaitingPlanApproval } from "../utils/reviewBudgetApproval";
import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback";
import type { ToastType } from "../hooks/useToast";
@@ -3377,6 +3377,7 @@ function TaskCardComponent({
&& !visualStatus
&& Boolean(task.recentAgentActivityAt)
&& isAgentActive;
const isLivePlanning = isTaskPlanningActive(task, { globalPaused });
/*
FNXC:TaskStatusBadge 2026-08-01-07:20 (operator: queued belongs with Planning and Ready):
Queued used to render as a clock-and-text footer tag, separating the waiting state from the
@@ -3407,7 +3408,7 @@ function TaskCardComponent({
? t("tasks.awaitingApproval", "Awaiting Approval")
: isAwaitingInput
? t("tasks.needsInput", "Needs input")
: isTransientPlannerActive
: isLivePlanning || isTransientPlannerActive
? t("tasks.statusPlanning", "Planning")
/*
FNXC:TaskStatusBadge 2026-08-01-03:20 (operator: ONE queued badge family, no dupes):
@@ -3621,7 +3622,7 @@ function TaskCardComponent({
)
: undefined
}
aria-label={isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : undefined}
aria-label={isLivePlanning || isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : undefined}
data-testid={isAwaitingApproval ? `card-awaiting-approval-${task.id}` : showQueuedToPlanBadge ? `card-queued-to-plan-${task.id}` : undefined}
data-awaiting-approval-reason={isAwaitingApproval ? (task.awaitingApprovalReason ?? "manual") : undefined}
>

View File

@@ -87,6 +87,7 @@ import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/f
import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay";
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
import { isReviewBudgetExhaustedApproval, isTaskAwaitingPlanApproval } from "../utils/reviewBudgetApproval";
import { getTaskStatusBadgeLabel, hasTaskStatusBadge, isTaskPlanningActive } from "../utils/taskStatusBadgeLabel";
import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution";
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel } from "./TaskContextMenu";
import type { TaskContextMenuColumnFlags, TaskContextMenuColumnMetadata } from "./TaskContextMenu";
@@ -406,6 +407,8 @@ export interface TaskDetailModalProps {
addToast: (message: string, type?: ToastType) => void;
prAuthAvailable?: boolean;
autoMergeEnabled?: boolean;
/** Prevent transient planner activity from presenting as live during an engine-wide pause. */
globalPaused?: boolean;
onOpenWorkflowEditor?: () => void;
/** Open the modal with this tab active instead of the default done-aware landing view. */
initialTab?: TabId;
@@ -786,6 +789,7 @@ export function TaskDetailContent({
addToast,
prAuthAvailable,
autoMergeEnabled: autoMergeEnabledProp,
globalPaused = false,
onOpenWorkflowEditor,
/**
* FNXC:TaskDetailActivityFirst 2026-06-30-23:59:
@@ -898,7 +902,7 @@ export function TaskDetailContent({
requestTaskDetail(task.id, projectId)
.then((detail) => {
if (!cancelled && detailRequestGenerationRef.current === requestGeneration) {
setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail) : detail);
setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail, { fullSnapshot: true }) : detail);
setDetailLoading(false);
}
})
@@ -937,6 +941,18 @@ export function TaskDetailContent({
: task.overlapBlockedBy === undefined ? fullDetail.overlapBlockedBy : task.overlapBlockedBy,
} as TaskDetail)
: ({ ...task, prompt: "" } as TaskDetail);
/*
FNXC:TaskStatusConsistency 2026-08-05-04:30:
Detail hosts consume the same reconciled snapshot as board and list cards. Show live planning as
Planning, while an idle `needs-replan` remains Queued to revise; this prevents an open modal from
presenting a different lifecycle than the card that launched it.
*/
const taskStatusBadgeLabel = isTaskPlanningActive(workingTask, { globalPaused })
? t("tasks.statusPlanning", "Planning")
: getTaskStatusBadgeLabel(workingTask.status, t, undefined, {
idle: true,
overlapBlockedBy: workingTask.overlapBlockedBy ?? null,
});
const originalTaskPrompt = workingTask.description ?? "";
const hasOriginalTaskPrompt = originalTaskPrompt.trim().length > 0;
/*
@@ -3636,7 +3652,7 @@ export function TaskDetailContent({
const handleWorkflowReconciled = useCallback(async () => {
try {
const detail = await fetchTaskDetail(task.id, projectId);
setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail) : detail);
setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail, { fullSnapshot: true }) : detail);
onTaskUpdated?.(detail);
} catch {
// Best-effort refresh; the SSE stream will catch the board up regardless.
@@ -3645,7 +3661,7 @@ export function TaskDetailContent({
const handleBranchGroupReset = useCallback(async () => {
const detail = await fetchTaskDetail(task.id, projectId);
setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail) : detail);
setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail, { fullSnapshot: true }) : detail);
onTaskUpdated?.(detail);
}, [task.id, projectId, onTaskUpdated]);
@@ -4545,6 +4561,11 @@ export function TaskDetailContent({
<span className={`detail-column-badge badge-${workingTask.column}`}>
{workflowColumnDisplayName ?? columnLabel(workingTask.column)}
</span>
{hasTaskStatusBadge(workingTask.status) && (
<span className="card-status-badge" data-testid="task-detail-status-badge">
{taskStatusBadgeLabel}
</span>
)}
</div>
<div className="modal-header-actions">
{!isEditing && canEdit && (

View File

@@ -1,25 +1,40 @@
import { useEffect as reactUseEffect } from "react";
import { createElement, useEffect as reactUseEffect } from "react";
import type { ReactElement, ReactNode } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render as rtlRender, screen, waitFor, type RenderOptions } from "@testing-library/react";
import { act, fireEvent, render as rtlRender, screen, waitFor, type RenderOptions } from "@testing-library/react";
import { AppModals } from "../AppModals";
import { TaskCard } from "../TaskCard";
import { ListView } from "../ListView";
import { useTasks } from "../../hooks/useTasks";
import * as taskApi from "../../api";
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
import type { ModalManager } from "../../hooks/useModalManager";
import type { Toast } from "../../hooks/useToast";
// Mock the modals to avoid rendering all of them
const mockTaskDetailModalProps = vi.fn();
vi.mock("../TaskDetailModal", () => ({
TaskDetailModal: (props: any) => {
mockTaskDetailModalProps(props);
return (
<button data-testid="task-detail-open-detail" onClick={() => props.onOpenDetail?.({ id: "FN-2", title: "Nested" })}>
open detail
</button>
);
},
const sseSubscriptions = vi.hoisted(() => [] as Array<{
url: string;
events?: Record<string, (event: { data: string }) => void>;
}>);
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((url: string, options: { events?: Record<string, (event: { data: string }) => void> }) => {
sseSubscriptions.push({ url, events: options.events });
return vi.fn();
}),
}));
// Spy through the real detail host so lifecycle assertions exercise its rendered state.
const mockTaskDetailModalProps = vi.fn();
vi.mock("../TaskDetailModal", async (importOriginal) => {
const actual = await importOriginal<typeof import("../TaskDetailModal")>();
return {
...actual,
TaskDetailModal: (props: React.ComponentProps<typeof actual.TaskDetailModal>) => {
mockTaskDetailModalProps(props);
return createElement(actual.TaskDetailModal, props);
},
};
});
const mockSettingsModalProps = vi.fn();
vi.mock("../SettingsModal", () => ({
SettingsModal: (props: any) => {
@@ -163,8 +178,8 @@ vi.mock("../../hooks/useProjectActions", () => ({
}),
}));
// Mock @fusion/core types
vi.mock("@fusion/core", () => ({}));
// Preserve runtime core constants because the real TaskDetailModal now renders in this suite.
vi.mock("@fusion/core", async (importOriginal) => importOriginal<typeof import("@fusion/core")>());
// Mock ModalErrorBoundary
vi.mock("../ErrorBoundary", () => ({
@@ -180,6 +195,52 @@ function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
return rtlRender(ui, { wrapper: NavigationWrapper, ...options });
}
const integrationNoop = vi.fn();
const integrationAsyncNoop = vi.fn(async () => ({}));
/** Drives the same hook-owned board row into every production status renderer. */
function PlanningStatusConvergenceHarness({ detailTask, modalManager, settings }: {
detailTask: Record<string, unknown>;
modalManager: ModalManager;
settings: Record<string, unknown>;
}) {
const { tasks } = useTasks({ projectId: "project-a" });
const task = tasks[0];
if (!task) return null;
return (
<>
<TaskCard task={task} onOpenDetail={integrationNoop} addToast={integrationNoop} projectId="project-a" />
<ListView
tasks={tasks}
projectId="project-a"
onMoveTask={integrationAsyncNoop}
onDeleteTask={integrationAsyncNoop}
onArchiveTask={integrationAsyncNoop}
onMergeTask={integrationAsyncNoop}
onOpenDetail={integrationNoop}
addToast={integrationNoop}
/>
<AppModals
projectId="project-a"
tasks={tasks}
projects={[]}
currentProject={null}
addToast={integrationNoop}
toasts={[]}
removeToast={integrationNoop}
modalManager={modalManager}
projectActions={{ handleAddProject: integrationNoop, handleSetupComplete: integrationNoop, handleModelOnboardingComplete: integrationNoop }}
taskHandlers={{ handleModalCreate: integrationAsyncNoop, handlePlanningTaskCreated: integrationNoop, handlePlanningTasksCreated: integrationNoop, handleSubtaskTasksCreated: integrationNoop, handleGitHubImport: integrationNoop }}
taskOperations={{ moveTask: integrationAsyncNoop, deleteTask: integrationAsyncNoop, mergeTask: integrationAsyncNoop, retryTask: integrationAsyncNoop, duplicateTask: integrationAsyncNoop }}
deepLink={{ handleDetailClose: integrationNoop }}
settings={settings as any}
/>
<output data-testid="planning-harness-detail-id">{String(detailTask.id)}</output>
</>
);
}
describe("AppModals", () => {
const mockModalManager: ModalManager = {
// State
@@ -269,6 +330,7 @@ describe("AppModals", () => {
beforeEach(() => {
vi.clearAllMocks();
sseSubscriptions.length = 0;
mockTaskDetailModalProps.mockClear();
mockScheduledTasksModalProps.mockClear();
mockModelOnboardingModalProps.mockClear();
@@ -468,6 +530,111 @@ describe("AppModals", () => {
expect(renderedTask.log).toEqual(detail.log);
});
it("keeps an open planning detail authoritative while the board has only live planner evidence", async () => {
const detail = {
id: "FN-8798",
title: "Revision task",
description: "",
column: "triage" as const,
status: "planning",
dependencies: [],
steps: [],
log: [],
prompt: "# Prompt",
createdAt: "2026-08-05T10:00:00.000Z",
updatedAt: "2026-08-05T10:01:00.000Z",
};
const boardRow = {
...detail,
status: "needs-replan",
prompt: undefined,
updatedAt: "2026-08-05T10:00:00.000Z",
recentAgentActivityAt: "2026-08-05T10:01:00.000Z",
};
const manager = { ...mockModalManager, detailTask: detail };
render(
<AppModals
projectId="project-a"
tasks={[boardRow]}
projects={[]}
currentProject={null}
addToast={vi.fn()}
toasts={mockToasts}
removeToast={vi.fn()}
modalManager={manager}
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
deepLink={{ handleDetailClose: vi.fn() }}
settings={mockSettings}
/>,
);
await waitFor(() => expect(mockTaskDetailModalProps).toHaveBeenCalled());
expect(mockTaskDetailModalProps.mock.calls.at(-1)?.[0]?.task).toMatchObject({
id: "FN-8798",
status: "planning",
prompt: "# Prompt",
});
expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning");
});
it("drives SSE planner activity and its authoritative status update through board, list, and the real detail host", async () => {
window.localStorage.setItem("kb:project-a:kb-dashboard-list-columns", JSON.stringify(["title", "status"]));
const parkedTask = {
id: "FN-8798",
title: "Revision task",
description: "",
column: "triage" as const,
status: "needs-replan",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# Prompt",
createdAt: "2026-08-05T10:00:00.000Z",
updatedAt: "2026-08-05T10:00:00.000Z",
columnMovedAt: "2026-08-05T10:00:00.000Z",
};
const manager = { ...mockModalManager, detailTask: parkedTask };
vi.spyOn(taskApi, "fetchTasks").mockResolvedValueOnce([parkedTask] as any);
vi.spyOn(taskApi, "fetchBoardWorkflows").mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [{
id: "builtin:coding",
name: "Coding",
columns: [{ id: "triage", name: "Planning", flags: { intake: true } }],
}],
taskWorkflowIds: { [parkedTask.id]: "builtin:coding" },
});
render(<PlanningStatusConvergenceHarness detailTask={parkedTask} modalManager={manager} settings={mockSettings} />);
await waitFor(() => expect(sseSubscriptions.some(({ url }) => url.startsWith("/api/events"))).toBe(true));
const boardEvents = sseSubscriptions.find(({ url }) => url.startsWith("/api/events"))?.events;
act(() => {
boardEvents?.["agent:log"]?.({ data: JSON.stringify({
taskId: parkedTask.id,
timestamp: "2026-08-05T10:01:00.000Z",
type: "tool",
agent: "triage",
}) });
boardEvents?.["task:updated"]?.({ data: JSON.stringify({
...parkedTask,
status: "planning",
updatedAt: "2026-08-05T10:02:00.000Z",
}) });
});
await waitFor(() => {
expect(document.querySelector('.card[data-id="FN-8798"] .card-status-badge')).toHaveTextContent("Planning");
expect(document.querySelector(".list-status-badge")).toHaveTextContent("Planning");
expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning");
});
});
describe("ModelOnboardingModal wiring", () => {
beforeEach(() => {
mockModelOnboardingModalProps.mockClear();
@@ -718,7 +885,9 @@ describe("AppModals", () => {
/>,
);
fireEvent.click(screen.getByTestId("task-detail-open-detail"));
act(() => {
mockTaskDetailModalProps.mock.calls.at(-1)?.[0]?.onOpenDetail({ id: "FN-2", title: "Nested" });
});
expect(pushStateSpy).toHaveBeenCalledTimes(1);
/*
FNXC:TaskDetailNav 2026-07-07-09:15:

View File

@@ -2816,6 +2816,37 @@ describe("ListView", () => {
}
});
it("shows Planning in desktop and mobile lists when planner liveness precedes the replan status update", () => {
const task = createMockTask({
id: "FN-8798-live-replan",
column: "triage",
status: "needs-replan",
recentAgentActivityAt: new Date().toISOString(),
});
const desktopViewport = mockDesktopViewport();
try {
const { unmount } = renderListView({ tasks: [task] });
const row = screen.getByText(task.id).closest("tr") as HTMLElement;
expect(within(row).getAllByText("Planning").find((element) => element.classList.contains("list-status-badge"))).toBeDefined();
unmount();
} finally {
desktopViewport.mockRestore();
}
const mobileViewport = mockMobileViewport();
try {
renderListView({ tasks: [task] });
const card = screen.getAllByText(task.id)
.map((element) => element.closest(".list-card"))
.find((element): element is HTMLElement => element instanceof HTMLElement);
expect(card).toBeDefined();
expect(within(card!).getAllByText("Planning").find((element) => element.classList.contains("list-status-badge"))).toBeDefined();
} finally {
mobileViewport.mockRestore();
}
});
it("renders paused tasks with dimmed styling", () => {
const tasks = [createMockTask({ id: "FN-001", paused: true })];

View File

@@ -2487,6 +2487,23 @@ describe("TaskCard", () => {
expect(screen.queryByText("Replan")).not.toBeInTheDocument();
});
it("shows Planning when a live planner log reaches a needs-replan board row before its status update", () => {
render(
<TaskCard
task={makeTask({
column: "triage",
status: "needs-replan",
recentAgentActivityAt: new Date().toISOString(),
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Planning")).toHaveClass("card-status-badge");
expect(screen.queryByText("Queued to revise")).not.toBeInTheDocument();
});
it.each([
{ column: "todo" as const, status: "planning" },
{ column: "in-progress" as const, status: "planning" },

View File

@@ -892,6 +892,7 @@ export function MainContent({
task={liveDetailTask}
projectId={currentProject?.id}
tasks={tasks}
globalPaused={globalPaused}
embedded
initialTab={mainPanelDetailInitialTab}
/*

View File

@@ -158,13 +158,61 @@ describe("task snapshot lifecycle freshness", () => {
expect(afterStale.log).toEqual(queued.log);
});
it("keeps the first known lifecycle state for duplicate/equal timestamps but fills sparse fields", () => {
const queued = { ...todo, status: "queued-overlap", updatedAt: "2026-08-05T10:02:00.000Z" };
const equalStale = { ...todo, status: "todo", updatedAt: queued.updatedAt, title: "Scheduler summary" };
it("keeps populated detail metadata through an equal-clock sparse snapshot", () => {
const queued = {
...todo,
status: "queued-overlap",
updatedAt: "2026-08-05T10:02:00.000Z",
overlapBlockedBy: "FN-HOLDER",
workflowStepResults: [{ stepId: "plan", status: "failed" }],
};
const equalSparseEvent = {
...todo,
status: "todo",
updatedAt: queued.updatedAt,
overlapBlockedBy: null,
workflowStepResults: [],
title: "Scheduler summary",
};
const resolved = mergeTaskSnapshot(queued, equalStale);
const resolved = mergeTaskSnapshot(queued, equalSparseEvent);
expect(resolved).toMatchObject({ status: "queued-overlap", title: todo.title });
expect(resolved).toMatchObject({
status: "queued-overlap",
overlapBlockedBy: "FN-HOLDER",
workflowStepResults: queued.workflowStepResults,
});
expect(resolved.title).toBe(todo.title);
});
it("keeps populated detail metadata when both a legacy row and sparse event lack update clocks", () => {
const current = {
...todo,
updatedAt: undefined,
overlapBlockedBy: "FN-HOLDER",
workflowStepResults: [{ stepId: "plan", status: "failed" }],
} as unknown as Task;
const sparseEvent = {
...todo,
updatedAt: undefined,
overlapBlockedBy: null,
workflowStepResults: [],
} as unknown as Task;
expect(mergeTaskSnapshot(current, sparseEvent)).toMatchObject({
overlapBlockedBy: "FN-HOLDER",
workflowStepResults: current.workflowStepResults,
});
});
it("accepts equal-clock non-lifecycle fields only from a marked complete fetch", () => {
const current = { ...todo, updatedAt: "2026-08-05T10:02:00.000Z", title: "Cached title" };
const completeFetch = { ...todo, updatedAt: current.updatedAt, title: "Fetched title" };
expect(mergeTaskSnapshot(current, completeFetch, { fullSnapshot: true })).toMatchObject({
status: current.status,
title: "Fetched title",
});
});
it("accepts a genuinely newer column transition", () => {

View File

@@ -2621,9 +2621,9 @@ describe("useTasks", () => {
MockEventSource.instances[0]._emit("task:updated", staleUpdate);
});
// Should have in-progress column (from move) but updated title
// The equal-clock SSE patch cannot replace populated metadata without complete-fetch authority.
expect(result.current.tasks[0].column).toBe("in-progress");
expect(result.current.tasks[0].title).toBe("Updated Title");
expect(result.current.tasks[0].title).toBe("Original Title");
});
});
@@ -3833,6 +3833,31 @@ describe("useTasks", () => {
expect(result.current.tasks[0]?.recentAgentActivityAt).toBeUndefined();
});
it("retains the parked replan row plus explicit live-planner evidence until the status event lands", async () => {
const initialTask = createMockTask({
column: "triage",
status: "needs-replan",
updatedAt: "2026-08-05T10: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-08-05T10:00:01.000Z",
type: "tool",
agent: "triage",
});
});
expect(result.current.tasks[0]).toMatchObject({
status: "needs-replan",
recentAgentActivityAt: "2026-08-05T10:00:01.000Z",
});
});
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
The SOURCE of the planner-activity signal. It only stamped `recentAgentActivityAt`

View File

@@ -252,22 +252,42 @@ The scheduler can refresh the board while a task-detail host holds a newer queue
file-overlap snapshot. Providers (SWR, SSE, fetchTaskDetail, and local mutations) do not share an
arrival order, so lifecycle rendering must use the existing timestamp evidence: a later `columnMovedAt`
owns a real column transition; within that column, a later `updatedAt` owns status. Equal or absent
clock evidence retains the already-visible known lifecycle state, preventing Todo/Queued flashes.
clock evidence retains the already-visible known lifecycle state unless a complete server snapshot is
newer than the row and resolves an equal legacy move clock; sparse SSE patches never receive that tie-break.
This helper intentionally merges only defined sparse fields and retains a fetched detail's prompt/log
when a slim board row arrives. Every open-detail host and useTasks ingestion uses this one boundary so
one provider cannot regress a modal, main panel, split detail, dock, or popup independently.
*/
export function mergeTaskSnapshot<T extends Task>(current: T, incoming: Task): T {
export interface TaskSnapshotMergeOptions {
/** A complete board/detail fetch can resolve an otherwise ambiguous legacy column clock. */
fullSnapshot?: boolean;
}
export function mergeTaskSnapshot<T extends Task>(
current: T,
incoming: Task,
options: TaskSnapshotMergeOptions = {},
): T {
if (current.id !== incoming.id) return current;
const merged = { ...current } as Record<string, unknown>;
const updatedAtCompare = compareTimestamps(incoming.updatedAt, current.updatedAt);
const acceptsIncomingSnapshot = updatedAtCompare > 0 || !current.updatedAt;
/*
FNXC:TaskStatusConsistency 2026-08-05-04:14:
Missing clocks are not authority: a legacy detail row and a sparse SSE patch with neither clock
must retain populated metadata rather than letting their arrival order erase queue/workflow state.
Only a strictly newer update clock, or an explicitly complete equal-clock fetch below, may replace it.
*/
const acceptsIncomingSnapshot = updatedAtCompare > 0;
// A fetch is explicitly marked complete at its call site. Equal clocks can only fill an absent
// field from a sparse event; they replace populated fields only for a complete fetch.
const acceptsEqualClockFields = options.fullSnapshot === true && updatedAtCompare === 0;
for (const [key, value] of Object.entries(incoming)) {
// Older/equal scheduler payloads may enrich only fields that are still absent; they never
// rewrite a known snapshot simply because they arrived later.
if (value !== undefined && (acceptsIncomingSnapshot || merged[key] === undefined)) {
const canMergeField = acceptsIncomingSnapshot
|| acceptsEqualClockFields
|| (updatedAtCompare === 0 && merged[key] === undefined);
if (value !== undefined && canMergeField) {
merged[key] = value;
}
}
@@ -277,6 +297,9 @@ export function mergeTaskSnapshot<T extends Task>(current: T, incoming: Task): T
const incomingMovesColumn = incoming.column !== undefined
&& (current.column === undefined
|| columnMovedAtCompare > 0
// A full server snapshot is more complete than an SSE patch, so its newer task clock can
// resolve a legacy equal move clock without letting a sparse event move the card.
|| (options.fullSnapshot === true && columnMovedAtCompare === 0 && updatedAtCompare > 0)
// Older rows have no column-move clock. A newer task timestamp is still evidence for a real move.
|| (!current.columnMovedAt && !incoming.columnMovedAt && updatedAtCompare > 0));
const incomingUpdatesStatus = incoming.status !== undefined
@@ -290,6 +313,16 @@ export function mergeTaskSnapshot<T extends Task>(current: T, incoming: Task): T
merged.awaitingPlanning = acceptsIncomingSnapshot
? carryAwaitingPlanning(current, incoming)
: current.awaitingPlanning;
/*
FNXC:TaskStatusConsistency 2026-08-05-04:05:
`recentAgentActivityAt` is a client-only bridge from an agent-log event to the next task snapshot.
Preserve it while a stale/equal payload is rejected so live Planning does not flash back to Queued,
but clear it when a newer authoritative row arrives without the marker. Equal-clock sparse events
may fill missing fields but cannot replace populated detail metadata; only complete fetches may do so.
*/
merged.recentAgentActivityAt = acceptsIncomingSnapshot
? incoming.recentAgentActivityAt
: current.recentAgentActivityAt;
if ("prompt" in current && incoming.prompt === undefined) {
merged.prompt = current.prompt;
@@ -299,8 +332,8 @@ export function mergeTaskSnapshot<T extends Task>(current: T, incoming: Task): T
return merged as T;
}
function mergeIncomingTask(current: Task, incoming: Task): Task {
return mergeTaskSnapshot(current, incoming);
function mergeIncomingTask(current: Task, incoming: Task, options?: TaskSnapshotMergeOptions): Task {
return mergeTaskSnapshot(current, incoming, options);
}
export interface UseTasksOptions {
@@ -544,7 +577,7 @@ export function useTasks(options?: UseTasksOptions) {
// (or its board source) back merely because this fetch callback arrived last.
const reconciledFetchedTasks = normalizedFetchedTasks.map((fetched) => {
const current = previous.find((candidate) => candidate.id === fetched.id);
return current ? mergeIncomingTask(current, fetched) : fetched;
return current ? mergeIncomingTask(current, fetched, { fullSnapshot: true }) : fetched;
});
if (!shouldCarryOverArchived) return reconciledFetchedTasks;

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TFunction } from "i18next";
import { getTaskStatusBadgeLabel, hasTaskStatusBadge } from "../taskStatusBadgeLabel";
import { getTaskStatusBadgeLabel, hasTaskStatusBadge, isTaskPlanningActive, PLANNER_ACTIVITY_LIVE_WINDOW_MS } from "../taskStatusBadgeLabel";
const t = ((key: string, fallback?: string) => fallback ?? key) as TFunction<"app">;
@@ -24,6 +24,26 @@ describe("hasTaskStatusBadge", () => {
});
});
describe("isTaskPlanningActive", () => {
it("accepts planning status or fresh, unpaused planner activity on a replan card only", () => {
const now = Date.parse("2026-08-05T10:05:00.000Z");
expect(isTaskPlanningActive({ status: "planning" }, { globalPaused: true, now })).toBe(true);
expect(isTaskPlanningActive({ status: "needs-replan", recentAgentActivityAt: "2026-08-05T10:01:00.000Z" }, { now })).toBe(true);
expect(isTaskPlanningActive({ status: "needs-replan", recentAgentActivityAt: "2026-08-05T10:01:00.000Z" }, { globalPaused: true, now })).toBe(false);
expect(isTaskPlanningActive({ status: "needs-replan" }, { now })).toBe(false);
expect(isTaskPlanningActive({ status: undefined, recentAgentActivityAt: "2026-08-05T10:01:00.000Z" }, { now })).toBe(false);
});
it("expires historical or malformed planner activity", () => {
const now = Date.parse("2026-08-05T10:05:00.000Z");
expect(isTaskPlanningActive(
{ status: "needs-replan", recentAgentActivityAt: new Date(now - PLANNER_ACTIVITY_LIVE_WINDOW_MS - 1).toISOString() },
{ now },
)).toBe(false);
expect(isTaskPlanningActive({ status: "needs-replan", recentAgentActivityAt: "not-a-date" }, { now })).toBe(false);
});
});
describe("getTaskStatusBadgeLabel", () => {
it("maps the full AI merge pipeline to Merging…", () => {
for (const status of ["merging", "merging-pr", "reviewing", "landing"]) {

View File

@@ -3,6 +3,7 @@ FNXC:MergeQueue 2026-07-15-10:45:
AI merge sets task.status to reviewing/landing for most of the live merge window. Board/list badges must never show those raw engine strings; map the full active-merge pipeline to operator-facing Merging… (and Merging fixes… for merging-fix).
*/
import type { TFunction } from "i18next";
import type { Task } from "@fusion/core";
import { isActiveMergeStatus } from "../../../core/src/merge/active-merge-status";
/*
@@ -15,6 +16,41 @@ export function hasTaskStatusBadge(status: string | null | undefined): boolean {
return typeof status === "string" && status.trim().length > 0;
}
/*
FNXC:TaskStatusConsistency 2026-08-05-03:49:
A planner agent-log event can reach the shared board snapshot before the status update that changes
`needs-replan` to `planning`. Its `recentAgentActivityAt` is written only after the log proves it is
newer than the row, so this is explicit live-planner evidence rather than arrival-order guessing.
Use it only for the parked-revision token while the event is inside the five-minute live window and
the engine is not globally paused: undefined status remains unknown and an idle needs-replan card
remains Queued to revise. TaskCard, both ListView paths, and task detail share this predicate so a
modal that has already fetched planning cannot contradict the board during that short SSE gap.
FNXC:TaskStatusConsistency 2026-08-05-04:54:
A parseable historical log is not liveness. The transient `needs-replan` override expires after the
shared five-minute heartbeat window and is suppressed during global pause, returning the operator to
Queued to revise instead of leaving every affected surface stuck on Planning.
*/
export const PLANNER_ACTIVITY_LIVE_WINDOW_MS = 5 * 60_000;
export interface TaskPlanningActivityOptions {
globalPaused?: boolean;
now?: number;
}
export function isTaskPlanningActive(
task: Pick<Task, "status" | "recentAgentActivityAt">,
{ globalPaused = false, now = Date.now() }: TaskPlanningActivityOptions = {},
): boolean {
if (task.status === "planning") return true;
if (globalPaused || task.status !== "needs-replan" || typeof task.recentAgentActivityAt !== "string") return false;
const activityAt = Date.parse(task.recentAgentActivityAt);
return Number.isFinite(activityAt)
&& activityAt <= now
&& now - activityAt <= PLANNER_ACTIVITY_LIVE_WINDOW_MS;
}
/*
FNXC:TaskStatusBadge 2026-08-01-01:30 (operator: "make the badges more descriptive"):
Three waiting states read as ACTIVITY on the board and made healthy queueing look broken: