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).
|
// 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.
|
// 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:
|
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
|
||||||
@@ -2200,6 +2200,8 @@ function AppInner() {
|
|||||||
onDeleteTask={deleteTask}
|
onDeleteTask={deleteTask}
|
||||||
onMergeTask={mergeTask}
|
onMergeTask={mergeTask}
|
||||||
onRetryTask={retryTask}
|
onRetryTask={retryTask}
|
||||||
|
onPauseTask={pauseTask}
|
||||||
|
onUnpauseTask={unpauseTask}
|
||||||
onBypassReview={bypassReview}
|
onBypassReview={bypassReview}
|
||||||
onResetTask={resetTask}
|
onResetTask={resetTask}
|
||||||
onDuplicateTask={duplicateTask}
|
onDuplicateTask={duplicateTask}
|
||||||
@@ -2234,7 +2236,7 @@ function AppInner() {
|
|||||||
onPlanningMode={openPlanningWithInitialPlanWithNav}
|
onPlanningMode={openPlanningWithInitialPlanWithNav}
|
||||||
onOpenChatWithPrefill={openChatWithPrefill}
|
onOpenChatWithPrefill={openChatWithPrefill}
|
||||||
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
|
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 }}
|
deepLink={{ handleDetailClose }}
|
||||||
settings={{ prAuthAvailable, autoMerge, taskDetailChatFirst, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate, setMobileNavPrimaryItemsImmediate }}
|
settings={{ prAuthAvailable, autoMerge, taskDetailChatFirst, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate, setMobileNavPrimaryItemsImmediate }}
|
||||||
onSettingsClose={handleSettingsCloseWithNav}
|
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. */
|
/* 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>;
|
revertTask?: (taskId: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
|
||||||
retryTask: (taskId: string) => Promise<Task>;
|
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. */
|
/* 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>;
|
bypassReview?: (taskId: string, reason: string) => Promise<Task>;
|
||||||
resetTask: (taskId: string) => Promise<Task>;
|
resetTask: (taskId: string) => Promise<Task>;
|
||||||
@@ -327,6 +329,8 @@ export function AppModals({
|
|||||||
onArchiveTask={taskOperations.archiveTask}
|
onArchiveTask={taskOperations.archiveTask}
|
||||||
onRevertTask={taskOperations.revertTask}
|
onRevertTask={taskOperations.revertTask}
|
||||||
onRetryTask={taskOperations.retryTask}
|
onRetryTask={taskOperations.retryTask}
|
||||||
|
onPauseTask={taskOperations.pauseTask}
|
||||||
|
onUnpauseTask={taskOperations.unpauseTask}
|
||||||
onBypassReview={taskOperations.bypassReview}
|
onBypassReview={taskOperations.bypassReview}
|
||||||
onResetTask={taskOperations.resetTask}
|
onResetTask={taskOperations.resetTask}
|
||||||
onDuplicateTask={taskOperations.duplicateTask}
|
onDuplicateTask={taskOperations.duplicateTask}
|
||||||
|
|||||||
@@ -3641,6 +3641,8 @@ export function ListView({
|
|||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
onMergeTask={onMergeTask}
|
onMergeTask={onMergeTask}
|
||||||
onRetryTask={onRetryTask}
|
onRetryTask={onRetryTask}
|
||||||
|
onPauseTask={onPauseTask}
|
||||||
|
onUnpauseTask={onUnpauseTask}
|
||||||
onResetTask={onResetTask}
|
onResetTask={onResetTask}
|
||||||
onDuplicateTask={onDuplicateTask}
|
onDuplicateTask={onDuplicateTask}
|
||||||
onPopOut={onPopOut ? () => onPopOut(selectedTaskSnapshot) : undefined}
|
onPopOut={onPopOut ? () => onPopOut(selectedTaskSnapshot) : undefined}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
isWipColumnRole,
|
isWipColumnRole,
|
||||||
} from "../utils/columnRoles";
|
} from "../utils/columnRoles";
|
||||||
import { resolveEffectiveAutoMerge } from "../../../core/src/merge/task-merge";
|
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 { RevertTaskOptions, RevertTaskResult, ModelInfo, NodeInfo } from "../api";
|
||||||
import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
||||||
import { WorkflowIcon } from "./WorkflowIcon";
|
import { WorkflowIcon } from "./WorkflowIcon";
|
||||||
@@ -394,6 +394,9 @@ export interface TaskDetailModalProps {
|
|||||||
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
|
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
|
||||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||||
onRetryTask?: (id: string) => Promise<Task>;
|
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:
|
FNXC:ReviewLaneBypass 2026-07-09-00:00:
|
||||||
Operator-only review-lane bypass (FN-7720). Only wired here (Task Detail) so
|
Operator-only review-lane bypass (FN-7720). Only wired here (Task Detail) so
|
||||||
@@ -782,6 +785,8 @@ export function TaskDetailContent({
|
|||||||
onRevertTask,
|
onRevertTask,
|
||||||
onMergeTask,
|
onMergeTask,
|
||||||
onRetryTask,
|
onRetryTask,
|
||||||
|
onPauseTask,
|
||||||
|
onUnpauseTask,
|
||||||
onBypassReview,
|
onBypassReview,
|
||||||
onResetTask,
|
onResetTask,
|
||||||
onDuplicateTask,
|
onDuplicateTask,
|
||||||
@@ -3446,18 +3451,21 @@ export function TaskDetailContent({
|
|||||||
|
|
||||||
const handleTogglePause = useCallback(async () => {
|
const handleTogglePause = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (isTaskPaused) {
|
const lifecycleOperation = isTaskPaused ? onUnpauseTask : onPauseTask;
|
||||||
await unpauseTask(task.id, projectId);
|
if (!lifecycleOperation) return;
|
||||||
addToast(t("taskDetail.pause.unpaused", "Unpaused {{id}}", { id: task.id }), "success");
|
const updatedTask = await lifecycleOperation(task.id);
|
||||||
} else {
|
onTaskUpdated?.(updatedTask);
|
||||||
await pauseTask(task.id, projectId);
|
addToast(
|
||||||
addToast(t("taskDetail.pause.paused", "Paused {{id}}", { id: task.id }), "success");
|
isTaskPaused
|
||||||
}
|
? t("taskDetail.pause.unpaused", "Unpaused {{id}}", { id: task.id })
|
||||||
|
: t("taskDetail.pause.paused", "Paused {{id}}", { id: task.id }),
|
||||||
|
"success",
|
||||||
|
);
|
||||||
requestClose();
|
requestClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(getErrorMessage(err), "error");
|
addToast(getErrorMessage(err), "error");
|
||||||
}
|
}
|
||||||
}, [isTaskPaused, task.id, requestClose, addToast]);
|
}, [isTaskPaused, onPauseTask, onTaskUpdated, onUnpauseTask, task.id, requestClose, addToast, t]);
|
||||||
|
|
||||||
const handleApprovePlan = useCallback(async () => {
|
const handleApprovePlan = useCallback(async () => {
|
||||||
try {
|
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
|
"Oversight actions", which also matches `/actions/i` and made every such
|
||||||
query ambiguous once the trigger stopped being a mobile-only affordance.
|
query ambiguous once the trigger stopped being a mobile-only affordance.
|
||||||
*/
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
@@ -25,6 +26,28 @@ import {
|
|||||||
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
|
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
|
||||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||||
import { readBoardWorkflowSelection, removeBoardWorkflowSelection, writeBoardWorkflowSelection } from "../../utils/boardWorkflowSelection";
|
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();
|
setupTaskDetailModalHooks();
|
||||||
|
|
||||||
@@ -936,10 +959,8 @@ describe("TaskDetailModal", () => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mobile task popup Actions menu selects a tapped item once and dismisses", async () => {
|
it("mobile task popup Actions menu selects the shared pause callback once and dismisses", async () => {
|
||||||
const { pauseTask } = await import("../../api");
|
const onPauseTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-001", paused: true }) as Task);
|
||||||
const mockPauseTask = vi.mocked(pauseTask);
|
|
||||||
mockPauseTask.mockResolvedValueOnce(makeTask({ id: "FN-001", paused: true }) as Task);
|
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -952,6 +973,7 @@ describe("TaskDetailModal", () => {
|
|||||||
onDeleteTask={noopDelete}
|
onDeleteTask={noopDelete}
|
||||||
onMergeTask={noopMerge}
|
onMergeTask={noopMerge}
|
||||||
onOpenDetail={noopOpenDetail}
|
onOpenDetail={noopOpenDetail}
|
||||||
|
onPauseTask={onPauseTask}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -961,8 +983,8 @@ describe("TaskDetailModal", () => {
|
|||||||
|
|
||||||
fireEvent.pointerUp(pauseItem, { pointerType: "touch", pointerId: 1 });
|
fireEvent.pointerUp(pauseItem, { pointerType: "touch", pointerId: 1 });
|
||||||
|
|
||||||
await waitFor(() => expect(mockPauseTask).toHaveBeenCalledWith("FN-001", undefined));
|
await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001"));
|
||||||
expect(mockPauseTask).toHaveBeenCalledTimes(1);
|
expect(onPauseTask).toHaveBeenCalledTimes(1);
|
||||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||||
expect(addToast).toHaveBeenCalledWith("Paused FN-001", "success");
|
expect(addToast).toHaveBeenCalledWith("Paused FN-001", "success");
|
||||||
});
|
});
|
||||||
@@ -1138,9 +1160,8 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeTruthy();
|
expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders Unpause for userPaused-only tasks and unpauses once", async () => {
|
it("renders Unpause for userPaused-only tasks and calls the shared lifecycle once", async () => {
|
||||||
const { unpauseTask } = await import("../../api");
|
const onUnpauseTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-001", paused: false, userPaused: false }) as Task);
|
||||||
const mockUnpauseTask = vi.mocked(unpauseTask);
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -1151,6 +1172,7 @@ describe("TaskDetailModal", () => {
|
|||||||
onDeleteTask={noopDelete}
|
onDeleteTask={noopDelete}
|
||||||
onMergeTask={noopMerge}
|
onMergeTask={noopMerge}
|
||||||
onOpenDetail={noopOpenDetail}
|
onOpenDetail={noopOpenDetail}
|
||||||
|
onUnpauseTask={onUnpauseTask}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -1159,17 +1181,28 @@ describe("TaskDetailModal", () => {
|
|||||||
await userEvent.click(screen.getByRole("menuitem", { name: "Unpause" }));
|
await userEvent.click(screen.getByRole("menuitem", { name: "Unpause" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUnpauseTask).toHaveBeenCalledTimes(1);
|
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
|
||||||
expect(mockUnpauseTask).toHaveBeenCalledWith("FN-001", undefined);
|
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 () => {
|
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 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);
|
mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "active" } as any);
|
||||||
mockUnpauseTask.mockClear();
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -1180,6 +1213,7 @@ describe("TaskDetailModal", () => {
|
|||||||
onDeleteTask={noopDelete}
|
onDeleteTask={noopDelete}
|
||||||
onMergeTask={noopMerge}
|
onMergeTask={noopMerge}
|
||||||
onOpenDetail={noopOpenDetail}
|
onOpenDetail={noopOpenDetail}
|
||||||
|
onUnpauseTask={onUnpauseTask}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -1192,8 +1226,8 @@ describe("TaskDetailModal", () => {
|
|||||||
await userEvent.click(screen.getByRole("menuitem", { name: "Unpause" }));
|
await userEvent.click(screen.getByRole("menuitem", { name: "Unpause" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUnpauseTask).toHaveBeenCalledTimes(1);
|
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
|
||||||
expect(mockUnpauseTask).toHaveBeenCalledWith("FN-ASSIGNED", undefined);
|
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 () => {
|
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 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);
|
mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "active" } as any);
|
||||||
mockPauseTask.mockClear();
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -1241,6 +1274,7 @@ describe("TaskDetailModal", () => {
|
|||||||
onDeleteTask={noopDelete}
|
onDeleteTask={noopDelete}
|
||||||
onMergeTask={noopMerge}
|
onMergeTask={noopMerge}
|
||||||
onOpenDetail={noopOpenDetail}
|
onOpenDetail={noopOpenDetail}
|
||||||
|
onPauseTask={onPauseTask}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -1253,8 +1287,8 @@ describe("TaskDetailModal", () => {
|
|||||||
await userEvent.click(screen.getByRole("menuitem", { name: "Pause" }));
|
await userEvent.click(screen.getByRole("menuitem", { name: "Pause" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockPauseTask).toHaveBeenCalledTimes(1);
|
expect(onPauseTask).toHaveBeenCalledTimes(1);
|
||||||
expect(mockPauseTask).toHaveBeenCalledWith("FN-ASSIGNED", undefined);
|
expect(onPauseTask).toHaveBeenCalledWith("FN-ASSIGNED");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -908,6 +908,8 @@ export function MainContent({
|
|||||||
onReviseTask={(task) => modalManager.openNewTaskWithDescription(task.description)}
|
onReviseTask={(task) => modalManager.openNewTaskWithDescription(task.description)}
|
||||||
onMergeTask={mergeTask}
|
onMergeTask={mergeTask}
|
||||||
onRetryTask={retryTask}
|
onRetryTask={retryTask}
|
||||||
|
onPauseTask={pauseTask}
|
||||||
|
onUnpauseTask={unpauseTask}
|
||||||
onResetTask={resetTask}
|
onResetTask={resetTask}
|
||||||
onDuplicateTask={duplicateTask}
|
onDuplicateTask={duplicateTask}
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface RightDockControllerInput {
|
|||||||
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
|
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
|
||||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||||
onRetryTask?: (id: string) => Promise<Task>;
|
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. */
|
/* 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>;
|
onBypassReview?: (id: string, reason: string) => Promise<Task>;
|
||||||
onResetTask?: (id: string) => Promise<Task>;
|
onResetTask?: (id: string) => Promise<Task>;
|
||||||
@@ -272,6 +274,8 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
|
|||||||
onRevertTask={input.onRevertTask}
|
onRevertTask={input.onRevertTask}
|
||||||
onMergeTask={input.onMergeTask}
|
onMergeTask={input.onMergeTask}
|
||||||
onRetryTask={input.onRetryTask}
|
onRetryTask={input.onRetryTask}
|
||||||
|
onPauseTask={input.onPauseTask}
|
||||||
|
onUnpauseTask={input.onUnpauseTask}
|
||||||
onBypassReview={input.onBypassReview}
|
onBypassReview={input.onBypassReview}
|
||||||
onResetTask={input.onResetTask}
|
onResetTask={input.onResetTask}
|
||||||
onDuplicateTask={input.onDuplicateTask}
|
onDuplicateTask={input.onDuplicateTask}
|
||||||
|
|||||||
@@ -2140,6 +2140,63 @@ describe("useTasks", () => {
|
|||||||
expect(result.current.tasks).toEqual([unpaused, keep]);
|
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 () => {
|
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 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 });
|
const pausedMissing = createMockTask({ id: "FN-MISSING", column: "todo" as Column, paused: true, userPaused: true });
|
||||||
|
|||||||
@@ -1113,74 +1113,59 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:DashboardPauseState 2026-07-12-00:00:
|
FNXC:DashboardPauseState 2026-08-05-07:18:
|
||||||
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.
|
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 reconcileConfirmedTask = useCallback((confirmedTask: Task): Task => {
|
||||||
const updatedTask = normalizeTask(await api.pauseTask(id, projectId));
|
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++;
|
fetchVersionRef.current++;
|
||||||
|
const replaceConfirmedTask = (currentTasks: Task[]) =>
|
||||||
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? updatedTask : task));
|
currentTasks.map((task) => task.id === updatedTask.id ? mergeIncomingTask(updatedTask, task) : task);
|
||||||
|
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
|
const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`;
|
||||||
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
|
const cachedTasks = readCache<unknown>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
|
||||||
if (Array.isArray(cachedTasks)) {
|
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) {
|
if (cacheContainsOnlyTaskRows) {
|
||||||
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? updatedTask : normalizeTask(task as Task)));
|
const nextCachedTasks = cachedTasks.map((task) =>
|
||||||
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
|
(task as Task).id === updatedTask.id ? updatedTask : normalizeTask(task as Task),
|
||||||
|
);
|
||||||
|
writeTaskCacheSnapshot(cacheKey, nextCachedTasks);
|
||||||
} else {
|
} else {
|
||||||
clearCache(cacheKey);
|
clearCache(cacheKey);
|
||||||
}
|
}
|
||||||
} else if (cachedTasks === null) {
|
} else if (cachedTasks === null) {
|
||||||
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
|
writeTaskCacheSnapshot(cacheKey, replaceConfirmedTask(tasksRef.current));
|
||||||
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
|
|
||||||
} else {
|
} else {
|
||||||
clearCache(cacheKey);
|
clearCache(cacheKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTasks((prev) => {
|
setTasks((previousTasks) => {
|
||||||
const next = projectUpdatedTasks(prev);
|
const nextTasks = replaceConfirmedTask(previousTasks);
|
||||||
tasksRef.current = next;
|
tasksRef.current = nextTasks;
|
||||||
return next;
|
return nextTasks;
|
||||||
});
|
});
|
||||||
return updatedTask;
|
return updatedTask;
|
||||||
}, [projectId]);
|
}, [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 unpauseTask = useCallback(async (id: string): Promise<Task> => {
|
||||||
const updatedTask = normalizeTask(await api.unpauseTask(id, projectId));
|
return reconcileConfirmedTask(await api.unpauseTask(id, projectId));
|
||||||
fetchVersionRef.current++;
|
}, [projectId, reconcileConfirmedTask]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const deleteTask = useCallback(async (
|
const deleteTask = useCallback(async (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user