diff --git a/.changeset/fn-7861-immediate-pause-state.md b/.changeset/fn-7861-immediate-pause-state.md new file mode 100644 index 0000000000..22086955c2 --- /dev/null +++ b/.changeset/fn-7861-immediate-pause-state.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Unpausing (and pausing) a task now updates the board immediately. +category: fix +dev: useTasks pauseTask/unpauseTask patch shared task state + SWR cache on API success (FN-7861), mirroring retryTask/bypassReview; no longer waits for SSE/poll. diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index d43a4fc9c9..55b7b47f31 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -34,6 +34,8 @@ vi.mock("../../api", async (importOriginal) => { mergeTask: vi.fn(), retryTask: vi.fn(), bypassReview: vi.fn(), + pauseTask: vi.fn(), + unpauseTask: vi.fn(), duplicateTask: vi.fn(), updateTask: vi.fn(), archiveTask: vi.fn(), @@ -53,6 +55,8 @@ const mockCreateTask = vi.mocked(api.createTask); const mockDeleteTask = vi.mocked(api.deleteTask); const mockRetryTask = vi.mocked(api.retryTask); const mockBypassReview = vi.mocked(api.bypassReview); +const mockPauseTask = vi.mocked(api.pauseTask); +const mockUnpauseTask = vi.mocked(api.unpauseTask); const mockDuplicateTask = vi.mocked(api.duplicateTask); const mockUpdateTask = vi.mocked(api.updateTask); const mockArchiveAllDone = vi.mocked(api.archiveAllDone); @@ -104,6 +108,8 @@ beforeEach(() => { mockFetchArchivedTasks.mockReset().mockResolvedValue({ tasks: [], total: 0, hasMore: false }); mockDeleteTask.mockReset(); mockRetryTask.mockReset(); + mockPauseTask.mockReset(); + mockUnpauseTask.mockReset(); mockReadCache.mockReset(); mockWriteCache.mockReset(); mockClearCache.mockReset(); @@ -1830,6 +1836,151 @@ describe("useTasks", () => { }); }); + describe("pauseTask and unpauseTask", () => { + it("FN-7861 immediately reflects unpaused state locally and in the project SWR cache without SSE", async () => { + const paused = createMockTask({ + id: "FN-PAUSE", + column: "todo" as Column, + paused: true, + userPaused: true, + pausedByAgentId: null, + pausedReason: "operator", + }); + const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column, paused: false, userPaused: false }); + const unpaused = createMockTask({ + ...paused, + paused: false, + userPaused: false, + pausedByAgentId: null, + pausedReason: null, + updatedAt: "2026-07-12T00:00:00.000Z", + }); + mockFetchTasks.mockResolvedValueOnce([paused, keep]); + mockUnpauseTask.mockResolvedValueOnce(unpaused); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(2)); + mockReadCache.mockClear(); + mockWriteCache.mockClear(); + mockClearCache.mockClear(); + mockReadCache.mockReturnValueOnce([paused, keep]); + + let returned: Task | undefined; + await act(async () => { + returned = await result.current.unpauseTask("FN-PAUSE"); + }); + + expect(mockUnpauseTask).toHaveBeenCalledWith("FN-PAUSE", "proj-1"); + expect(returned).toEqual(expect.objectContaining({ id: "FN-PAUSE", paused: false, userPaused: false })); + expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")).toEqual(unpaused); + expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.paused).toBe(false); + expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.userPaused).toBe(false); + expect(mockFetchTasks).toHaveBeenCalledTimes(1); + expect(mockWriteCache).toHaveBeenCalledWith( + `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`, + [unpaused, keep], + { maxBytes: 500_000 }, + ); + expect(mockClearCache).not.toHaveBeenCalled(); + }); + + it("FN-7861 immediately reflects paused state locally and in the project SWR cache without SSE", async () => { + const unpaused = createMockTask({ + id: "FN-PAUSE", + column: "todo" as Column, + paused: false, + userPaused: false, + pausedByAgentId: null, + pausedReason: null, + }); + const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column, paused: false, userPaused: false }); + const paused = createMockTask({ + ...unpaused, + paused: true, + userPaused: true, + pausedByAgentId: null, + pausedReason: "operator", + updatedAt: "2026-07-12T00:01:00.000Z", + }); + mockFetchTasks.mockResolvedValueOnce([unpaused, keep]); + mockPauseTask.mockResolvedValueOnce(paused); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(2)); + mockReadCache.mockClear(); + mockWriteCache.mockClear(); + mockClearCache.mockClear(); + mockReadCache.mockReturnValueOnce([unpaused, keep]); + + let returned: Task | undefined; + await act(async () => { + returned = await result.current.pauseTask("FN-PAUSE"); + }); + + expect(mockPauseTask).toHaveBeenCalledWith("FN-PAUSE", "proj-1"); + expect(returned).toEqual(expect.objectContaining({ id: "FN-PAUSE", paused: true, userPaused: true })); + expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")).toEqual(paused); + expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.paused).toBe(true); + expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.userPaused).toBe(true); + expect(mockFetchTasks).toHaveBeenCalledTimes(1); + expect(mockWriteCache).toHaveBeenCalledWith( + `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`, + [paused, keep], + { maxBytes: 500_000 }, + ); + expect(mockClearCache).not.toHaveBeenCalled(); + }); + + it("does not let an older in-flight fetch restore stale paused state after unpause", async () => { + const paused = createMockTask({ id: "FN-PAUSE", column: "todo" as Column, paused: true, userPaused: true }); + const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column }); + const unpaused = createMockTask({ ...paused, paused: false, userPaused: false, updatedAt: "2026-07-12T00:02:00.000Z" }); + let resolveRefresh!: (tasks: Task[]) => void; + mockReadCache.mockReturnValue([paused, keep]); + mockFetchTasks.mockImplementationOnce(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + mockUnpauseTask.mockResolvedValueOnce(unpaused); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-PAUSE", "FN-KEEP"]); + await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1)); + + await act(async () => { + await result.current.unpauseTask("FN-PAUSE"); + }); + + expect(result.current.tasks).toEqual([unpaused, keep]); + + await act(async () => { + resolveRefresh([paused, keep]); + await flushPromises(); + }); + + expect(result.current.tasks).toEqual([unpaused, keep]); + }); + + 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 }); + mockFetchTasks.mockResolvedValueOnce([keep]); + mockPauseTask.mockResolvedValueOnce(pausedMissing); + + const { result } = renderHook(() => useTasks()); + + await waitFor(() => expect(result.current.tasks).toEqual([keep])); + + await act(async () => { + await result.current.pauseTask("FN-MISSING"); + }); + + expect(result.current.tasks).toEqual([keep]); + }); + }); + describe("retryTask", () => { it("FN-7295 immediately replaces every matching local retry task without SSE or refresh", async () => { const failedOne = createMockTask({ diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 8ba0dfb5bd..f2e6fb1b66 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -744,12 +744,74 @@ export function useTasks(options?: UseTasksOptions) { return normalizeTask(await api.moveTask(id, column, projectId, optionsOrPosition)); }, [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. + */ const pauseTask = useCallback(async (id: string): Promise => { - return normalizeTask(await api.pauseTask(id, projectId)); + const updatedTask = normalizeTask(await api.pauseTask(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]); const unpauseTask = useCallback(async (id: string): Promise => { - return normalizeTask(await api.unpauseTask(id, projectId)); + 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]); const deleteTask = useCallback(async (