diff --git a/.changeset/fn-8801-immediate-unpause-state.md b/.changeset/fn-8801-immediate-unpause-state.md new file mode 100644 index 0000000000..58aa1f76d2 --- /dev/null +++ b/.changeset/fn-8801-immediate-unpause-state.md @@ -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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 7a18f30292..79b3c5c2a8 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1567,7 +1567,7 @@ function AppInner() { // Props for the extracted 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} diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 0023789ee2..502877a187 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -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; retryTask: (taskId: string) => Promise; + pauseTask: (taskId: string) => Promise; + unpauseTask: (taskId: string) => Promise; /* FNXC:ReviewLaneBypass 2026-07-09-00:00 (FN-7720): operator-only review-lane bypass, threaded to TaskDetailModal only. */ bypassReview?: (taskId: string, reason: string) => Promise; resetTask: (taskId: string) => Promise; @@ -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} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index f73cc1acbc..d14d8e2db6 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -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} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index f33142def7..17d4f1bfef 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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; onMergeTask: (id: string) => Promise; onRetryTask?: (id: string) => Promise; + /** Shared lifecycle operations reconcile confirmed rows before detail hosts render their next frame. */ + onPauseTask?: (id: string) => Promise; + onUnpauseTask?: (id: string) => Promise; /* 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 { diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx index 06ae93464a..e92de2a42b 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx @@ -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 ( + + ); +} 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( { 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(); + + 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( { 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( { 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"); }); }); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 5d353d9281..6517e5f9c1 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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} /* diff --git a/packages/dashboard/app/components/useRightDockController.tsx b/packages/dashboard/app/components/useRightDockController.tsx index cdad7db576..a37b05ca63 100644 --- a/packages/dashboard/app/components/useRightDockController.tsx +++ b/packages/dashboard/app/components/useRightDockController.tsx @@ -43,6 +43,8 @@ export interface RightDockControllerInput { onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise; onMergeTask: (id: string) => Promise; onRetryTask?: (id: string) => Promise; + onPauseTask?: (id: string) => Promise; + onUnpauseTask?: (id: string) => Promise; /* 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; onResetTask?: (id: string) => Promise; @@ -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} diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index c51dd29abb..537f11cb4b 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -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((resolve) => { resolveUnpause = resolve; })); + + const { result } = renderHook(() => useTasks()); + await waitFor(() => expect(result.current.tasks).toEqual([paused])); + + let mutation!: Promise; + 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 }); diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index e415e7aeae..640796146c 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -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 => { - 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(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 => { + return reconcileConfirmedTask(await api.pauseTask(id, projectId)); + }, [projectId, reconcileConfirmedTask]); + const unpauseTask = useCallback(async (id: string): Promise => { - 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(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,