Commit dc082c590da for woocommerce

commit dc082c590dae4db1c2294b64d0353722d2dc282e
Author: Poli Gilad <83961704+poligilad-auto@users.noreply.github.com>
Date:   Fri Aug 28 08:48:08 2026 +0200

    Add skip action to Things to do next tasks (#66199)

    * Add skip action to Things to do next tasks

    * Add changelog entry for task skipping

    * Harden Things to do next task dismissal

    * Restore caught-up task list copy

    * Serialize task dismissal requests

    * Scope task dismissal to its task list

    The Things to do next "additional payments" task shares its ID
    ("payments") with the core, non-dismissable Payments task in the setup
    list. The dismiss/undo endpoints resolved the task by ID across all
    lists, matching the non-dismissable task first and returning a 404, so
    the skip action surfaced an error notice.

    Pass the task list ID through dismissTask/undoDismissTask to the REST
    endpoints so the correct task is resolved, mirroring the existing snooze
    endpoint. Falls back to the previous cross-list lookup when no list ID
    is supplied.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * Reject unknown task list ID when dismissing a task

    An unregistered task_list_id caused the scoped lookup to miss and fall
    through to the DeprecatedExtendedTask path, reporting a successful
    dismiss or undo for a task outside the requested scope. Return a 404 for
    an unknown list ID before that fallback; the unscoped legacy lookup is
    unchanged when no list ID is supplied.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * Validate supplied task list ID with a null check

    PHP casts "0" (and "") to false, so the truthiness guard skipped
    validation for those values and let the request fall through to an
    unscoped lookup. Check null !== $task_list_id so any supplied list ID is
    validated, and cover the "0" case for dismiss and undo.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * Fix dismiss_task/undo_dismiss_task return type docblocks

    The @return docblocks referenced WP_REST_Request instead of the
    WP_REST_Response actually returned by rest_ensure_response(), and left
    the WordPress REST classes unqualified so PHPStan resolved them to the
    wrong namespace. Fully qualify the classes and type the request
    parameter, which resolves the pre-existing baselined errors for these
    methods as well as the ones added here; the baseline shrinks to match.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * Scope task dismissal to the actioned task list

    Dismissing a task passed the task list ID to the REST call, but the
    store actions dropped it, so getUpdatedTaskLists still matched by task
    ID across every list. AdditionalPayments and the setup list's Payments
    task share the `payments` ID, so skipping the extended one also marked
    the setup checklist task dismissed.

    Thread the list ID through the dismiss and undo request, success, and
    error actions, and scope the reducer update to that list. Dismissals
    with no list ID keep the previous cross-list behavior.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Stop Enter on Skip from also opening the task

    onSkip stopped propagation on the click event only, so the keydown still
    reached ExperimentalListItem's handler, which fires the row's onClick on
    Enter. Skipping with the keyboard dismissed the task and navigated to the
    task page, leaving no Undo anywhere.

    Stop the keydown on the Skip button. The mocked TaskItem now reproduces
    the row's button role and Enter handling so the regression test exercises
    the real behavior.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Fix skipped placeholder semantics and extended list matching

    Three follow-ups from review:

    - The skipped placeholder was a div directly inside the List's ul, so it
      read as a bare generic among listitem siblings and picked up a stray
      animation attribute from List's cloneElement. Render it as a ListItem,
      which keeps the semantics and the existing geometry.
    - Stop the keydown on Undo as well, so the placeholder stays inert on
      Enter if the row ever gains a click handler.
    - Skip, the completed-task filter, and the empty state matched the list
      ID `extended` exactly, while TaskLists::maybe_add_extended_tasks()
      treats any ID starting with `extended` as an extended list. Match on
      the prefix instead, and move the styles onto a stable
      woocommerce-task-list--extended modifier so they apply to those lists
      too.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Give task list item fills the skip action

    taskItemProps flows into the fill props, so a fill that composes its own
    TaskItem receives the actions the default row would have rendered. Skip
    was built inside DefaultTaskItem instead, so an extended-list fill saw
    onDismiss suppressed with nothing in its place: a third-party task with
    is_dismissable set lost Dismiss and gained no Skip.

    Move the Skip button onto taskItemProps so it travels with the props it
    replaces. The spread order inside DefaultTaskItem is unchanged, so a
    fill's own secondaryAction still wins.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Fix prettier formatting in the onboarding reducer test

    The assertion parentheses in the task and taskList helpers were formatted
    as `} as TaskType )` where prettier expects `} ) as TaskType`, failing the
    @woocommerce/data lint job.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Jill Quek <jill.quek@automattic.com>
    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

diff --git a/packages/js/data/changelog/fix-dismiss-task-list-scope b/packages/js/data/changelog/fix-dismiss-task-list-scope
new file mode 100644
index 00000000000..97f19c2b9af
--- /dev/null
+++ b/packages/js/data/changelog/fix-dismiss-task-list-scope
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Scope onboarding task dismissal to the supplied task list so a task ID that exists in more than one list is only dismissed or restored in the list it was actioned from.
diff --git a/packages/js/data/src/onboarding/actions.ts b/packages/js/data/src/onboarding/actions.ts
index f6d9fc6df65..cd34c525c29 100644
--- a/packages/js/data/src/onboarding/actions.ts
+++ b/packages/js/data/src/onboarding/actions.ts
@@ -3,6 +3,7 @@
  */
 import { apiFetch } from '@wordpress/data-controls';
 import { controls, dispatch } from '@wordpress/data';
+import { addQueryArgs } from '@wordpress/url';

 /**
  * Internal dependencies
@@ -123,47 +124,67 @@ export function undoSnoozeTaskSuccess( task: Partial< TaskType > ) {
 	};
 }

-export function dismissTaskError( taskId: string, error: unknown ) {
+export function dismissTaskError(
+	taskId: string,
+	error: unknown,
+	taskListId?: string
+) {
 	return {
 		type: TYPES.DISMISS_TASK_ERROR,
 		taskId,
 		error,
+		taskListId,
 	};
 }

-export function dismissTaskRequest( taskId: string ) {
+export function dismissTaskRequest( taskId: string, taskListId?: string ) {
 	return {
 		type: TYPES.DISMISS_TASK_REQUEST,
 		taskId,
+		taskListId,
 	};
 }

-export function dismissTaskSuccess( task: Partial< TaskType > ) {
+export function dismissTaskSuccess(
+	task: Partial< TaskType >,
+	taskListId?: string
+) {
 	return {
 		type: TYPES.DISMISS_TASK_SUCCESS,
 		task,
+		taskListId,
 	};
 }

-export function undoDismissTaskError( taskId: string, error: unknown ) {
+export function undoDismissTaskError(
+	taskId: string,
+	error: unknown,
+	taskListId?: string
+) {
 	return {
 		type: TYPES.UNDO_DISMISS_TASK_ERROR,
 		taskId,
 		error,
+		taskListId,
 	};
 }

-export function undoDismissTaskRequest( taskId: string ) {
+export function undoDismissTaskRequest( taskId: string, taskListId?: string ) {
 	return {
 		type: TYPES.UNDO_DISMISS_TASK_REQUEST,
 		taskId,
+		taskListId,
 	};
 }

-export function undoDismissTaskSuccess( task: Partial< TaskType > ) {
+export function undoDismissTaskSuccess(
+	task: Partial< TaskType >,
+	taskListId?: string
+) {
 	return {
 		type: TYPES.UNDO_DISMISS_TASK_SUCCESS,
 		task,
+		taskListId,
 	};
 }

@@ -430,12 +451,15 @@ export function* undoSnoozeTask( id: string ) {
 	}
 }

-export function* dismissTask( id: string ) {
-	yield dismissTaskRequest( id );
+export function* dismissTask( id: string, taskListId?: string ) {
+	yield dismissTaskRequest( id, taskListId );

 	try {
 		const task: TaskType = yield apiFetch( {
-			path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/dismiss`,
+			path: addQueryArgs(
+				`${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/dismiss`,
+				taskListId ? { task_list_id: taskListId } : {}
+			),
 			method: 'POST',
 		} );

@@ -443,20 +467,24 @@ export function* dismissTask( id: string ) {
 			DeprecatedTasks.possiblyPruneTaskData( task, [
 				'isDismissed',
 				'isSnoozed',
-			] )
+			] ),
+			taskListId
 		);
 	} catch ( error ) {
-		yield dismissTaskError( id, error );
+		yield dismissTaskError( id, error, taskListId );
 		throw new Error();
 	}
 }

-export function* undoDismissTask( id: string ) {
-	yield undoDismissTaskRequest( id );
+export function* undoDismissTask( id: string, taskListId?: string ) {
+	yield undoDismissTaskRequest( id, taskListId );

 	try {
 		const task: TaskType = yield apiFetch( {
-			path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/undo_dismiss`,
+			path: addQueryArgs(
+				`${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/undo_dismiss`,
+				taskListId ? { task_list_id: taskListId } : {}
+			),
 			method: 'POST',
 		} );

@@ -464,10 +492,11 @@ export function* undoDismissTask( id: string ) {
 			DeprecatedTasks.possiblyPruneTaskData( task, [
 				'isDismissed',
 				'isSnoozed',
-			] )
+			] ),
+			taskListId
 		);
 	} catch ( error ) {
-		yield undoDismissTaskError( id, error );
+		yield undoDismissTaskError( id, error, taskListId );
 		throw new Error();
 	}
 }
diff --git a/packages/js/data/src/onboarding/reducer.ts b/packages/js/data/src/onboarding/reducer.ts
index a5f5eba6f17..560cb6618ea 100644
--- a/packages/js/data/src/onboarding/reducer.ts
+++ b/packages/js/data/src/onboarding/reducer.ts
@@ -44,9 +44,20 @@ export const defaultState: OnboardingState = {

 const getUpdatedTaskLists = (
 	taskLists: Record< string, TaskListType >,
-	args: Partial< TaskType >
+	args: Partial< TaskType >,
+	// When supplied, only this task list is updated. The same task ID can exist
+	// in more than one list, so an unscoped update would also change the task
+	// in every other list that happens to share the ID.
+	scopedTaskListId?: string
 ) => {
-	return Object.keys( taskLists ).reduce(
+	const taskListIds =
+		scopedTaskListId === undefined
+			? Object.keys( taskLists )
+			: Object.keys( taskLists ).filter(
+					( taskListId ) => taskListId === scopedTaskListId
+			  );
+
+	return taskListIds.reduce(
 		( lists, taskListId ) => {
 			return {
 				...lists,
@@ -162,10 +173,14 @@ const reducer: Reducer< OnboardingState, Action > = (
 					...state.errors,
 					dismissTask: action.error,
 				},
-				taskLists: getUpdatedTaskLists( state.taskLists, {
-					id: action.taskId,
-					isDismissed: false,
-				} ),
+				taskLists: getUpdatedTaskLists(
+					state.taskLists,
+					{
+						id: action.taskId,
+						isDismissed: false,
+					},
+					action.taskListId
+				),
 			};
 		case TYPES.DISMISS_TASK_REQUEST:
 			return {
@@ -174,10 +189,14 @@ const reducer: Reducer< OnboardingState, Action > = (
 					...state.requesting,
 					dismissTask: true,
 				},
-				taskLists: getUpdatedTaskLists( state.taskLists, {
-					id: action.taskId,
-					isDismissed: true,
-				} ),
+				taskLists: getUpdatedTaskLists(
+					state.taskLists,
+					{
+						id: action.taskId,
+						isDismissed: true,
+					},
+					action.taskListId
+				),
 			};
 		case TYPES.DISMISS_TASK_SUCCESS:
 			return {
@@ -186,7 +205,11 @@ const reducer: Reducer< OnboardingState, Action > = (
 					...state.requesting,
 					dismissTask: false,
 				},
-				taskLists: getUpdatedTaskLists( state.taskLists, action.task ),
+				taskLists: getUpdatedTaskLists(
+					state.taskLists,
+					action.task,
+					action.taskListId
+				),
 			};
 		case TYPES.UNDO_DISMISS_TASK_ERROR:
 			return {
@@ -195,10 +218,14 @@ const reducer: Reducer< OnboardingState, Action > = (
 					...state.errors,
 					undoDismissTask: action.error,
 				},
-				taskLists: getUpdatedTaskLists( state.taskLists, {
-					id: action.taskId,
-					isDismissed: true,
-				} ),
+				taskLists: getUpdatedTaskLists(
+					state.taskLists,
+					{
+						id: action.taskId,
+						isDismissed: true,
+					},
+					action.taskListId
+				),
 			};
 		case TYPES.UNDO_DISMISS_TASK_REQUEST:
 			return {
@@ -207,10 +234,14 @@ const reducer: Reducer< OnboardingState, Action > = (
 					...state.requesting,
 					undoDismissTask: true,
 				},
-				taskLists: getUpdatedTaskLists( state.taskLists, {
-					id: action.taskId,
-					isDismissed: false,
-				} ),
+				taskLists: getUpdatedTaskLists(
+					state.taskLists,
+					{
+						id: action.taskId,
+						isDismissed: false,
+					},
+					action.taskListId
+				),
 			};
 		case TYPES.UNDO_DISMISS_TASK_SUCCESS:
 			return {
@@ -219,7 +250,11 @@ const reducer: Reducer< OnboardingState, Action > = (
 					...state.requesting,
 					undoDismissTask: false,
 				},
-				taskLists: getUpdatedTaskLists( state.taskLists, action.task ),
+				taskLists: getUpdatedTaskLists(
+					state.taskLists,
+					action.task,
+					action.taskListId
+				),
 			};
 		case TYPES.SNOOZE_TASK_ERROR:
 			return {
diff --git a/packages/js/data/src/onboarding/test/reducer.ts b/packages/js/data/src/onboarding/test/reducer.ts
index b54df15cb80..4d4f3760997 100644
--- a/packages/js/data/src/onboarding/test/reducer.ts
+++ b/packages/js/data/src/onboarding/test/reducer.ts
@@ -7,6 +7,7 @@
  */
 import reducer, { defaultState } from '../reducer';
 import TYPES from '../action-types';
+import { TaskListType, TaskType } from '../types';

 const profileItems = {
 	business_extensions: [],
@@ -39,6 +40,53 @@ const paymentMethods = [
 	},
 ];

+const task = ( id: string, overrides = {} ) =>
+	( {
+		id,
+		title: id,
+		content: '',
+		parentId: '',
+		isComplete: false,
+		isDismissable: true,
+		isDismissed: false,
+		isSnoozed: false,
+		isInProgress: false,
+		inProgressLabel: '',
+		isVisible: true,
+		isSnoozeable: false,
+		isDisabled: false,
+		snoozedUntil: 0,
+		time: '',
+		isVisited: false,
+		additionalInfo: '',
+		canView: true,
+		isActioned: false,
+		eventPrefix: '',
+		level: 3,
+		recordViewEvent: false,
+		...overrides,
+	} ) as TaskType;
+
+const taskList = ( id: string, tasks: TaskType[] ) =>
+	( {
+		id,
+		title: id,
+		isHidden: false,
+		isVisible: true,
+		isComplete: false,
+		tasks,
+		eventPrefix: '',
+		displayProgressHeader: false,
+		keepCompletedTaskList: 'no',
+	} ) as TaskListType;
+
+// `payments` exists in both the setup and extended lists, so an unscoped
+// update would dismiss the setup task too.
+const taskListsWithSharedTaskId = {
+	setup: taskList( 'setup', [ task( 'payments' ) ] ),
+	extended: taskList( 'extended', [ task( 'payments' ) ] ),
+};
+
 describe( 'plugins reducer', () => {
 	it( 'should return a default state', () => {
 		// @ts-expect-error - we're testing the default state
@@ -139,4 +187,55 @@ describe( 'plugins reducer', () => {
 		expect( state.requesting[ 'updateProfileItems' ] ).toBeTruthy();
 		/* eslint-enable dot-notation */
 	} );
+
+	it( 'should only dismiss the task in the supplied task list', () => {
+		const state = reducer(
+			{ ...defaultState, taskLists: taskListsWithSharedTaskId },
+			{
+				type: TYPES.DISMISS_TASK_REQUEST,
+				taskId: 'payments',
+				taskListId: 'extended',
+			}
+		);
+
+		expect( state.taskLists.extended.tasks[ 0 ].isDismissed ).toBe( true );
+		expect( state.taskLists.setup.tasks[ 0 ].isDismissed ).toBe( false );
+	} );
+
+	it( 'should only restore the task in the supplied task list', () => {
+		const dismissed = {
+			setup: taskList( 'setup', [
+				task( 'payments', { isDismissed: true } ),
+			] ),
+			extended: taskList( 'extended', [
+				task( 'payments', { isDismissed: true } ),
+			] ),
+		};
+
+		const state = reducer(
+			{ ...defaultState, taskLists: dismissed },
+			{
+				type: TYPES.UNDO_DISMISS_TASK_SUCCESS,
+				task: { id: 'payments', isDismissed: false },
+				taskListId: 'extended',
+			}
+		);
+
+		expect( state.taskLists.extended.tasks[ 0 ].isDismissed ).toBe( false );
+		expect( state.taskLists.setup.tasks[ 0 ].isDismissed ).toBe( true );
+	} );
+
+	it( 'should dismiss the task in every list when no task list is supplied', () => {
+		const state = reducer(
+			{ ...defaultState, taskLists: taskListsWithSharedTaskId },
+			{
+				type: TYPES.DISMISS_TASK_REQUEST,
+				taskId: 'payments',
+				taskListId: undefined,
+			}
+		);
+
+		expect( state.taskLists.extended.tasks[ 0 ].isDismissed ).toBe( true );
+		expect( state.taskLists.setup.tasks[ 0 ].isDismissed ).toBe( true );
+	} );
 } );
diff --git a/packages/js/experimental/changelog/add-dismiss-whats-next-tasks b/packages/js/experimental/changelog/add-dismiss-whats-next-tasks
new file mode 100644
index 00000000000..0ba6cdc1faf
--- /dev/null
+++ b/packages/js/experimental/changelog/add-dismiss-whats-next-tasks
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add support for secondary actions in experimental task list items.
diff --git a/packages/js/experimental/src/experimental-list/task-item/index.tsx b/packages/js/experimental/src/experimental-list/task-item/index.tsx
index 45621cee5a7..158c9a628f9 100644
--- a/packages/js/experimental/src/experimental-list/task-item/index.tsx
+++ b/packages/js/experimental/src/experimental-list/task-item/index.tsx
@@ -57,6 +57,7 @@ type TaskItemProps = {
 	actionLabel?: string;
 	className?: string;
 	children?: React.ReactNode;
+	secondaryAction?: React.ReactNode;
 };

 const OptionalTaskTooltip = ( {
@@ -133,6 +134,7 @@ export const TaskItem = ( {
 	level = 3,
 	action,
 	actionLabel,
+	secondaryAction,
 	...listItemProps
 }: TaskItemProps ) => {
 	const [ isTaskExpanded, setTaskExpanded ] = useState( expanded );
@@ -261,56 +263,65 @@ export const TaskItem = ( {
 					</div>
 				) }
 			</div>
-			{ showEllipsisMenu && (
-				<EllipsisMenu
-					label={ __( 'Task Options', 'woocommerce' ) }
-					className="woocommerce-task-list__item-after"
-					onToggle={ ( e: React.MouseEvent | React.KeyboardEvent ) =>
-						e.stopPropagation()
-					}
-					renderContent={ () => (
-						<div className="woocommerce-task-card__section-controls">
-							{ onDismiss && ! completed && (
-								<Button
-									onClick={ (
-										e:
-											| React.MouseEvent
-											| React.KeyboardEvent
-									) => {
-										e.stopPropagation();
-										onDismiss();
-									} }
-								>
-									{ __( 'Dismiss', 'woocommerce' ) }
-								</Button>
-							) }
-							{ onSnooze && ! completed && (
-								<Button
-									onClick={ ( e: React.MouseEvent ) => {
-										e.stopPropagation();
-										onSnooze();
-									} }
-								>
-									{ __( 'Remind me later', 'woocommerce' ) }
-								</Button>
-							) }
-							{ onDelete && completed && (
-								<Button
-									onClick={ (
-										e:
-											| React.MouseEvent
-											| React.KeyboardEvent
-									) => {
-										e.stopPropagation();
-										onDelete();
-									} }
-								>
-									{ __( 'Delete', 'woocommerce' ) }
-								</Button>
+			{ ( secondaryAction || showEllipsisMenu ) && (
+				<div className="woocommerce-task-list__item-after">
+					{ secondaryAction }
+					{ showEllipsisMenu && (
+						<EllipsisMenu
+							label={ __( 'Task Options', 'woocommerce' ) }
+							onToggle={ (
+								e: React.MouseEvent | React.KeyboardEvent
+							) => e.stopPropagation() }
+							renderContent={ () => (
+								<div className="woocommerce-task-card__section-controls">
+									{ onDismiss && ! completed && (
+										<Button
+											onClick={ (
+												e:
+													| React.MouseEvent
+													| React.KeyboardEvent
+											) => {
+												e.stopPropagation();
+												onDismiss();
+											} }
+										>
+											{ __( 'Dismiss', 'woocommerce' ) }
+										</Button>
+									) }
+									{ onSnooze && ! completed && (
+										<Button
+											onClick={ (
+												e: React.MouseEvent
+											) => {
+												e.stopPropagation();
+												onSnooze();
+											} }
+										>
+											{ __(
+												'Remind me later',
+												'woocommerce'
+											) }
+										</Button>
+									) }
+									{ onDelete && completed && (
+										<Button
+											onClick={ (
+												e:
+													| React.MouseEvent
+													| React.KeyboardEvent
+											) => {
+												e.stopPropagation();
+												onDelete();
+											} }
+										>
+											{ __( 'Delete', 'woocommerce' ) }
+										</Button>
+									) }
+								</div>
 							) }
-						</div>
+						/>
 					) }
-				/>
+				</div>
 			) }
 		</ListItem>
 	);
diff --git a/plugins/woocommerce/changelog/add-dismiss-whats-next-tasks b/plugins/woocommerce/changelog/add-dismiss-whats-next-tasks
new file mode 100644
index 00000000000..63c5eefc23f
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-dismiss-whats-next-tasks
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add a skip action for individual Things to do next tasks.
diff --git a/plugins/woocommerce/client/admin/client/task-lists/components/checklist.svg b/plugins/woocommerce/client/admin/client/task-lists/components/checklist.svg
new file mode 100644
index 00000000000..81570637c7c
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/task-lists/components/checklist.svg
@@ -0,0 +1,18 @@
+<svg width="88" height="88" viewBox="0 0 88 88" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_2950_17772)">
+<path d="M14.7036 14.3733L14.667 80.8807C14.667 85.4245 17.1998 88 21.7158 88H66.2441C70.7601 88 73.2929 85.4245 73.2929 80.8807L73.3296 14.3733C73.3296 9.82944 70.7967 7.25391 66.2808 7.25391H21.7565C17.2365 7.25391 14.7036 9.82944 14.7036 14.3733Z" fill="#E0E0E0"/>
+<path d="M55.164 7.25811V4.25061C55.164 1.54467 53.6329 -0.00390625 50.9005 -0.00390625H37.369C34.6366 -0.00390625 33.1055 1.5406 33.1055 4.25061V7.25811H55.1599H55.164Z" fill="#F0F0F0"/>
+<path d="M25.6865 7.25417V14.7852L62.3354 14.7852V7.25417L25.6865 7.25417Z" fill="#F0F0F0"/>
+<path d="M62.3352 22.4873H25.6904V26.155H62.3352V22.4873Z" fill="#F0F0F0"/>
+<path d="M62.3352 33.4941H25.6904V37.1618H62.3352V33.4941Z" fill="#F0F0F0"/>
+<path d="M42.8421 44.502H25.6904V48.1696H42.8421V44.502Z" fill="#F0F0F0"/>
+<path d="M58.127 48.3921L35.5127 67.7256L40.2631 73.2852L62.8774 53.9517L58.127 48.3921Z" fill="#F0F0F0"/>
+<path d="M67.4254 45.1494L58.084 48.3851L62.8443 53.9641L67.4254 45.1494Z" fill="white"/>
+<path d="M67.4256 45.1494L63.7607 46.4209L65.6298 48.6093L67.4256 45.1494Z" fill="#F0F0F0"/>
+</g>
+<defs>
+<clipPath id="clip0_2950_17772">
+<rect width="58.6667" height="88" fill="white" transform="translate(14.667)"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/plugins/woocommerce/client/admin/client/task-lists/components/task-list-item.tsx b/plugins/woocommerce/client/admin/client/task-lists/components/task-list-item.tsx
index e1d4547256b..07d1b00cb2e 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/components/task-list-item.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/components/task-list-item.tsx
@@ -12,6 +12,7 @@ import { recordEvent } from '@woocommerce/tracks';
 import { TaskItem, useSlot } from '@woocommerce/experimental';
 import { useCallback, useEffect } from '@wordpress/element';
 import { useDispatch } from '@wordpress/data';
+import { Button } from '@wordpress/components';
 import { WooOnboardingTaskListItem } from '@woocommerce/onboarding';
 import { useLayoutContext } from '@woocommerce/admin-layout';

@@ -27,6 +28,9 @@ export type TaskListItemProps = {
 	task: TaskType & {
 		onClick?: () => void;
 	};
+	isSkipDisabled?: boolean;
+	onTaskSkip?: ( task: TaskType ) => Promise< void >;
+	showSkipAction?: boolean;
 	trackClick?: () => void;
 };

@@ -35,6 +39,9 @@ export const TaskListItem = ( {
 	isExpanded = false,
 	setExpandedTask,
 	task,
+	isSkipDisabled = false,
+	onTaskSkip,
+	showSkipAction = false,
 	trackClick: trackTaskListClick,
 }: TaskListItemProps ) => {
 	const { createNotice } = useDispatch( 'core/notices' );
@@ -93,6 +100,22 @@ export const TaskListItem = ( {
 		} );
 	}, [ id ] );

+	const onSkip = useCallback(
+		( event: React.MouseEvent | React.KeyboardEvent ) => {
+			event.preventDefault();
+			event.stopPropagation();
+
+			void onTaskSkip?.( task );
+		},
+		[ onTaskSkip, task ]
+	);
+
+	// The surrounding list item treats Enter as a click on the row, so the
+	// keydown has to stop here or skipping also navigates to the task.
+	const onSkipKeyDown = useCallback( ( event: React.KeyboardEvent ) => {
+		event.stopPropagation();
+	}, [] );
+
 	const onSnooze = useCallback( () => {
 		void snoozeTask( id );
 		createNotice(
@@ -155,12 +178,29 @@ export const TaskListItem = ( {
 		navigateTo( { url: getNewPath( { task: id }, '/', {} ) } );
 	}, [ id, isComplete, actionUrl ] );

+	// Extended lists trade the ellipsis Dismiss for a row-level Skip. Keeping
+	// the skip action alongside the props it replaces means a fill that
+	// composes its own TaskItem receives it too, instead of losing both.
+	const skipAction =
+		showSkipAction && isDismissable && ! isComplete ? (
+			<Button
+				className="woocommerce-task-list__item-skip"
+				disabled={ isSkipDisabled }
+				variant="link"
+				onClick={ onSkip }
+				onKeyDown={ onSkipKeyDown }
+			>
+				{ __( 'Skip', 'woocommerce' ) }
+			</Button>
+		) : undefined;
+
 	const taskItemProps = {
 		expandable: isExpandable,
 		expanded: isExpandable && isExpanded,
 		completed: isComplete,
 		onSnooze: isSnoozeable ? onSnooze : undefined,
-		onDismiss: isDismissable ? onDismiss : undefined,
+		onDismiss: isDismissable && ! showSkipAction ? onDismiss : undefined,
+		secondaryAction: skipAction,
 	};

 	const DefaultTaskItem = useCallback(
diff --git a/plugins/woocommerce/client/admin/client/task-lists/components/task-list.scss b/plugins/woocommerce/client/admin/client/task-lists/components/task-list.scss
index b3018b39542..5c9d99d6261 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/components/task-list.scss
+++ b/plugins/woocommerce/client/admin/client/task-lists/components/task-list.scss
@@ -7,7 +7,9 @@
 	&.woocommerce-list__item-enter-active {
 		opacity: 1;
 		max-height: 100px;
-		transition: opacity 500ms, max-height 500ms;
+		transition:
+			opacity 500ms,
+			max-height 500ms;
 	}

 	&.woocommerce-list__item-exit {
@@ -18,6 +20,69 @@
 	&.woocommerce-list__item-exit-active {
 		opacity: 0;
 		max-height: 0;
-		transition: opacity 500ms, max-height 500ms;
+		transition:
+			opacity 500ms,
+			max-height 500ms;
+	}
+}
+
+.woocommerce-task-list--extended {
+	.woocommerce-task-list__item-skip {
+		opacity: 0;
+		pointer-events: none;
+	}
+
+	.woocommerce-task-list__item:hover,
+	.woocommerce-task-list__item:focus-within {
+		.woocommerce-task-list__item-skip {
+			opacity: 1;
+			pointer-events: auto;
+		}
+	}
+
+	@media (hover: none) {
+		.woocommerce-task-list__item-skip {
+			opacity: 1;
+			pointer-events: auto;
+		}
+	}
+
+	.woocommerce-task-list__item-removed-message {
+		color: $gray-700;
+		font-weight: 400;
+	}
+
+	.woocommerce-task-list__empty-state {
+		align-items: center;
+		border-top: 1px solid $gray-100;
+		display: flex;
+		flex-direction: column;
+		padding: $gap-large;
+		text-align: center;
+
+		.woocommerce-task-list__empty-state-image {
+			display: block;
+			height: $grid-unit-60;
+			margin: 0 0 $gap-smaller;
+			width: $grid-unit-60;
+		}
+
+		h2 {
+			color: $gray-900;
+			@include font-size(16);
+			font-style: normal;
+			font-weight: normal;
+			line-height: 1.5;
+			margin: $gap-smaller 0 0;
+		}
+
+		p {
+			color: $gray-700;
+			@include font-size(14);
+			font-style: normal;
+			font-weight: normal;
+			line-height: 20px;
+			margin: $gap-smaller 0;
+		}
 	}
 }
diff --git a/plugins/woocommerce/client/admin/client/task-lists/components/task-list.tsx b/plugins/woocommerce/client/admin/client/task-lists/components/task-list.tsx
index 833f02815f5..3ebb06d6bfe 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/components/task-list.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/components/task-list.tsx
@@ -3,17 +3,24 @@
  */
 import { __, _n, sprintf } from '@wordpress/i18n';
 import { useEffect, useRef, useState } from '@wordpress/element';
-import { Card, CardHeader } from '@wordpress/components';
-import { useSelect } from '@wordpress/data';
-import { Badge } from '@woocommerce/components';
+import { Button, Card, CardHeader } from '@wordpress/components';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { Badge, H } from '@woocommerce/components';
 import {
 	getVisibleTasks,
 	onboardingStore,
+	TaskType,
 	TaskListType,
 } from '@woocommerce/data';
 import { recordEvent } from '@woocommerce/tracks';
-import { Text, List, CollapsibleList } from '@woocommerce/experimental';
+import {
+	Text,
+	List,
+	ListItem,
+	CollapsibleList,
+} from '@woocommerce/experimental';
 import { useLayoutContext } from '@woocommerce/admin-layout';
+import clsx from 'clsx';

 /**
  * Internal dependencies
@@ -21,6 +28,7 @@ import { useLayoutContext } from '@woocommerce/admin-layout';
 import { TaskListItem } from './task-list-item';
 import { TaskListMenu } from './task-list-menu';
 import './task-list.scss';
+import ChecklistImage from './checklist.svg';
 import { ProgressHeader } from '~/task-lists/components/progress-header';

 export type TaskListProps = TaskListType & {
@@ -32,6 +40,13 @@ export type TaskListProps = TaskListType & {
 	cesHeader?: boolean;
 };

+type DismissedTask = Pick< TaskType, 'id' | 'title' >;
+
+// TaskLists::maybe_add_extended_tasks() treats any list ID beginning with
+// `extended` as an extended list, so the client has to match the same way.
+const isExtendedTaskListId = ( taskListId: string ) =>
+	taskListId.startsWith( 'extended' );
+
 export const TaskList = ( {
 	id,
 	eventPrefix,
@@ -42,6 +57,8 @@ export const TaskList = ( {
 	displayProgressHeader = false,
 	query,
 }: TaskListProps ) => {
+	const { dismissTask, undoDismissTask } = useDispatch( onboardingStore );
+	const { createNotice } = useDispatch( 'core/notices' );
 	const { profileItems } = useSelect( ( select ) => {
 		const { getProfileItems } = select( onboardingStore );

@@ -50,11 +67,38 @@ export const TaskList = ( {
 		};
 	}, [] );
 	const prevQueryRef = useRef( query );
-	const visibleTasks = getVisibleTasks( tasks );
+	const [ dismissedTasks, setDismissedTasks ] = useState<
+		Record< string, DismissedTask >
+	>( {} );
+	const pendingTaskRequestsRef = useRef( new Set< string >() );
+	const [ pendingTaskRequests, setPendingTaskRequests ] = useState<
+		Record< string, boolean >
+	>( {} );
+	const isExtendedTaskList = isExtendedTaskListId( id );
+	const visibleTasks = getVisibleTasks( tasks ).filter( ( task ) => {
+		if ( dismissedTasks[ task.id ] ) {
+			return false;
+		}
+
+		return ! isExtendedTaskList || ! task.isComplete;
+	} );
+	const taskIdsToRender = new Set( [
+		...visibleTasks.map( ( task ) => task.id ),
+		...Object.keys( dismissedTasks ),
+	] );
+	const displayTasks = tasks.filter( ( task ) =>
+		taskIdsToRender.has( task.id )
+	);
+	const dismissedTaskCount = Object.keys( dismissedTasks ).length;
+	const shouldShowEmptyState =
+		isExtendedTaskList && ! visibleTasks.length && ! dismissedTaskCount;
 	const { layoutString } = useLayoutContext();

 	const incompleteTasks = tasks.filter(
-		( task ) => ! task.isComplete && ! task.isDismissed
+		( task ) =>
+			! task.isComplete &&
+			! task.isDismissed &&
+			! dismissedTasks[ task.id ]
 	);

 	const [ expandedTask, setExpandedTask ] = useState(
@@ -92,7 +136,7 @@ export const TaskList = ( {
 		}
 	}, [ query ] );

-	if ( ! visibleTasks.length ) {
+	if ( ! displayTasks.length && ! shouldShowEmptyState ) {
 		return <div className="woocommerce-task-dashboard__container"></div>;
 	}

@@ -108,24 +152,197 @@ export const TaskList = ( {
 	);
 	const collapseLabel = __( 'Show less', 'woocommerce' );

-	const taskListItems = visibleTasks.map( ( task ) => (
-		<TaskListItem
-			key={ task.id }
-			isExpanded={ expandedTask === task.id }
-			isExpandable={ isExpandable }
-			task={ task }
-			setExpandedTask={ setExpandedTask }
-			trackClick={ () => trackClick( task ) }
-		/>
-	) );
+	const addDismissedTask = ( task: DismissedTask ) => {
+		setDismissedTasks( ( currentDismissedTasks ) => ( {
+			...currentDismissedTasks,
+			[ task.id ]: {
+				id: task.id,
+				title: task.title,
+			},
+		} ) );
+	};
+
+	const removeDismissedTask = ( taskId: string ) => {
+		setDismissedTasks( ( currentDismissedTasks ) => {
+			const updatedDismissedTasks = { ...currentDismissedTasks };
+			delete updatedDismissedTasks[ taskId ];
+			return updatedDismissedTasks;
+		} );
+	};
+
+	const beginTaskRequest = ( taskId: string ) => {
+		if ( pendingTaskRequestsRef.current.has( taskId ) ) {
+			return false;
+		}
+
+		pendingTaskRequestsRef.current.add( taskId );
+		setPendingTaskRequests( ( currentRequests ) => ( {
+			...currentRequests,
+			[ taskId ]: true,
+		} ) );
+		return true;
+	};
+
+	const endTaskRequest = ( taskId: string ) => {
+		pendingTaskRequestsRef.current.delete( taskId );
+		setPendingTaskRequests( ( currentRequests ) => {
+			const updatedRequests = { ...currentRequests };
+			delete updatedRequests[ taskId ];
+			return updatedRequests;
+		} );
+	};
+
+	const onTaskSkip = async ( task: TaskType ) => {
+		if ( ! beginTaskRequest( task.id ) ) {
+			return;
+		}
+
+		addDismissedTask( task );
+		try {
+			await dismissTask( task.id, id );
+		} catch {
+			removeDismissedTask( task.id );
+			createNotice(
+				'error',
+				__(
+					'There was a problem skipping this task. Please try again.',
+					'woocommerce'
+				)
+			);
+		} finally {
+			endTaskRequest( task.id );
+		}
+	};
+
+	const onUndoDismiss = async ( task: DismissedTask ) => {
+		if ( ! beginTaskRequest( task.id ) ) {
+			return;
+		}
+
+		removeDismissedTask( task.id );
+		try {
+			await undoDismissTask( task.id, id );
+		} catch {
+			addDismissedTask( task );
+			createNotice(
+				'error',
+				__(
+					'There was a problem restoring this task. Please try again.',
+					'woocommerce'
+				)
+			);
+		} finally {
+			endTaskRequest( task.id );
+		}
+	};
+
+	const taskListItems = displayTasks.map( ( task ) => {
+		if ( dismissedTasks[ task.id ] ) {
+			return (
+				<ListItem
+					key={ task.id }
+					disableGutters
+					className="woocommerce-task-list__item woocommerce-task-list__item--dismissed"
+				>
+					<div className="woocommerce-task-list__item-before">
+						<div className="woocommerce-task__icon"></div>
+					</div>
+					<div className="woocommerce-task-list__item-text">
+						<Text
+							as="div"
+							size="14"
+							lineHeight="20px"
+							variant="body.small"
+							className="woocommerce-task-list__item-removed-message"
+						>
+							{ __(
+								"This suggestion has been removed and won't be shown again.",
+								'woocommerce'
+							) }
+						</Text>
+					</div>
+					<div className="woocommerce-task-list__item-after">
+						<Button
+							className="woocommerce-task-list__item-undo"
+							disabled={ pendingTaskRequests[ task.id ] }
+							variant="link"
+							onClick={ (
+								event: React.MouseEvent | React.KeyboardEvent
+							) => {
+								event.preventDefault();
+								event.stopPropagation();
+								void onUndoDismiss( dismissedTasks[ task.id ] );
+							} }
+							onKeyDown={ ( event: React.KeyboardEvent ) =>
+								event.stopPropagation()
+							}
+						>
+							{ __( 'Undo', 'woocommerce' ) }
+						</Button>
+					</div>
+				</ListItem>
+			);
+		}
+
+		return (
+			<TaskListItem
+				key={ task.id }
+				isExpanded={ expandedTask === task.id }
+				isExpandable={ isExpandable }
+				task={ task }
+				setExpandedTask={ setExpandedTask }
+				isSkipDisabled={ pendingTaskRequests[ task.id ] }
+				showSkipAction={ isExtendedTaskList }
+				onTaskSkip={ onTaskSkip }
+				trackClick={ () => trackClick( task ) }
+			/>
+		);
+	} );
+
+	let taskListContent = <List animation="custom">{ taskListItems }</List>;
+
+	if ( shouldShowEmptyState ) {
+		taskListContent = (
+			<div className="woocommerce-task-list__empty-state">
+				<img
+					className="woocommerce-task-list__empty-state-image"
+					src={ ChecklistImage }
+					alt=""
+				/>
+				<H>{ __( "You're all caught up", 'woocommerce' ) }</H>
+				<p>
+					{ __(
+						"You've completed all the things to do next. Watch this space for more recommendations.",
+						'woocommerce'
+					) }
+				</p>
+			</div>
+		);
+	} else if ( isCollapsible ) {
+		taskListContent = (
+			<CollapsibleList
+				animation="custom"
+				collapseLabel={ collapseLabel }
+				expandLabel={ expandLabel }
+				show={ 2 }
+				onCollapse={ () => recordEvent( eventPrefix + 'collapse', {} ) }
+				onExpand={ () => recordEvent( eventPrefix + 'expand', {} ) }
+			>
+				{ taskListItems }
+			</CollapsibleList>
+		);
+	}

 	return (
 		<>
 			<div
-				className={
-					'woocommerce-task-dashboard__container woocommerce-task-list__' +
-					id
-				}
+				className={ clsx(
+					'woocommerce-task-dashboard__container',
+					`woocommerce-task-list__${ id }`,
+					{
+						'woocommerce-task-list--extended': isExtendedTaskList,
+					}
+				) }
 			>
 				{ displayProgressHeader ? (
 					<ProgressHeader taskListId={ id } />
@@ -147,24 +364,7 @@ export const TaskList = ( {
 						</div>
 						<TaskListMenu id={ id } />
 					</CardHeader>
-					{ isCollapsible ? (
-						<CollapsibleList
-							animation="custom"
-							collapseLabel={ collapseLabel }
-							expandLabel={ expandLabel }
-							show={ 2 }
-							onCollapse={ () =>
-								recordEvent( eventPrefix + 'collapse', {} )
-							}
-							onExpand={ () =>
-								recordEvent( eventPrefix + 'expand', {} )
-							}
-						>
-							{ taskListItems }
-						</CollapsibleList>
-					) : (
-						<List animation="custom">{ taskListItems }</List>
-					) }
+					{ taskListContent }
 				</Card>
 			</div>
 		</>
diff --git a/plugins/woocommerce/client/admin/client/task-lists/components/test/task-list-item.test.tsx b/plugins/woocommerce/client/admin/client/task-lists/components/test/task-list-item.test.tsx
index 01a378f8a43..adfb6a3e0f9 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/components/test/task-list-item.test.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/components/test/task-list-item.test.tsx
@@ -5,9 +5,11 @@ import { act, render, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { SlotFillProvider } from '@wordpress/components';
 import { useDispatch } from '@wordpress/data';
-import { useSlot } from '@woocommerce/experimental';
+import { TaskItem, useSlot } from '@woocommerce/experimental';
+import { WooOnboardingTaskListItem } from '@woocommerce/onboarding';
 import { TaskType } from '@woocommerce/data';
 import { recordEvent } from '@woocommerce/tracks';
+import { navigateTo } from '@woocommerce/navigation';

 /**
  * Internal dependencies
@@ -68,9 +70,27 @@ jest.mock( '@woocommerce/experimental', () => {
 		TaskItem: jest
 			.fn()
 			.mockImplementation(
-				( { title, onSnooze, onDismiss, onClick } ) => (
-					<div>
+				( {
+					title,
+					onSnooze,
+					onDismiss,
+					onClick,
+					secondaryAction,
+				} ) => (
+					// ExperimentalListItem gives the row a button role that
+					// treats Enter as a click, so the mock does the same to
+					// keep keyboard coverage meaningful.
+					<div
+						role="button"
+						tabIndex={ 0 }
+						onKeyDown={ ( event: React.KeyboardEvent ) => {
+							if ( event.key === 'Enter' ) {
+								onClick?.( event );
+							}
+						} }
+					>
 						<button onClick={ onClick }>{ title }</button>
+						{ secondaryAction }
 						{ onSnooze && (
 							<button onClick={ onSnooze } name="Snooze">
 								Snooze
@@ -122,6 +142,7 @@ const task: TaskType = {
 describe( 'TaskListItem', () => {
 	beforeEach( () => {
 		jest.clearAllMocks();
+		mockDispatch.dismissTask.mockResolvedValue( undefined );
 	} );

 	it( 'should render the default task list item', () => {
@@ -265,6 +286,66 @@ describe( 'TaskListItem', () => {
 		).not.toBeInTheDocument();
 	} );

+	it( 'should request a task skip through the parent callback', async () => {
+		const onTaskSkip = jest.fn().mockResolvedValue( undefined );
+		const { getByRole } = render(
+			<TaskListItem
+				task={ { ...task } }
+				isExpandable={ false }
+				isExpanded={ false }
+				setExpandedTask={ () => {} }
+				showSkipAction={ true }
+				onTaskSkip={ onTaskSkip }
+			/>
+		);
+
+		await userEvent.click( getByRole( 'button', { name: 'Skip' } ) );
+
+		expect( onTaskSkip ).toHaveBeenCalledWith( task );
+		expect( mockDispatch.dismissTask ).not.toHaveBeenCalled();
+	} );
+
+	it( 'should not navigate to the task when Skip is activated with Enter', async () => {
+		const onTaskSkip = jest.fn().mockResolvedValue( undefined );
+		const { getByRole } = render(
+			<TaskListItem
+				task={ { ...task } }
+				isExpandable={ false }
+				isExpanded={ false }
+				setExpandedTask={ () => {} }
+				showSkipAction={ true }
+				onTaskSkip={ onTaskSkip }
+			/>
+		);
+
+		getByRole( 'button', { name: 'Skip' } ).focus();
+		await userEvent.keyboard( '{Enter}' );
+
+		expect( onTaskSkip ).toHaveBeenCalledWith( task );
+		expect( navigateTo ).not.toHaveBeenCalled();
+	} );
+
+	it( 'should disable Skip while a task request is pending', async () => {
+		const onTaskSkip = jest.fn().mockResolvedValue( undefined );
+		const { getByRole } = render(
+			<TaskListItem
+				task={ { ...task } }
+				isExpandable={ false }
+				isExpanded={ false }
+				setExpandedTask={ () => {} }
+				showSkipAction={ true }
+				isSkipDisabled={ true }
+				onTaskSkip={ onTaskSkip }
+			/>
+		);
+
+		const skipButton = getByRole( 'button', { name: 'Skip' } );
+		expect( skipButton ).toBeDisabled();
+		await userEvent.click( skipButton );
+
+		expect( onTaskSkip ).not.toHaveBeenCalled();
+	} );
+
 	it( 'should call snoozeTask and trigger a notice when snoozing a task', () => {
 		const { getByRole } = render(
 			<TaskListItem
@@ -317,4 +398,36 @@ describe( 'TaskListItem', () => {
 		);
 		expect( queryByText( task.title ) ).not.toBeInTheDocument();
 	} );
+
+	it( 'should hand the skip action to a fill that composes its own TaskItem', async () => {
+		( useSlot as jest.Mock ).mockReturnValue( { fills: [ 'test' ] } );
+		const onTaskSkip = jest.fn().mockResolvedValue( undefined );
+		const extendedTask = { ...task, id: 'test' };
+
+		const { getByRole, queryByRole } = render(
+			<SlotFillProvider>
+				<WooOnboardingTaskListItem id="test">
+					{ (
+						fillProps: React.ComponentProps< typeof TaskItem >
+					) => <TaskItem { ...fillProps } title={ task.title } /> }
+				</WooOnboardingTaskListItem>
+				<TaskListItem
+					task={ extendedTask }
+					isExpandable={ false }
+					isExpanded={ false }
+					setExpandedTask={ () => {} }
+					showSkipAction={ true }
+					onTaskSkip={ onTaskSkip }
+				/>
+			</SlotFillProvider>
+		);
+
+		// Extended lists drop Dismiss, so the fill has to receive Skip in its
+		// place rather than losing both actions.
+		expect( queryByRole( 'button', { name: 'Dismiss' } ) ).toBeNull();
+
+		await userEvent.click( getByRole( 'button', { name: 'Skip' } ) );
+
+		expect( onTaskSkip ).toHaveBeenCalledWith( extendedTask );
+	} );
 } );
diff --git a/plugins/woocommerce/client/admin/client/task-lists/components/test/task-list.test.tsx b/plugins/woocommerce/client/admin/client/task-lists/components/test/task-list.test.tsx
index 2d966a5723c..dc430af540f 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/components/test/task-list.test.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/components/test/task-list.test.tsx
@@ -1,9 +1,10 @@
 /**
  * External dependencies
  */
-import { fireEvent, render } from '@testing-library/react';
+import { act, fireEvent, render, waitFor } from '@testing-library/react';
 import { recordEvent } from '@woocommerce/tracks';
 import { TaskType } from '@woocommerce/data';
+import { useDispatch } from '@wordpress/data';

 /**
  * Internal dependencies
@@ -15,9 +16,23 @@ jest.mock( '@woocommerce/tracks', () => ( {
 	recordEvent: jest.fn(),
 } ) );
 jest.mock( '../task-list-item', () => ( {
-	TaskListItem: ( props: TaskListItemProps ) => (
-		<button onClick={ props.trackClick }>{ props.task.title }</button>
-	),
+	TaskListItem: ( props: TaskListItemProps ) => {
+		return (
+			<>
+				<button onClick={ props.trackClick }>
+					{ props.task.title }
+				</button>
+				{ props.showSkipAction && (
+					<button
+						disabled={ props.isSkipDisabled }
+						onClick={ () => void props.onTaskSkip?.( props.task ) }
+					>
+						Skip { props.task.title }
+					</button>
+				) }
+			</>
+		);
+	},
 } ) );
 jest.mock( '../task-list-menu', () => ( {
 	TaskListMenu: jest
@@ -28,6 +43,9 @@ jest.mock( '@woocommerce/components', () => ( {
 	Badge: jest
 		.fn()
 		.mockImplementation( ( { count } ) => <div>Count:{ count }</div> ),
+	H: jest
+		.fn()
+		.mockImplementation( ( { children } ) => <h2>{ children }</h2> ),
 } ) );
 jest.mock( '@woocommerce/admin-layout', () => {
 	const mockContext = {
@@ -43,6 +61,21 @@ jest.mock( '@woocommerce/admin-layout', () => {
 	};
 } );

+jest.mock( '@wordpress/data', () => {
+	const originalModule = jest.requireActual( '@wordpress/data' );
+	return {
+		...originalModule,
+		useDispatch: jest.fn(),
+	};
+} );
+
+const mockDispatch = {
+	createNotice: jest.fn(),
+	dismissTask: jest.fn(),
+	undoDismissTask: jest.fn(),
+};
+( useDispatch as jest.Mock ).mockReturnValue( mockDispatch );
+
 const tasks: { [ key: string ]: TaskType[] } = {
 	setup: [
 		{
@@ -146,6 +179,8 @@ const tasks: { [ key: string ]: TaskType[] } = {
 describe( 'TaskList', () => {
 	beforeEach( () => {
 		jest.clearAllMocks();
+		mockDispatch.dismissTask.mockResolvedValue( undefined );
+		mockDispatch.undoDismissTask.mockResolvedValue( undefined );
 	} );

 	it( 'should trigger tasklist_view event on initial render for setup task list', () => {
@@ -257,6 +292,326 @@ describe( 'TaskList', () => {
 		).not.toBeInTheDocument();
 	} );

+	it( 'should render an empty state for extended task list when all tasks are dismissed', () => {
+		const dismissedTask = [
+			{ ...tasks.extension[ 0 ], isDismissed: true },
+		];
+		const { queryByText } = render(
+			<TaskList
+				id="extended"
+				eventPrefix="extended_tasklist_"
+				tasks={ dismissedTask }
+				title="Things to do next"
+				query={ {} }
+				isVisible={ true }
+				isHidden={ false }
+				isComplete={ false }
+				displayProgressHeader={ false }
+				keepCompletedTaskList="no"
+			/>
+		);
+
+		expect( queryByText( "You're all caught up" ) ).toBeInTheDocument();
+		expect(
+			queryByText(
+				"You've completed all the things to do next. Watch this space for more recommendations."
+			)
+		).toBeInTheDocument();
+	} );
+
+	it( 'should restore a skipped task when dismissing fails', async () => {
+		mockDispatch.dismissTask.mockRejectedValueOnce(
+			new Error( 'Unable to dismiss task' )
+		);
+		const { getByRole, queryByText } = render(
+			<TaskList
+				id="extended"
+				eventPrefix="extended_tasklist_"
+				tasks={ [ ...tasks.extension ] }
+				title="Things to do next"
+				query={ {} }
+				isVisible={ true }
+				isHidden={ false }
+				isComplete={ false }
+				displayProgressHeader={ false }
+				keepCompletedTaskList="no"
+			/>
+		);
+
+		fireEvent.click(
+			getByRole( 'button', {
+				name: `Skip ${ tasks.extension[ 0 ].title }`,
+			} )
+		);
+
+		await waitFor( () => {
+			expect(
+				queryByText( tasks.extension[ 0 ].title )
+			).toBeInTheDocument();
+		} );
+		expect( mockDispatch.createNotice ).toHaveBeenCalledWith(
+			'error',
+			'There was a problem skipping this task. Please try again.'
+		);
+	} );
+
+	it( 'should keep a skipped task removed when undo fails', async () => {
+		mockDispatch.undoDismissTask.mockRejectedValueOnce(
+			new Error( 'Unable to restore task' )
+		);
+		const { getByRole, queryByText } = render(
+			<TaskList
+				id="extended"
+				eventPrefix="extended_tasklist_"
+				tasks={ [ ...tasks.extension ] }
+				title="Things to do next"
+				query={ {} }
+				isVisible={ true }
+				isHidden={ false }
+				isComplete={ false }
+				displayProgressHeader={ false }
+				keepCompletedTaskList="no"
+			/>
+		);
+
+		fireEvent.click(
+			getByRole( 'button', {
+				name: `Skip ${ tasks.extension[ 0 ].title }`,
+			} )
+		);
+		await waitFor( () => {
+			expect( getByRole( 'button', { name: 'Undo' } ) ).toBeEnabled();
+		} );
+
+		fireEvent.click( getByRole( 'button', { name: 'Undo' } ) );
+
+		await waitFor( () => {
+			expect( mockDispatch.createNotice ).toHaveBeenCalledWith(
+				'error',
+				'There was a problem restoring this task. Please try again.'
+			);
+		} );
+		expect( getByRole( 'button', { name: 'Undo' } ) ).toBeEnabled();
+		expect(
+			queryByText( tasks.extension[ 0 ].title )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'should pass the task list id when skipping and restoring a task', async () => {
+		const { getByRole } = render(
+			<TaskList
+				id="extended"
+				eventPrefix="extended_tasklist_"
+				tasks={ [ ...tasks.extension ] }
+				title="Things to do next"
+				query={ {} }
+				isVisible={ true }
+				isHidden={ false }
+				isComplete={ false }
+				displayProgressHeader={ false }
+				keepCompletedTaskList="no"
+			/>
+		);
+
+		fireEvent.click(
+			getByRole( 'button', {
+				name: `Skip ${ tasks.extension[ 0 ].title }`,
+			} )
+		);
+
+		await waitFor( () => {
+			expect( mockDispatch.dismissTask ).toHaveBeenCalledWith(
+				tasks.extension[ 0 ].id,
+				'extended'
+			);
+		} );
+
+		fireEvent.click( getByRole( 'button', { name: 'Undo' } ) );
+
+		await waitFor( () => {
+			expect( mockDispatch.undoDismissTask ).toHaveBeenCalledWith(
+				tasks.extension[ 0 ].id,
+				'extended'
+			);
+		} );
+	} );
+
+	it( 'should serialize Skip and Undo requests for the same task', async () => {
+		let resolveDismissTask: () => void;
+		let resolveUndoDismissTask: () => void;
+		const dismissTaskRequest = new Promise< void >( ( resolve ) => {
+			resolveDismissTask = resolve;
+		} );
+		const undoDismissTaskRequest = new Promise< void >( ( resolve ) => {
+			resolveUndoDismissTask = resolve;
+		} );
+		mockDispatch.dismissTask.mockReturnValueOnce( dismissTaskRequest );
+		mockDispatch.undoDismissTask.mockReturnValueOnce(
+			undoDismissTaskRequest
+		);
+		const { getByRole } = render(
+			<TaskList
+				id="extended"
+				eventPrefix="extended_tasklist_"
+				tasks={ [ ...tasks.extension ] }
+				title="Things to do next"
+				query={ {} }
+				isVisible={ true }
+				isHidden={ false }
+				isComplete={ false }
+				displayProgressHeader={ false }
+				keepCompletedTaskList="no"
+			/>
+		);
+
+		fireEvent.click(
+			getByRole( 'button', {
+				name: `Skip ${ tasks.extension[ 0 ].title }`,
+			} )
+		);
+		expect( mockDispatch.dismissTask ).toHaveBeenCalledTimes( 1 );
+		const undoButton = getByRole( 'button', { name: 'Undo' } );
+		expect( undoButton ).toBeDisabled();
+
+		fireEvent.click( undoButton );
+		expect( mockDispatch.undoDismissTask ).not.toHaveBeenCalled();
+
+		await act( async () => {
+			resolveDismissTask!();
+			await dismissTaskRequest;
+		} );
+		await waitFor( () => {
+			expect( getByRole( 'button', { name: 'Undo' } ) ).toBeEnabled();
+		} );
+
+		fireEvent.click( getByRole( 'button', { name: 'Undo' } ) );
+		expect( mockDispatch.undoDismissTask ).toHaveBeenCalledTimes( 1 );
+		const skipButton = getByRole( 'button', {
+			name: `Skip ${ tasks.extension[ 0 ].title }`,
+		} );
+		expect( skipButton ).toBeDisabled();
+
+		fireEvent.click( skipButton );
+		expect( mockDispatch.dismissTask ).toHaveBeenCalledTimes( 1 );
+
+		await act( async () => {
+			resolveUndoDismissTask!();
+			await undoDismissTaskRequest;
+		} );
+		await waitFor( () => {
+			expect(
+				getByRole( 'button', {
+					name: `Skip ${ tasks.extension[ 0 ].title }`,
+				} )
+			).toBeEnabled();
+		} );
+	} );
+
+	it( 'should render an empty state for extended task list when all tasks are completed', () => {
+		const completedTask = [ { ...tasks.extension[ 0 ], isComplete: true } ];
+		const { queryByText } = render(
+			<TaskList
+				id="extended"
+				eventPrefix="extended_tasklist_"
+				tasks={ completedTask }
+				title="Things to do next"
+				query={ {} }
+				isVisible={ true }
+				isHidden={ false }
+				isComplete={ true }
+				displayProgressHeader={ false }
+				keepCompletedTaskList="no"
+			/>
+		);
+
+		expect(
+			queryByText( completedTask[ 0 ].title )
+		).not.toBeInTheDocument();
+		expect( queryByText( "You're all caught up" ) ).toBeInTheDocument();
+	} );
+
+	it( 'should treat any task list ID starting with extended as an extended list', () => {
+		const completedTask = [ { ...tasks.extension[ 0 ], isComplete: true } ];
+		const { queryByText } = render(
+			<TaskList
+				id="extended_foo"
+				eventPrefix="extended_foo_tasklist_"
+				tasks={ completedTask }
+				title="Things to do next"
+				query={ {} }
+				isVisible={ true }
+				isHidden={ false }
+				isComplete={ true }
+				displayProgressHeader={ false }
+				keepCompletedTaskList="no"
+			/>
+		);
+
+		expect(
+			queryByText( completedTask[ 0 ].title )
+		).not.toBeInTheDocument();
+		expect( queryByText( "You're all caught up" ) ).toBeInTheDocument();
+	} );
+
+	it( 'should offer the skip action on a task list ID starting with extended', () => {
+		const { getByRole } = render(
+			<TaskList
+				id="extended_foo"
+				eventPrefix="extended_foo_tasklist_"
+				tasks={ [ ...tasks.extension ] }
+				title="Things to do next"
+				query={ {} }
+				isVisible={ true }
+				isHidden={ false }
+				isComplete={ false }
+				displayProgressHeader={ false }
+				keepCompletedTaskList="no"
+			/>
+		);
+
+		expect(
+			getByRole( 'button', {
+				name: `Skip ${ tasks.extension[ 0 ].title }`,
+			} )
+		).toBeInTheDocument();
+	} );
+
+	it( 'should render the skipped task placeholder as a list item', async () => {
+		mockDispatch.dismissTask.mockResolvedValueOnce( undefined );
+		const { getByRole, getAllByRole } = render(
+			<TaskList
+				id="extended"
+				eventPrefix="extended_tasklist_"
+				tasks={ [ ...tasks.extension ] }
+				title="Things to do next"
+				query={ {} }
+				isVisible={ true }
+				isHidden={ false }
+				isComplete={ false }
+				displayProgressHeader={ false }
+				keepCompletedTaskList="no"
+			/>
+		);
+
+		await act( async () => {
+			fireEvent.click(
+				getByRole( 'button', {
+					name: `Skip ${ tasks.extension[ 0 ].title }`,
+				} )
+			);
+		} );
+
+		await waitFor( () => {
+			expect(
+				getAllByRole( 'listitem' ).some( ( item ) =>
+					item.classList.contains(
+						'woocommerce-task-list__item--dismissed'
+					)
+				)
+			).toBe( true );
+		} );
+	} );
+
 	it( 'should fire extended tasklist task clicked event when a task is clicked', () => {
 		const { getByRole } = render(
 			<TaskList
@@ -293,7 +648,7 @@ describe( 'TaskList', () => {
 	it( 'should include task_complete when a completed task is clicked', () => {
 		const { getByRole } = render(
 			<TaskList
-				id="extended"
+				id="setup"
 				eventPrefix="extended_tasklist_"
 				tasks={ [ tasks.setup[ 2 ] ] }
 				title="List title"
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index aa19fa63b43..9a92bacd4f1 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -38628,7 +38628,7 @@ parameters:
 		-
 			message: '#^Call to method get_param\(\) on an unknown class Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request\.$#'
 			identifier: class.notFound
-			count: 12
+			count: 10
 			path: src/Admin/API/OnboardingTasks.php

 		-
@@ -38733,30 +38733,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/OnboardingTasks.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:dismiss_task\(\) has invalid return type Automattic\\WooCommerce\\Admin\\API\\WP_Error\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:dismiss_task\(\) has invalid return type Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:dismiss_task\(\) should return Automattic\\WooCommerce\\Admin\\API\\WP_Error\|Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request but returns WP_Error\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:dismiss_task\(\) should return Automattic\\WooCommerce\\Admin\\API\\WP_Error\|Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request but returns WP_Error\|WP_REST_Response\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:get_tasks\(\) has invalid return type Automattic\\WooCommerce\\Admin\\API\\WP_Error\.$#'
 			identifier: class.notFound
@@ -38883,30 +38859,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/OnboardingTasks.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:undo_dismiss_task\(\) has invalid return type Automattic\\WooCommerce\\Admin\\API\\WP_Error\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:undo_dismiss_task\(\) has invalid return type Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:undo_dismiss_task\(\) should return Automattic\\WooCommerce\\Admin\\API\\WP_Error\|Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request but returns WP_Error\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:undo_dismiss_task\(\) should return Automattic\\WooCommerce\\Admin\\API\\WP_Error\|Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request but returns WP_Error\|WP_REST_Response\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:undo_snooze_task\(\) has invalid return type Automattic\\WooCommerce\\Admin\\API\\WP_Error\.$#'
 			identifier: class.notFound
@@ -39003,12 +38955,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/OnboardingTasks.php

-		-
-			message: '#^Parameter \$request of method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:dismiss_task\(\) has invalid type Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
 		-
 			message: '#^Parameter \$request of method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:get_tasks\(\) has invalid type Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request\.$#'
 			identifier: class.notFound
@@ -39045,12 +38991,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/OnboardingTasks.php

-		-
-			message: '#^Parameter \$request of method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:undo_dismiss_task\(\) has invalid type Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
 		-
 			message: '#^Parameter \$request of method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:undo_snooze_task\(\) has invalid type Automattic\\WooCommerce\\Admin\\API\\WP_REST_Request\.$#'
 			identifier: class.notFound
diff --git a/plugins/woocommerce/src/Admin/API/OnboardingTasks.php b/plugins/woocommerce/src/Admin/API/OnboardingTasks.php
index 69f59f6c66f..b3907bb7a23 100644
--- a/plugins/woocommerce/src/Admin/API/OnboardingTasks.php
+++ b/plugins/woocommerce/src/Admin/API/OnboardingTasks.php
@@ -163,6 +163,12 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
 			$this->namespace,
 			'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/dismiss',
 			array(
+				'args'   => array(
+					'task_list_id' => array(
+						'description' => __( 'Optional parameter to query specific task list.', 'woocommerce' ),
+						'type'        => 'string',
+					),
+				),
 				array(
 					'methods'             => \WP_REST_Server::EDITABLE,
 					'callback'            => array( $this, 'dismiss_task' ),
@@ -176,6 +182,12 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
 			$this->namespace,
 			'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/undo_dismiss',
 			array(
+				'args'   => array(
+					'task_list_id' => array(
+						'description' => __( 'Optional parameter to query specific task list.', 'woocommerce' ),
+						'type'        => 'string',
+					),
+				),
 				array(
 					'methods'             => \WP_REST_Server::EDITABLE,
 					'callback'            => array( $this, 'undo_dismiss_task' ),
@@ -769,12 +781,24 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
 	/**
 	 * Dismiss a single task.
 	 *
-	 * @param WP_REST_Request $request Full details about the request.
-	 * @return WP_REST_Request|WP_Error
+	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+	 * @return \WP_REST_Response|\WP_Error
 	 */
 	public function dismiss_task( $request ) {
-		$id   = $request->get_param( 'id' );
-		$task = TaskLists::get_task( $id );
+		$id           = $request->get_param( 'id' );
+		$task_list_id = $request->get_param( 'task_list_id' );
+
+		if ( null !== $task_list_id && ! TaskLists::get_list( $task_list_id ) ) {
+			return new \WP_Error(
+				'woocommerce_rest_invalid_task_list',
+				__( 'Sorry, no task list with that ID was found.', 'woocommerce' ),
+				array(
+					'status' => 404,
+				)
+			);
+		}
+
+		$task = TaskLists::get_task( $id, $task_list_id );

 		if ( ! $task && $id ) {
 			$task = new DeprecatedExtendedTask(
@@ -803,12 +827,24 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
 	/**
 	 * Undo dismissal of a single task.
 	 *
-	 * @param WP_REST_Request $request Full details about the request.
-	 * @return WP_REST_Request|WP_Error
+	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+	 * @return \WP_REST_Response|\WP_Error
 	 */
 	public function undo_dismiss_task( $request ) {
-		$id   = $request->get_param( 'id' );
-		$task = TaskLists::get_task( $id );
+		$id           = $request->get_param( 'id' );
+		$task_list_id = $request->get_param( 'task_list_id' );
+
+		if ( null !== $task_list_id && ! TaskLists::get_list( $task_list_id ) ) {
+			return new \WP_Error(
+				'woocommerce_rest_invalid_task_list',
+				__( 'Sorry, no task list with that ID was found.', 'woocommerce' ),
+				array(
+					'status' => 404,
+				)
+			);
+		}
+
+		$task = TaskLists::get_task( $id, $task_list_id );

 		if ( ! $task && $id ) {
 			$task = new DeprecatedExtendedTask(
diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/AdditionalPayments.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/AdditionalPayments.php
index 6fd4509025d..4df93d21892 100644
--- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/AdditionalPayments.php
+++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/AdditionalPayments.php
@@ -99,6 +99,15 @@ class AdditionalPayments extends Payments {
 		return $this->can_view_result;
 	}

+	/**
+	 * Check if a task is dismissable.
+	 *
+	 * @return bool
+	 */
+	public function is_dismissable() {
+		return true;
+	}
+
 	/**
 	 * Action URL.
 	 *
diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ExtendStore.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ExtendStore.php
index 8fbc1be38c8..69ed8d92a7b 100644
--- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ExtendStore.php
+++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ExtendStore.php
@@ -63,12 +63,12 @@ class ExtendStore extends Task {
 	}

 	/**
-	 * Always dismissable.
+	 * Check if a task is dismissable.
 	 *
 	 * @return bool
 	 */
 	public function is_dismissable() {
-		return false;
+		return true;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/GetMobileApp.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/GetMobileApp.php
index e03b880ba5b..efb460aab9e 100644
--- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/GetMobileApp.php
+++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/GetMobileApp.php
@@ -69,6 +69,15 @@ class GetMobileApp extends Task {
 		return $jetpack_can_be_installed || $jetpack_is_installed_and_current_user_connected;
 	}

+	/**
+	 * Check if a task is dismissable.
+	 *
+	 * @return bool
+	 */
+	public function is_dismissable() {
+		return true;
+	}
+
 	/**
 	 * Determines if site has any users connected to WordPress.com via JetPack
 	 *
diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Marketing.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Marketing.php
index d11d50df58b..5d255f254cf 100644
--- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Marketing.php
+++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Marketing.php
@@ -112,6 +112,15 @@ class Marketing extends Task {
 		return true;
 	}

+	/**
+	 * Check if a task is dismissable.
+	 *
+	 * @return bool
+	 */
+	public function is_dismissable() {
+		return true;
+	}
+
 	/**
 	 * Get the marketing plugins.
 	 *
diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ReviewShippingOptions.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ReviewShippingOptions.php
index 996fabe60cf..4ef7ee218a9 100644
--- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ReviewShippingOptions.php
+++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ReviewShippingOptions.php
@@ -62,6 +62,15 @@ class ReviewShippingOptions extends Task {
 		return get_option( 'woocommerce_admin_created_default_shipping_zones' ) === 'yes';
 	}

+	/**
+	 * Check if a task is dismissable.
+	 *
+	 * @return bool
+	 */
+	public function is_dismissable() {
+		return true;
+	}
+
 	/**
 	 * Action URL.
 	 *
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/onboarding-tasks.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/onboarding-tasks.php
index 69f64043e65..15a04a1ddfa 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/onboarding-tasks.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/onboarding-tasks.php
@@ -339,6 +339,144 @@ class WC_Admin_Tests_API_Onboarding_Tasks extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( $task_after_request->is_dismissed(), false );
 	}

+	/**
+	 * Test that a task is dismissed and restored in the correct task list when
+	 * the same task ID exists in more than one list.
+	 *
+	 * Regression: the "Things to do next" (extended) list registers an
+	 * AdditionalPayments task whose ID ("payments") collides with the core,
+	 * non-dismissable Payments task in the setup list. Without a task list ID,
+	 * the endpoint resolved the non-dismissable task first and returned a 404.
+	 *
+	 * @group tasklist
+	 */
+	public function test_colliding_task_id_is_resolved_by_task_list_id() {
+		wp_set_current_user( $this->user );
+
+		// Registered first: a non-dismissable task that shares the ID.
+		TaskLists::add_list( array( 'id' => 'list-a' ) );
+		TaskLists::add_task(
+			'list-a',
+			new TestTask(
+				TaskLists::get_list( 'list-a' ),
+				array(
+					'id'             => 'shared-task',
+					'title'          => 'Shared Task (not dismissable)',
+					'is_dismissable' => false,
+				)
+			)
+		);
+
+		// Registered second: the dismissable task with the same ID.
+		TaskLists::add_list( array( 'id' => 'list-b' ) );
+		TaskLists::add_task(
+			'list-b',
+			new TestTask(
+				TaskLists::get_list( 'list-b' ),
+				array(
+					'id'             => 'shared-task',
+					'title'          => 'Shared Task (dismissable)',
+					'is_dismissable' => true,
+				)
+			)
+		);
+
+		// Without a task list ID, the non-dismissable task resolves first -> 404.
+		$request = new WP_REST_Request( 'POST', $this->endpoint . '/shared-task/dismiss' );
+		$request->set_headers( array( 'content-type' => 'application/json' ) );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 404, $response->get_status() );
+
+		// Scoping to the list that owns the dismissable task succeeds.
+		$request = new WP_REST_Request( 'POST', $this->endpoint . '/shared-task/dismiss' );
+		$request->set_headers( array( 'content-type' => 'application/json' ) );
+		$request->set_param( 'task_list_id', 'list-b' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( true, $data['isDismissed'] );
+		$this->assertEquals( true, TaskLists::get_task( 'shared-task', 'list-b' )->is_dismissed() );
+
+		// Undo is likewise scoped to the correct list.
+		$request = new WP_REST_Request( 'POST', $this->endpoint . '/shared-task/undo_dismiss' );
+		$request->set_headers( array( 'content-type' => 'application/json' ) );
+		$request->set_param( 'task_list_id', 'list-b' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( false, $data['isDismissed'] );
+	}
+
+	/**
+	 * Test that the dismiss endpoint rejects an unknown task list ID instead of
+	 * falling back to a successful dismissal outside the requested scope.
+	 *
+	 * @group tasklist
+	 */
+	public function test_dismiss_rejects_unknown_task_list_id() {
+		wp_set_current_user( $this->user );
+
+		TaskLists::add_list( array( 'id' => 'list-a' ) );
+		TaskLists::add_task(
+			'list-a',
+			new TestTask(
+				TaskLists::get_list( 'list-a' ),
+				array(
+					'id'             => 'test-task',
+					'title'          => 'Test Task',
+					'is_dismissable' => true,
+				)
+			)
+		);
+
+		$request = new WP_REST_Request( 'POST', $this->endpoint . '/test-task/dismiss' );
+		$request->set_headers( array( 'content-type' => 'application/json' ) );
+		$request->set_param( 'task_list_id', 'does-not-exist' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 404, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_task_list', $data['code'] );
+		$this->assertEquals( false, TaskLists::get_task( 'test-task', 'list-a' )->is_dismissed() );
+	}
+
+	/**
+	 * Test that a falsy-but-supplied task list ID such as "0" is validated
+	 * rather than treated as unscoped (PHP casts "0" to false).
+	 *
+	 * @group tasklist
+	 */
+	public function test_dismiss_and_undo_reject_falsy_task_list_id() {
+		wp_set_current_user( $this->user );
+
+		TaskLists::add_list( array( 'id' => 'list-a' ) );
+		TaskLists::add_task(
+			'list-a',
+			new TestTask(
+				TaskLists::get_list( 'list-a' ),
+				array(
+					'id'             => 'test-task',
+					'title'          => 'Test Task',
+					'is_dismissable' => true,
+				)
+			)
+		);
+
+		foreach ( array( 'dismiss', 'undo_dismiss' ) as $action ) {
+			$request = new WP_REST_Request( 'POST', $this->endpoint . '/test-task/' . $action );
+			$request->set_headers( array( 'content-type' => 'application/json' ) );
+			$request->set_param( 'task_list_id', '0' );
+			$response = $this->server->dispatch( $request );
+			$data     = $response->get_data();
+
+			$this->assertEquals( 404, $response->get_status(), $action );
+			$this->assertEquals( 'woocommerce_rest_invalid_task_list', $data['code'], $action );
+		}
+
+		$this->assertEquals( false, TaskLists::get_task( 'test-task', 'list-a' )->is_dismissed() );
+	}
+
 	/**
 	 * Test that dismiss endpoint returns error for invalid task.
 	 * @group tasklist