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:
7
.changeset/fn-8801-immediate-unpause-state.md
Normal file
7
.changeset/fn-8801-immediate-unpause-state.md
Normal 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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/*
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user