FN-8801: refresh task state after lifecycle actions

Refresh dashboard task views immediately when lifecycle mutations are confirmed.

- Centralize confirmed task reconciliation across pause and unpause actions
- Propagate lifecycle handlers through dashboard detail and dock hosts
- Add state synchronization regression coverage and a patch changeset

Files changed:
 .changeset/fn-8801-immediate-unpause-state.md      |  7 ++
 packages/dashboard/app/App.tsx                     |  6 +-
 packages/dashboard/app/components/AppModals.tsx    |  4 ++
 packages/dashboard/app/components/ListView.tsx     |  2 +
 .../dashboard/app/components/TaskDetailModal.tsx   | 26 +++++---
 .../TaskDetailModal.definition-actions.test.tsx    | 76 +++++++++++++++------
 .../app/components/dashboard/MainContent.tsx       |  2 +
 .../app/components/useRightDockController.tsx      |  4 ++
 .../dashboard/app/hooks/__tests__/useTasks.test.ts | 57 ++++++++++++++++
 packages/dashboard/app/hooks/useTasks.ts           | 77 +++++++++-------------
 10 files changed, 183 insertions(+), 78 deletions(-)

Fusion-Task-Id: FN-8801
Fusion-Task-Lineage: 2ab80d4d-8914-43c5-8ebb-281d31276b61
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-05 00:34:06 -07:00
parent 68e964383f
commit 59cfa47b1c
10 changed files with 183 additions and 78 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Refresh dashboard task state immediately after unpausing.
category: fix
dev: Detail pause controls now use the shared lifecycle reconciliation path.

View File

@@ -1567,7 +1567,7 @@ function AppInner() {
// Props for the extracted <MainContent> switch (see components/dashboard/MainContent.tsx).
// Every value is passed by its App name; the switch renders the same subtrees as before.
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, columnFlagsByTaskId: footerColumnFlagsByTaskId, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetailForCurrentView, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onRevertTask: revertTask, onMergeTask: mergeTask, onRetryTask: retryTask, onBypassReview: bypassReview, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, columnFlagsByTaskId: footerColumnFlagsByTaskId, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetailForCurrentView, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onRevertTask: revertTask, onMergeTask: mergeTask, onRetryTask: retryTask, onPauseTask: pauseTask, onUnpauseTask: unpauseTask, onBypassReview: bypassReview, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
/*
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
@@ -2200,6 +2200,8 @@ function AppInner() {
onDeleteTask={deleteTask}
onMergeTask={mergeTask}
onRetryTask={retryTask}
onPauseTask={pauseTask}
onUnpauseTask={unpauseTask}
onBypassReview={bypassReview}
onResetTask={resetTask}
onDuplicateTask={duplicateTask}
@@ -2234,7 +2236,7 @@ function AppInner() {
onPlanningMode={openPlanningWithInitialPlanWithNav}
onOpenChatWithPrefill={openChatWithPrefill}
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, revertTask, retryTask, bypassReview, resetTask, duplicateTask }}
taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, revertTask, retryTask, pauseTask, unpauseTask, bypassReview, resetTask, duplicateTask }}
deepLink={{ handleDetailClose }}
settings={{ prAuthAvailable, autoMerge, taskDetailChatFirst, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate, setMobileNavPrimaryItemsImmediate }}
onSettingsClose={handleSettingsCloseWithNav}

View File

@@ -71,6 +71,8 @@ interface AppModalsProps {
/* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside archiveTask; never mutates the source task's column. */
revertTask?: (taskId: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
retryTask: (taskId: string) => Promise<Task>;
pauseTask: (taskId: string) => Promise<Task>;
unpauseTask: (taskId: string) => Promise<Task>;
/* FNXC:ReviewLaneBypass 2026-07-09-00:00 (FN-7720): operator-only review-lane bypass, threaded to TaskDetailModal only. */
bypassReview?: (taskId: string, reason: string) => Promise<Task>;
resetTask: (taskId: string) => Promise<Task>;
@@ -327,6 +329,8 @@ export function AppModals({
onArchiveTask={taskOperations.archiveTask}
onRevertTask={taskOperations.revertTask}
onRetryTask={taskOperations.retryTask}
onPauseTask={taskOperations.pauseTask}
onUnpauseTask={taskOperations.unpauseTask}
onBypassReview={taskOperations.bypassReview}
onResetTask={taskOperations.resetTask}
onDuplicateTask={taskOperations.duplicateTask}

View File

@@ -3641,6 +3641,8 @@ export function ListView({
onDeleteTask={onDeleteTask}
onMergeTask={onMergeTask}
onRetryTask={onRetryTask}
onPauseTask={onPauseTask}
onUnpauseTask={onUnpauseTask}
onResetTask={onResetTask}
onDuplicateTask={onDuplicateTask}
onPopOut={onPopOut ? () => onPopOut(selectedTaskSnapshot) : undefined}

View File

@@ -34,7 +34,7 @@ import {
isWipColumnRole,
} from "../utils/columnRoles";
import { resolveEffectiveAutoMerge } from "../../../core/src/merge/task-merge";
import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchTaskPrompt, fetchTaskVerificationRequest, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, fetchModels, fetchNodes, api } from "../api";
import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, fetchTaskDetail, fetchTaskPrompt, fetchTaskVerificationRequest, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, fetchModels, fetchNodes, api } from "../api";
import type { RevertTaskOptions, RevertTaskResult, ModelInfo, NodeInfo } from "../api";
import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
import { WorkflowIcon } from "./WorkflowIcon";
@@ -394,6 +394,9 @@ export interface TaskDetailModalProps {
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
onMergeTask: (id: string) => Promise<MergeResult>;
onRetryTask?: (id: string) => Promise<Task>;
/** Shared lifecycle operations reconcile confirmed rows before detail hosts render their next frame. */
onPauseTask?: (id: string) => Promise<Task>;
onUnpauseTask?: (id: string) => Promise<Task>;
/*
FNXC:ReviewLaneBypass 2026-07-09-00:00:
Operator-only review-lane bypass (FN-7720). Only wired here (Task Detail) so
@@ -782,6 +785,8 @@ export function TaskDetailContent({
onRevertTask,
onMergeTask,
onRetryTask,
onPauseTask,
onUnpauseTask,
onBypassReview,
onResetTask,
onDuplicateTask,
@@ -3446,18 +3451,21 @@ export function TaskDetailContent({
const handleTogglePause = useCallback(async () => {
try {
if (isTaskPaused) {
await unpauseTask(task.id, projectId);
addToast(t("taskDetail.pause.unpaused", "Unpaused {{id}}", { id: task.id }), "success");
} else {
await pauseTask(task.id, projectId);
addToast(t("taskDetail.pause.paused", "Paused {{id}}", { id: task.id }), "success");
}
const lifecycleOperation = isTaskPaused ? onUnpauseTask : onPauseTask;
if (!lifecycleOperation) return;
const updatedTask = await lifecycleOperation(task.id);
onTaskUpdated?.(updatedTask);
addToast(
isTaskPaused
? t("taskDetail.pause.unpaused", "Unpaused {{id}}", { id: task.id })
: t("taskDetail.pause.paused", "Paused {{id}}", { id: task.id }),
"success",
);
requestClose();
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [isTaskPaused, task.id, requestClose, addToast]);
}, [isTaskPaused, onPauseTask, onTaskUpdated, onUnpauseTask, task.id, requestClose, addToast, t]);
const handleApprovePlan = useCallback(async () => {
try {

View File

@@ -6,6 +6,7 @@ regex. The now-universal Oversight overflow trigger's aria-label is
"Oversight actions", which also matches `/actions/i` and made every such
query ambiguous once the trigger stopped being a mobile-only affordance.
*/
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -25,6 +26,28 @@ import {
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
import { FileBrowserProvider } from "../../context/FileBrowserContext";
import { readBoardWorkflowSelection, removeBoardWorkflowSelection, writeBoardWorkflowSelection } from "../../utils/boardWorkflowSelection";
import type { Task } from "@fusion/core";
function PauseDetailHarness({ mobileHeaderMode }: { mobileHeaderMode?: "back" }) {
const [task, setTask] = useState(() => makeTask({ id: "FN-UNPAUSE", column: "todo", paused: true, userPaused: true }));
const onUnpauseTask = vi.fn(async () => ({ ...task, paused: false, userPaused: false } as Task));
return (
<TaskDetailContent
task={task}
mobileHeaderMode={mobileHeaderMode}
embedded
onRequestClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onUnpauseTask={onUnpauseTask}
onTaskUpdated={setTask}
addToast={noop}
/>
);
}
setupTaskDetailModalHooks();
@@ -936,10 +959,8 @@ describe("TaskDetailModal", () => {
});
it("mobile task popup Actions menu selects a tapped item once and dismisses", async () => {
const { pauseTask } = await import("../../api");
const mockPauseTask = vi.mocked(pauseTask);
mockPauseTask.mockResolvedValueOnce(makeTask({ id: "FN-001", paused: true }) as Task);
it("mobile task popup Actions menu selects the shared pause callback once and dismisses", async () => {
const onPauseTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-001", paused: true }) as Task);
const addToast = vi.fn();
render(
@@ -952,6 +973,7 @@ describe("TaskDetailModal", () => {
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onPauseTask={onPauseTask}
addToast={addToast}
/>,
);
@@ -961,8 +983,8 @@ describe("TaskDetailModal", () => {
fireEvent.pointerUp(pauseItem, { pointerType: "touch", pointerId: 1 });
await waitFor(() => expect(mockPauseTask).toHaveBeenCalledWith("FN-001", undefined));
expect(mockPauseTask).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001"));
expect(onPauseTask).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(addToast).toHaveBeenCalledWith("Paused FN-001", "success");
});
@@ -1138,9 +1160,8 @@ describe("TaskDetailModal", () => {
expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeTruthy();
});
it("renders Unpause for userPaused-only tasks and unpauses once", async () => {
const { unpauseTask } = await import("../../api");
const mockUnpauseTask = vi.mocked(unpauseTask);
it("renders Unpause for userPaused-only tasks and calls the shared lifecycle once", async () => {
const onUnpauseTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-001", paused: false, userPaused: false }) as Task);
render(
<TaskDetailModal
@@ -1151,6 +1172,7 @@ describe("TaskDetailModal", () => {
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onUnpauseTask={onUnpauseTask}
addToast={noop}
/>,
);
@@ -1159,17 +1181,28 @@ describe("TaskDetailModal", () => {
await userEvent.click(screen.getByRole("menuitem", { name: "Unpause" }));
await waitFor(() => {
expect(mockUnpauseTask).toHaveBeenCalledTimes(1);
expect(mockUnpauseTask).toHaveBeenCalledWith("FN-001", undefined);
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
expect(onUnpauseTask).toHaveBeenCalledWith("FN-001");
});
});
it.each([undefined, "back"] as const)("immediately renders the confirmed unpause state for %s detail presentation", async (mobileHeaderMode) => {
const user = userEvent.setup();
render(<PauseDetailHarness mobileHeaderMode={mobileHeaderMode} />);
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Unpause" }));
await waitFor(() => expect(screen.queryByRole("menuitem", { name: "Unpause" })).toBeNull());
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(screen.getByRole("menuitem", { name: "Pause" })).toBeTruthy();
});
it("renders actionable Unpause button for agent-assigned paused tasks", async () => {
const { fetchAgent, unpauseTask } = await import("../../api");
const { fetchAgent } = await import("../../api");
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUnpauseTask = vi.mocked(unpauseTask);
const onUnpauseTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-ASSIGNED", paused: false }) as Task);
mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "active" } as any);
mockUnpauseTask.mockClear();
render(
<TaskDetailModal
@@ -1180,6 +1213,7 @@ describe("TaskDetailModal", () => {
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onUnpauseTask={onUnpauseTask}
addToast={noop}
/>,
);
@@ -1192,8 +1226,8 @@ describe("TaskDetailModal", () => {
await userEvent.click(screen.getByRole("menuitem", { name: "Unpause" }));
await waitFor(() => {
expect(mockUnpauseTask).toHaveBeenCalledTimes(1);
expect(mockUnpauseTask).toHaveBeenCalledWith("FN-ASSIGNED", undefined);
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
expect(onUnpauseTask).toHaveBeenCalledWith("FN-ASSIGNED");
});
});
@@ -1226,11 +1260,10 @@ describe("TaskDetailModal", () => {
});
it("renders actionable Pause button for agent-assigned tasks that are not paused", async () => {
const { fetchAgent, pauseTask } = await import("../../api");
const { fetchAgent } = await import("../../api");
const mockFetchAgent = vi.mocked(fetchAgent);
const mockPauseTask = vi.mocked(pauseTask);
const onPauseTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-ASSIGNED", paused: true }) as Task);
mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "active" } as any);
mockPauseTask.mockClear();
render(
<TaskDetailModal
@@ -1241,6 +1274,7 @@ describe("TaskDetailModal", () => {
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onPauseTask={onPauseTask}
addToast={noop}
/>,
);
@@ -1253,8 +1287,8 @@ describe("TaskDetailModal", () => {
await userEvent.click(screen.getByRole("menuitem", { name: "Pause" }));
await waitFor(() => {
expect(mockPauseTask).toHaveBeenCalledTimes(1);
expect(mockPauseTask).toHaveBeenCalledWith("FN-ASSIGNED", undefined);
expect(onPauseTask).toHaveBeenCalledTimes(1);
expect(onPauseTask).toHaveBeenCalledWith("FN-ASSIGNED");
});
});

View File

@@ -908,6 +908,8 @@ export function MainContent({
onReviseTask={(task) => modalManager.openNewTaskWithDescription(task.description)}
onMergeTask={mergeTask}
onRetryTask={retryTask}
onPauseTask={pauseTask}
onUnpauseTask={unpauseTask}
onResetTask={resetTask}
onDuplicateTask={duplicateTask}
/*

View File

@@ -43,6 +43,8 @@ export interface RightDockControllerInput {
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
onMergeTask: (id: string) => Promise<MergeResult>;
onRetryTask?: (id: string) => Promise<Task>;
onPauseTask?: (id: string) => Promise<Task>;
onUnpauseTask?: (id: string) => Promise<Task>;
/* FNXC:ReviewLaneBypass 2026-07-09-00:00 (FN-7720): threaded through so the right-dock host renders the same TaskDetailContent bypass affordance as the full modal/floating hosts. */
onBypassReview?: (id: string, reason: string) => Promise<Task>;
onResetTask?: (id: string) => Promise<Task>;
@@ -272,6 +274,8 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
onRevertTask={input.onRevertTask}
onMergeTask={input.onMergeTask}
onRetryTask={input.onRetryTask}
onPauseTask={input.onPauseTask}
onUnpauseTask={input.onUnpauseTask}
onBypassReview={input.onBypassReview}
onResetTask={input.onResetTask}
onDuplicateTask={input.onDuplicateTask}

View File

@@ -2140,6 +2140,63 @@ describe("useTasks", () => {
expect(result.current.tasks).toEqual([unpaused, keep]);
});
it("keeps newer SSE state authoritative when it arrives before the unpause response", async () => {
const paused = createMockTask({ id: "FN-PAUSE", column: "todo" as Column, paused: true, userPaused: true, updatedAt: "2026-07-12T00:00:00.000Z" });
const newerServerState = createMockTask({ ...paused, paused: true, userPaused: true, pausedReason: "newer server decision", updatedAt: "2026-07-12T00:02:00.000Z" });
const staleUnpauseResponse = createMockTask({ ...paused, paused: false, userPaused: false, pausedReason: null, updatedAt: "2026-07-12T00:01:00.000Z" });
let resolveUnpause!: (task: Task) => void;
mockFetchTasks.mockResolvedValueOnce([paused]);
mockUnpauseTask.mockImplementationOnce(() => new Promise<Task>((resolve) => { resolveUnpause = resolve; }));
const { result } = renderHook(() => useTasks());
await waitFor(() => expect(result.current.tasks).toEqual([paused]));
let mutation!: Promise<Task>;
act(() => { mutation = result.current.unpauseTask("FN-PAUSE"); });
await waitFor(() => expect(mockUnpauseTask).toHaveBeenCalledTimes(1));
await act(async () => {
MockEventSource.instances[0]?._emit("task:updated", newerServerState);
await flushPromises();
});
await act(async () => {
resolveUnpause(staleUnpauseResponse);
await mutation;
});
expect(result.current.tasks).toEqual([newerServerState]);
});
it("leaves rows and cache untouched when unpause fails", async () => {
const paused = createMockTask({ id: "FN-PAUSE", column: "todo" as Column, paused: true, userPaused: true });
mockFetchTasks.mockResolvedValueOnce([paused]);
mockUnpauseTask.mockRejectedValueOnce(new Error("network failed"));
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));
await waitFor(() => expect(result.current.tasks).toEqual([paused]));
mockWriteCache.mockClear();
await expect(result.current.unpauseTask("FN-PAUSE")).rejects.toThrow("network failed");
expect(result.current.tasks).toEqual([paused]);
expect(mockWriteCache).not.toHaveBeenCalled();
});
it("clears a malformed project cache rather than persisting a mixed task snapshot", async () => {
const paused = createMockTask({ id: "FN-PAUSE", column: "todo" as Column, paused: true, userPaused: true });
const unpaused = createMockTask({ ...paused, paused: false, userPaused: false });
mockFetchTasks.mockResolvedValueOnce([paused]);
mockUnpauseTask.mockResolvedValueOnce(unpaused);
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));
await waitFor(() => expect(result.current.tasks).toEqual([paused]));
mockReadCache.mockReset().mockReturnValue([paused, "malformed"]);
mockClearCache.mockClear();
await act(async () => { await result.current.unpauseTask("FN-PAUSE"); });
expect(mockClearCache).toHaveBeenCalledWith(`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`);
expect(result.current.tasks).toEqual([unpaused]);
});
it("leaves missing-id task collections stable after pause success", async () => {
const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column, paused: false, userPaused: false });
const pausedMissing = createMockTask({ id: "FN-MISSING", column: "todo" as Column, paused: true, userPaused: true });

View File

@@ -1113,74 +1113,59 @@ export function useTasks(options?: UseTasksOptions) {
}, [projectId]);
/*
FNXC:DashboardPauseState 2026-07-12-00:00:
FN-7861 makes pause and unpause user-visible state boundaries. After the API confirms either transition, patch shared hook state and the project SWR task cache immediately, mirroring retryTask/bypassReview, so Board/List/right-dock task renderers do not wait for SSE or polling to clear stale paused rendering.
FNXC:DashboardPauseState 2026-08-05-07:18:
Every lifecycle surface must publish the server-confirmed pause row to shared state before
waiting on SSE or polling. One reconciliation seam advances the fetch version, replaces only
the matching task, and safely refreshes the project cache, so detail, board, list, and dock
hosts cannot diverge after pause or unpause.
*/
const pauseTask = useCallback(async (id: string): Promise<Task> => {
const updatedTask = normalizeTask(await api.pauseTask(id, projectId));
const reconcileConfirmedTask = useCallback((confirmedTask: Task): Task => {
const confirmedRow = normalizeTask(confirmedTask);
const currentTask = tasksRef.current.find((task) => task.id === confirmedRow.id);
// A live event that arrived while the mutation was pending may be newer than its response.
// Start from the confirmed row so equal clocks retain the mutation, then admit only newer state.
const updatedTask = currentTask ? mergeIncomingTask(confirmedRow, currentTask) : confirmedRow;
fetchVersionRef.current++;
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? updatedTask : task));
const replaceConfirmedTask = (currentTasks: Task[]) =>
currentTasks.map((task) => task.id === updatedTask.id ? mergeIncomingTask(updatedTask, task) : task);
if (projectId) {
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
const cacheContainsOnlyTaskRows = cachedTasks.every((task) => Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"));
const cacheContainsOnlyTaskRows = cachedTasks.every((task) =>
Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"),
);
if (cacheContainsOnlyTaskRows) {
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? updatedTask : normalizeTask(task as Task)));
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
const nextCachedTasks = cachedTasks.map((task) =>
(task as Task).id === updatedTask.id ? updatedTask : normalizeTask(task as Task),
);
writeTaskCacheSnapshot(cacheKey, nextCachedTasks);
} else {
clearCache(cacheKey);
}
} else if (cachedTasks === null) {
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
writeTaskCacheSnapshot(cacheKey, replaceConfirmedTask(tasksRef.current));
} else {
clearCache(cacheKey);
}
}
setTasks((prev) => {
const next = projectUpdatedTasks(prev);
tasksRef.current = next;
return next;
setTasks((previousTasks) => {
const nextTasks = replaceConfirmedTask(previousTasks);
tasksRef.current = nextTasks;
return nextTasks;
});
return updatedTask;
}, [projectId]);
const pauseTask = useCallback(async (id: string): Promise<Task> => {
return reconcileConfirmedTask(await api.pauseTask(id, projectId));
}, [projectId, reconcileConfirmedTask]);
const unpauseTask = useCallback(async (id: string): Promise<Task> => {
const updatedTask = normalizeTask(await api.unpauseTask(id, projectId));
fetchVersionRef.current++;
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? updatedTask : task));
if (projectId) {
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks)) {
const cacheContainsOnlyTaskRows = cachedTasks.every((task) => Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"));
if (cacheContainsOnlyTaskRows) {
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? updatedTask : normalizeTask(task as Task)));
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
} else if (cachedTasks === null) {
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
} else {
clearCache(cacheKey);
}
}
setTasks((prev) => {
const next = projectUpdatedTasks(prev);
tasksRef.current = next;
return next;
});
return updatedTask;
}, [projectId]);
return reconcileConfirmedTask(await api.unpauseTask(id, projectId));
}, [projectId, reconcileConfirmedTask]);
const deleteTask = useCallback(async (
id: string,