diff --git a/ajax_comments.module b/ajax_comments.module
index 4a173b1..6f5adba 100644
--- a/ajax_comments.module
+++ b/ajax_comments.module
@@ -99,13 +99,28 @@ function ajax_comments_comment_links_alter(array &$links, CommentInterface &$ent
     // Attach classes to comment reply links to allow ajax_comments.js
     // to attach ajax behaviors to them.
     if (isset($links['comment']['#links']['comment-reply'])) {
-      $classes = array('js-use-ajax-comments', 'js-ajax-comments-reply', 'js-ajax-comments-reply-' . $entity->getCommentedEntityId() . '-' . $entity->getFieldName() . '-' . $entity->id());
+      $classes = [
+        'use-ajax',
+        'js-use-ajax-comments',
+        'js-ajax-comments-reply',
+        'js-ajax-comments-reply-' . $entity->getCommentedEntityId() . '-' . $entity->getFieldName() . '-' . $entity->id(),
+      ];
       if (empty($links['comment']['#links']['comment-reply']['attributes']['class'])) {
         $links['comment']['#links']['comment-reply']['attributes']['class'] = $classes;
       }
       else {
         $links['comment']['#links']['comment-reply']['attributes']['class'] = array_unique(array_merge($links['comment']['#links']['comment-reply']['attributes']['class'], $classes));
       }
+      $links['comment']['#links']['comment-reply']['attributes']['data-wrapper-html-id'] = $wrapper_html_id;
+      $links['comment']['#links']['comment-reply']['url'] = Url::fromRoute(
+        'ajax_comments.reply',
+        [
+          'entity_type' => $entity->getCommentedEntityTypeId(),
+          'entity' => $entity->getCommentedEntityId(),
+          'field_name' => $entity->getFieldName(),
+          'pid' => $entity->id(),
+        ]
+      );
     }
   }
 }
@@ -254,42 +269,6 @@ function ajax_comments_form_comment_form_alter(&$form, \Drupal\Core\Form\FormSta
   if (empty($form['actions']['preview']['#id'])) {
     $form['actions']['preview']['#id'] = \Drupal\Component\Utility\Html::getUniqueId('preview-' . $id);
   }
-
-  // If this a reply to comment offer a 'cancel' button.
-  if (!empty($pid)) {
-    $form['actions']['cancel'] = array(
-      '#type' => 'button',
-      '#value' => t('Cancel'),
-      '#access' => TRUE,
-      '#weight' => 21,
-      '#limit_validation_errors' => array(),
-    );
-    $form['actions']['cancel']['#ajax'] = array(
-      'wrapper' => $form['#attributes']['id'],
-      'method' => 'replace',
-      'effect' => 'fade',
-    );
-    if (empty($cid)) {
-      $form['actions']['cancel']['#ajax']['callback'] = '_ajax_comments_add_cancel_js';
-    }
-    else {
-      $form['actions']['cancel']['#ajax']['callback'] = '_ajax_comments_edit_cancel_js';
-    }
-    // Set unique id (need for Views with enabled AJAX).
-    if (empty($form['actions']['cancel']['#id'])) {
-      $form['actions']['cancel']['#id'] = \Drupal\Component\Utility\Html::getUniqueId('cancel-' . $id);
-    }
-  }
-}
-
-/**
- * Ajax form callback: Submit comment.
- */
-function _ajax_comments_submit_js($form, FormStateInterface $form_state) {
-  $message = 'Comment submit';
-  $response = new AjaxResponse();
-  $response->addCommand(new AlertCommand($message));
-  return $response;
 }
 
 /**
@@ -303,26 +282,6 @@ function _ajax_comments_preview_js($form, FormStateInterface $form_state) {
 }
 
 /**
- * Ajax form callback: Cancel comment add.
- */
-function _ajax_comments_add_cancel_js($form, FormStateInterface $form_state) {
-  $message = 'Comment add cancel';
-  $response = new AjaxResponse();
-  $response->addCommand(new AlertCommand($message));
-  return $response;
-}
-
-/**
- * Ajax form callback: Cancel comment edit.
- */
-function _ajax_comments_edit_cancel_js($form, FormStateInterface $form_state) {
-  $message = 'Comment edit cancel';
-  $response = new AjaxResponse();
-  $response->addCommand(new AlertCommand($message));
-  return $response;
-}
-
-/**
  * Implements template_preprocess_comment().
  */
 function ajax_comments_preprocess_comment(&$variables) {
diff --git a/ajax_comments.routing.yml b/ajax_comments.routing.yml
index 25d188d..5bdf547 100644
--- a/ajax_comments.routing.yml
+++ b/ajax_comments.routing.yml
@@ -17,10 +17,10 @@ ajax_comments.edit:
       comment:
         type: entity:comment
 
-ajax_comments.update:
-  path: '/ajax_comments/{comment}/update'
+ajax_comments.save:
+  path: '/ajax_comments/{comment}/save'
   defaults:
-    _controller: '\Drupal\ajax_comments\Controller\AjaxCommentsController::update'
+    _controller: '\Drupal\ajax_comments\Controller\AjaxCommentsController::save'
   requirements:
     _entity_access: 'comment.update'
   options:
@@ -28,16 +28,25 @@ ajax_comments.update:
       comment:
         type: entity:comment
 
-ajax_comments.cancel:
-  path: '/ajax_comments/{comment}/cancel'
+ajax_comments.save_reply:
+  path: '/ajax_comments/save_reply/{entity_type}/{entity}/{field_name}/{pid}'
   defaults:
-    _controller: '\Drupal\ajax_comments\Controller\AjaxCommentsController::cancel'
+    _controller: '\Drupal\ajax_comments\Controller\AjaxCommentsController::saveReply'
   requirements:
     _access: 'TRUE'
+    pid: ^[0-9]+
   options:
     parameters:
-      comment:
-        type: entity:comment
+      entity:
+        type: entity:{entity_type}
+
+ajax_comments.cancel:
+  path: '/ajax_comments/{cid}/cancel'
+  defaults:
+    _controller: '\Drupal\ajax_comments\Controller\AjaxCommentsController::cancel'
+  requirements:
+    _access: 'TRUE'
+    cid: ^[0-9]+
 
 ajax_comments.delete:
   path: '/ajax_comments/{comment}/delete'
@@ -51,13 +60,25 @@ ajax_comments.delete:
       comment:
         type: entity:comment
 
+ajax_comments.add:
+  path: '/ajax_comments/add/{entity_type}/{entity}/{field_name}/{pid}'
+  defaults:
+    _controller: '\Drupal\ajax_comments\Controller\AjaxCommentsController::add'
+    pid: ~
+  requirements:
+    _access: 'TRUE'
+  options:
+    parameters:
+      entity:
+        type: entity:{entity_type}
+
 ajax_comments.reply:
   path: '/ajax_comments/reply/{entity_type}/{entity}/{field_name}/{pid}'
   defaults:
     _controller: '\Drupal\ajax_comments\Controller\AjaxCommentsController::reply'
-    pid: ~
   requirements:
     _access: 'TRUE'
+    pid: ^[0-9]+
   options:
     parameters:
       entity:
diff --git a/src/Controller/AjaxCommentsController.php b/src/Controller/AjaxCommentsController.php
index dba9ea6..decfa45 100644
--- a/src/Controller/AjaxCommentsController.php
+++ b/src/Controller/AjaxCommentsController.php
@@ -9,6 +9,7 @@ use Drupal\comment\CommentInterface;
 use Drupal\comment\Controller\CommentController;
 use Drupal\Core\Ajax\AfterCommand;
 use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\AppendCommand;
 use Drupal\Core\Ajax\BeforeCommand;
 use Drupal\Core\Ajax\CloseModalDialogCommand;
 use Drupal\Core\Ajax\InvokeCommand;
@@ -135,11 +136,13 @@ class AjaxCommentsController extends ControllerBase {
    *   The entity that has the comment field.
    * @param string $field_name
    *   The machine name of the comment field.
+   * @param int|null $pid
+   *   The entity id of the parent comment, if applicable, NULL otherwise.
    *
    * @return \Drupal\Core\Ajax\AjaxResponse
    *   The modified ajax response.
    */
-  protected function buildCommentFieldResponse(Request $request, AjaxResponse $response, EntityInterface $entity, $field_name) {
+  protected function buildCommentFieldResponse(Request $request, AjaxResponse $response, EntityInterface $entity, $field_name, $pid = NULL) {
     // Build a comment field render array for the ajax response.
     $comment_display = $this->renderCommentField($entity, $field_name);
 
@@ -164,7 +167,8 @@ class AjaxCommentsController extends ControllerBase {
    * @param string $selector
    *   The DOM selector used to insert status messages.
    * @param string $position
-   *   Indicates whether to use PrependCommand or BeforeCommand.
+   *   Indicates whether to use PrependCommand, BeforeCommand, AppendCommand,
+   *   or AfterCommand.
    *
    * @return \Drupal\Core\Ajax\AjaxResponse
    *   The modified ajax response.
@@ -191,6 +195,13 @@ class AjaxCommentsController extends ControllerBase {
       $status_messages = ['#type' => 'status_messages'];
 
       switch ($position) {
+        case 'after':
+          $command = new AfterCommand(
+            $selector,
+            $this->renderer->renderRoot($status_messages)
+          );
+          break;
+
         case 'before':
           $command = new BeforeCommand(
             $selector,
@@ -198,6 +209,13 @@ class AjaxCommentsController extends ControllerBase {
           );
           break;
 
+        case 'append':
+          $command = new AppendCommand(
+            $selector,
+            $this->renderer->renderRoot($status_messages)
+          );
+          break;
+
         case 'prepend':
         default:
           $command = new PrependCommand(
@@ -221,8 +239,8 @@ class AjaxCommentsController extends ControllerBase {
    * @param \Drupal\comment\CommentInterface $comment
    *   The comment entity.
    *
-   * @return \Drupal\Core\Ajax\AjaxResponse
-   *   The Ajax response.
+   * @return \Drupal\Core\Ajax\AjaxResponse|\Symfony\Component\HttpFoundation\RedirectResponse
+   *   The Ajax response, or a redirect response if not using ajax.
    */
   public function edit(Request $request, CommentInterface $comment) {
     $is_ajax = Utility::isAjaxRequest($request);
@@ -275,7 +293,7 @@ class AjaxCommentsController extends ControllerBase {
   }
 
   /**
-   * Submit handler for the comment edit form.
+   * Submit handler for the comment reply and edit forms.
    *
    * @param \Symfony\Component\HttpFoundation\Request $request
    *   The current request object.
@@ -285,7 +303,7 @@ class AjaxCommentsController extends ControllerBase {
    * @return \Drupal\Core\Ajax\AjaxResponse
    *   The Ajax response.
    */
-  public function update(Request $request, CommentInterface $comment) {
+  public function save(Request $request, CommentInterface $comment) {
     $response = new AjaxResponse();
 
     // Store the selectors from the incoming request, if applicable.
@@ -298,6 +316,7 @@ class AjaxCommentsController extends ControllerBase {
 
     // Check for errors.
     if (empty(drupal_get_messages('error', FALSE))) {
+      $errors = FALSE;
       // If there are no errors, set the ajax-updated
       // selector value for the form.
       $this->tempStore->setSelector('form_html_id', $form['#attributes']['id']);
@@ -312,6 +331,7 @@ class AjaxCommentsController extends ControllerBase {
       );
     }
     else {
+      $errors = TRUE;
       // Retrieve the selector values for use in building the response.
       $selectors = $this->tempStore->getSelectors($request, $overwrite = TRUE);
       $wrapper_html_id = $selectors['wrapper_html_id'];
@@ -321,12 +341,45 @@ class AjaxCommentsController extends ControllerBase {
       $response->addCommand(new RemoveCommand($wrapper_html_id . ' .js-ajax-comments-messages'));
       $response->addCommand(new ReplaceCommand($form_html_id, $form));
     }
-    $response = $this->addMessages(
-      $request,
-      $response,
-      static::getCommentSelectorPrefix() . $comment->id(),
-      'before'
-    );
+    // If this is a new comment being added from this method, it is being
+    // inserted as a reply to another comment, and there is no $comment->id()
+    // yet. Insert the message after the parent comment instead.
+    if ($comment->isNew()) {
+      // Retrieve the comment id of the new comment, which was saved in
+      // AjaxCommentsForm::save() during the previous HTTP request.
+      $cid = $this->tempStore->getCid();
+
+      // Try to insert the message above the new comment.
+      if (!empty($cid) && !$errors) {
+        $selector = static::getCommentSelectorPrefix() . $cid;
+        $response = $this->addMessages(
+          $request,
+          $response,
+          $selector,
+          'before'
+        );
+      }
+      // If no selector for the new comment can be found, or if there are
+      // errors, insert the message directly below the parent comment.
+      else {
+        $response = $this->addMessages(
+          $request,
+          $response,
+          static::getCommentSelectorPrefix() . $comment->get('pid')->target_id,
+          'after'
+        );
+      }
+    }
+    // Otherwise, if this is an edit to an existing comment, insert the
+    // messages above the existing comment item.
+    else {
+      $response = $this->addMessages(
+        $request,
+        $response,
+        static::getCommentSelectorPrefix() . $comment->id(),
+        'before'
+      );
+    }
 
     // Clear out the tempStore variables.
     $this->tempStore->deleteAll();
@@ -359,13 +412,13 @@ class AjaxCommentsController extends ControllerBase {
    *
    * @param \Symfony\Component\HttpFoundation\Request $request
    *   The current request object.
-   * @param \Drupal\comment\CommentInterface $comment
-   *   The comment entity.
+   * @param int $cid
+   *   The id of the comment being edited, or 0 if this is a new comment.
    *
    * @return \Drupal\Core\Ajax\AjaxResponse
    *   The Ajax response.
    */
-  public function cancel(Request $request, CommentInterface $comment) {
+  public function cancel(Request $request, $cid) {
     $response = new AjaxResponse();
 
     // Get the selectors.
@@ -373,11 +426,13 @@ class AjaxCommentsController extends ControllerBase {
     $wrapper_html_id = $selectors['wrapper_html_id'];
     $form_html_id = $selectors['form_html_id'];
 
-    // Show the hidden anchor.
-    $response->addCommand(new InvokeCommand('a#comment-' . $comment->id(), 'show', [200, 'linear']));
+    if ($cid != 0) {
+      // Show the hidden anchor.
+      $response->addCommand(new InvokeCommand('a#comment-' . $cid, 'show', [200, 'linear']));
 
-    // Show the hidden comment.
-    $response->addCommand(new InvokeCommand(static::getCommentSelectorPrefix() . $comment->id(), 'show', [200, 'linear']));
+      // Show the hidden comment.
+      $response->addCommand(new InvokeCommand(static::getCommentSelectorPrefix() . $cid, 'show', [200, 'linear']));
+    }
 
     // Remove the form.
     $response->addCommand(new RemoveCommand($form_html_id));
@@ -447,7 +502,7 @@ class AjaxCommentsController extends ControllerBase {
   }
 
   /**
-   * Builds ajax response for adding a comment.
+   * Builds ajax response for adding a new comment without a parent comment.
    *
    * @param \Symfony\Component\HttpFoundation\Request $request
    *   The current request object.
@@ -464,7 +519,7 @@ class AjaxCommentsController extends ControllerBase {
    *
    * @see \Drupal\comment\Controller\CommentController::getReplyForm()
    */
-  public function reply(Request $request, EntityInterface $entity, $field_name, $pid = NULL) {
+  public function add(Request $request, EntityInterface $entity, $field_name, $pid = NULL) {
     $response = new AjaxResponse();
 
     // Store the selectors from the incoming request, if applicable.
@@ -526,6 +581,133 @@ class AjaxCommentsController extends ControllerBase {
   }
 
   /**
+   * Builds ajax response to display a form to reply to another comment.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request object.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity this comment belongs to.
+   * @param string $field_name
+   *   The field_name to which the comment belongs.
+   * @param int $pid
+   *   The parent comment's comment ID.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse|\Symfony\Component\HttpFoundation\RedirectResponse
+   *   The Ajax response, or a redirect response if not using ajax.
+   *
+   * @see \Drupal\comment\Controller\CommentController::getReplyForm()
+   */
+  public function reply(Request $request, EntityInterface $entity, $field_name, $pid) {
+    $is_ajax = Utility::isAjaxRequest($request);
+
+    if ($is_ajax) {
+      $response = new AjaxResponse();
+
+      // Get the selectors.
+      $selectors = $this->tempStore->getSelectors($request, $overwrite = TRUE);
+      $wrapper_html_id = $selectors['wrapper_html_id'];
+
+      // Check the user's access to reply.
+      // The user should not have made it this far without proper permission,
+      // but adding this access check as a fallback.
+      $this->replyAccess($request, $response, $entity, $field_name, $pid);
+
+      // Remove any existing status messages in the comment field,
+      // if applicable.
+      $response->addCommand(new RemoveCommand($wrapper_html_id . ' .js-ajax-comments-messages'));
+
+      // Build the comment entity form.
+      // This approach is very similar to the one taken in
+      // \Drupal\comment\CommentLazyBuilders::renderForm().
+      $comment = $this->entityTypeManager()->getStorage('comment')->create(array(
+        'entity_id' => $entity->id(),
+        'pid' => $pid,
+        'entity_type' => $entity->getEntityTypeId(),
+        'field_name' => $field_name,
+      ));
+      // Build the comment form.
+      $form = $this->entityFormBuilder()->getForm($comment);
+      $response->addCommand(new AfterCommand(static::getCommentSelectorPrefix() . $pid, $form));
+
+      // Don't delete the tempStore variables here; we need them
+      // to persist for the update() method, where the form returned
+      // here will be submitted.
+      // Instead, return the response without calling $this->tempStore->deleteAll().
+      return $response;
+    }
+    else {
+      // If the user attempts to access the comment reply form with JavaScript
+      // disabled, degrade gracefully by redirecting to the core comment
+      // reply form.
+      $redirect = Url::fromRoute(
+        'comment.reply',
+        [
+          'entity_type' => $entity->getEntityTypeId(),
+          'entity' => $entity->id(),
+          'field_name' => $field_name,
+          'pid' => $pid,
+        ]
+      )
+        ->setAbsolute()
+        ->toString();
+      $response = new RedirectResponse($redirect);
+      return $response;
+    }
+  }
+
+  /**
+   * Builds ajax response to save a submitted reply to another comment.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request object.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity this comment belongs to.
+   * @param string $field_name
+   *   The field_name to which the comment belongs.
+   * @param int $pid
+   *   The parent comment's comment ID.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The Ajax response.
+   */
+  public function saveReply(Request $request, EntityInterface $entity, $field_name, $pid) {
+    $response = new AjaxResponse();
+
+    // Check the user's access to reply.
+    // The user should not have made it this far without proper permission,
+    // but adding this access check as a fallback.
+    $this->replyAccess($request, $response, $entity, $field_name, $pid);
+
+    // Build a dummy comment entity to pass to $this->save(), which will use
+    // it to rebuild the comment entity form to trigger form submission.
+    // @code
+    // $form = $this->entityFormBuilder()->getForm($comment, 'default', ['editing' => TRUE]);
+    // @endcode
+    // Note that this approach will correctly process the form submission
+    // even though we are passing in an empty, dummy comment, because two steps
+    // later in the call stack, \Drupal\Core\Form\FormBuilder::buildForm() is
+    // called, and it checks the current request object for form submission
+    // values if there aren't any in the form state, yet:
+    // @code
+    // $input = $form_state->getUserInput();
+    // if (!isset($input)) {
+    //   $input = $form_state->isMethodType('get') ? $request->query->all() : $request->request->all();
+    //   $form_state->setUserInput($input);
+    // }
+    // @endcode
+    // This approach is very similar to the one taken in
+    // \Drupal\comment\CommentLazyBuilders::renderForm().
+    $comment = $this->entityTypeManager()->getStorage('comment')->create(array(
+      'entity_id' => $entity->id(),
+      'pid' => $pid,
+      'entity_type' => $entity->getEntityTypeId(),
+      'field_name' => $field_name,
+    ));
+    // Rebuild the form to trigger form submission.
+    return $this->save($request, $comment);
+  }
+
+  /**
    * Check the user's permission to post a comment.
    *
    * @param \Symfony\Component\HttpFoundation\Request $request
diff --git a/src/Form/AjaxCommentsForm.php b/src/Form/AjaxCommentsForm.php
index 9e51048..e4e5ca8 100644
--- a/src/Form/AjaxCommentsForm.php
+++ b/src/Form/AjaxCommentsForm.php
@@ -142,7 +142,7 @@ class AjaxCommentsForm extends CommentForm {
     // update the temp store values while rebuilding the form, if necessary.
     $this->tempStore->processForm($request, $form, $form_state);
 
-    if ($is_ajax && $route_name === 'ajax_comments.edit') {
+    if ($is_ajax && in_array($route_name, ['ajax_comments.edit', 'ajax_comments.reply'])) {
       $wrapper_html_id = $this->tempStore->getSelectorValue($request, 'wrapper_html_id');
     }
     else {
@@ -230,7 +230,7 @@ class AjaxCommentsForm extends CommentForm {
 
     // Build the ajax submit URLs.
     $ajax_new_comment_url = Url::fromRoute(
-      'ajax_comments.reply',
+      'ajax_comments.add',
       [
         'entity_type' => $commented_entity->getEntityTypeId(),
         'entity' => $commented_entity->id(),
@@ -239,9 +239,18 @@ class AjaxCommentsForm extends CommentForm {
       ]
     );
     $ajax_edit_comment_url = Url::fromRoute(
-      'ajax_comments.update',
+      'ajax_comments.save',
       [
-        'comment' => $comment->id(),
+        'comment' => $cid,
+      ]
+    );
+    $ajax_comment_reply_url = Url::fromRoute(
+      'ajax_comments.save_reply',
+      [
+        'entity_type' => $commented_entity->getEntityTypeId(),
+        'entity' => $commented_entity->id(),
+        'field_name' => $field_name,
+        'pid' => $pid,
       ]
     );
 
@@ -254,7 +263,7 @@ class AjaxCommentsForm extends CommentForm {
         'url' => Url::fromRoute(
           'ajax_comments.cancel',
           [
-            'comment' => $comment->id(),
+            'cid' => $cid,
           ]
         ),
         // We need to wait for ajax_comments_entity_display_build_alter() to run
@@ -286,7 +295,7 @@ class AjaxCommentsForm extends CommentForm {
 
         break;
 
-      case 'ajax_comments.update':
+      case 'ajax_comments.save':
         $element['submit']['#ajax'] = $ajax;
         // If the user attempted to submit the form but there were errors,
         // rebuild the form used at the 'ajax_comments.edit' route.
@@ -301,6 +310,27 @@ class AjaxCommentsForm extends CommentForm {
         }
         break;
 
+      case 'ajax_comments.reply':
+        $element['submit']['#ajax'] = $ajax;
+        $element['submit']['#ajax']['url'] = $ajax_comment_reply_url;
+        $element['cancel'] = $cancel;
+        break;
+
+      case 'ajax_comments.save_reply':
+        $element['submit']['#ajax'] = $ajax;
+        // If the user attempted to submit the form but there were errors,
+        // rebuild the form used at the 'ajax_comments.reply' route.
+        if ($editing) {
+          $element['submit']['#ajax']['url'] = $ajax_comment_reply_url;
+          $element['cancel'] = $cancel;
+        }
+        // Otherwise, rebuild the 'add comment' form during rebuild of the
+        // comment field.
+        else {
+          $element['submit']['#ajax']['url'] = $ajax_new_comment_url;
+        }
+        break;
+
       default:
         $element['submit']['#ajax'] = $ajax;
         $element['submit']['#ajax']['url'] = $ajax_new_comment_url;
@@ -319,7 +349,7 @@ class AjaxCommentsForm extends CommentForm {
     $request = $this->requestStack->getCurrentRequest();
     $route_name = $this->currentRouteMatch->getRouteName();
     $this->tempStore->processForm($request, $form, $form_state, $is_validating = TRUE);
-    if ($form_state->hasAnyErrors() && $route_name === 'ajax_comments.update') {
+    if ($form_state->hasAnyErrors() && in_array($route_name, ['ajax_comments.save', 'ajax_comments.save_reply'])) {
       // If we are trying to save an edit to an existing comment, and there is
       // a form error, set the wrapper element ID back to its original value,
       // because we haven't executed a complete replacement of the wrapper
@@ -352,10 +382,14 @@ class AjaxCommentsForm extends CommentForm {
       return;
     }
 
+    // Save the comment id in the private tempStore, so that the controller
+    // can access it in a subsequent HTTP request.
+    $this->tempStore->setCid($comment->id());
+
     // Code adapted from FormSubmitter::redirectForm().
     $request = $this->requestStack->getCurrentRequest();
     $route_name = RouteMatch::createFromRequest($request)->getRouteName();
-    if ($route_name !== 'entity.comment.edit_form') {
+    if (!in_array($route_name, array('entity.comment.edit_form', 'comment.reply'))) {
       $form_state->setRedirect(
         '<current>',
         [],
diff --git a/src/TempStore.php b/src/TempStore.php
index e39d44b..fb19a59 100644
--- a/src/TempStore.php
+++ b/src/TempStore.php
@@ -36,6 +36,26 @@ class TempStore {
   }
 
   /**
+   * Store a newly-added comment ID for retrieval across HTTP requests.
+   *
+   * @param mixed $cid
+   *   The comment ID to store for the newly-added comment.
+   */
+  public function setCid($cid) {
+    $this->privateTempStore->set('cid', $cid);
+  }
+
+  /**
+   * Retrieve a newly-added comment ID set in a previous request by setCid().
+   *
+   * @return mixed
+   *   The comment ID saved in ::setCid().
+   */
+  public function getCid() {
+    return $this->privateTempStore->get('cid');
+  }
+
+  /**
    * Get a single selector value, without the '#' prefix.
    *
    * @param \Symfony\Component\HttpFoundation\Request $request
@@ -143,6 +163,7 @@ class TempStore {
       'wrapper_html_id',
       'form_html_id',
       'form_machine_name',
+      'cid',
     ];
     foreach ($values as $value) {
       $this->privateTempStore->delete($value);
diff --git a/src/Tests/AjaxCommentsTest.php b/src/Tests/AjaxCommentsTest.php
index 460e516..66a5e50 100644
--- a/src/Tests/AjaxCommentsTest.php
+++ b/src/Tests/AjaxCommentsTest.php
@@ -10,6 +10,7 @@ use Drupal\node\Entity\Node;
 use Drupal\Core\EventSubscriber\AjaxResponseSubscriber;
 use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
 use Drupal\Core\Url;
+use Drupal\Component\Utility\Html;
 use Drupal\Component\Utility\Xss;
 
 /**
@@ -33,9 +34,9 @@ class AjaxCommentsTest extends CommentTestBase {
   ];
 
   /**
-   * Tests posting a comment using ajax.
+   * Tests comment posting, editing, deleting, and replying using ajax.
    */
-  public function testAjaxCommentPost() {
+  public function testAjaxComments() {
     $this->drupalLogin($this->webUser);
     $this->drupalGet('node/' . $this->node->id());
     $comment_text = $this->randomMachineName();
@@ -57,15 +58,18 @@ class AjaxCommentsTest extends CommentTestBase {
     // Need to log in as adminUser to delete comments.
     $this->drupalLogin($this->adminUser);
     // Need to reload the node to get the updated comment field values.
-    $node = Node::load($this->node->id());
+    $this->node = Node::load($this->node->id());
 
-    $comment_cid = $node->get('comment')->get(0)->cid;
+    $comment_cid = $this->node->get('comment')->get(0)->cid;
     /** @var \Drupal\comment\Entity\Comment $comment */
     $comment = Comment::load($comment_cid);
 
-    $delete_form_render_array = \Drupal::service('entity.form_builder')
+    $delete_form_render_array = $this->container
+      ->get('entity.form_builder')
       ->getForm($comment, 'delete', ['input' => ['_drupal_ajax' => TRUE]]);
-    $delete_form_rendered = \Drupal::service('renderer')->renderRoot($delete_form_render_array);
+    $delete_form_rendered = $this->container
+      ->get('renderer')
+      ->renderRoot($delete_form_render_array);
 
     $delete_form_expected_response = [
       'command' => 'openDialog',
@@ -80,7 +84,7 @@ class AjaxCommentsTest extends CommentTestBase {
         )->render(),
       ],
     ];
-    $wrapper_html_id = Utility::getWrapperIdFromEntity($node, $comment->getFieldName());
+    $wrapper_html_id = Utility::getWrapperIdFromEntity($this->node, $comment->getFieldName());
 
     // Test opening a modal dialog with the comment delete confirmation form.
     $modal_form_response = $this->drupalGetAjax(
@@ -167,16 +171,19 @@ class AjaxCommentsTest extends CommentTestBase {
 
     // Need to reload the node to get the updated comment field values.
     $this->container->get('entity_type.manager')->getStorage('node')->resetCache();
-    $node = Node::load($this->node->id());
-    $comment_cid = $node->get('comment')->get(0)->cid;
+    $this->node = Node::load($this->node->id());
+    $comment_cid = $this->node->get('comment')->get(0)->cid;
     /** @var \Drupal\comment\Entity\Comment $comment */
     $comment = Comment::load($comment_cid);
     // Get the wrapper id for the comment to edit.
-    $wrapper_html_id = Utility::getWrapperIdFromEntity($node, $comment->getFieldName());
+    $wrapper_html_id = Utility::getWrapperIdFromEntity($this->node, $comment->getFieldName());
 
-    $edit_form_render_array = \Drupal::service('entity.form_builder')
+    $edit_form_render_array = $this->container
+      ->get('entity.form_builder')
       ->getForm($comment, 'default', ['input' => ['_drupal_ajax' => TRUE]]);
-    $edit_form_rendered = \Drupal::service('renderer')->renderRoot($edit_form_render_array);
+    $edit_form_rendered = $this->container
+      ->get('renderer')
+      ->renderRoot($edit_form_render_array);
 
     // Test opening the edit form for the comment.
     $selector = AjaxCommentsController::getCommentSelectorPrefix() . $comment->id();
@@ -231,6 +238,66 @@ class AjaxCommentsTest extends CommentTestBase {
     $this->assertTrue($edit_form_response_found, 'The edit form loaded.');
     $this->pass('Edit form: ' . $ajax_result[$edit_form_index]['data']);
 
+    // Test the cancel button.
+    $edit_form_dom_document = Html::load($ajax_result[$edit_form_index]['data']);
+    $edit_form_id = $edit_form_dom_document->getElementsByTagName('form')->item(0)->getAttribute('id');
+
+    $cancel_remove_form_expected_response = [
+      'command' => 'remove',
+      'selector' => '#' . $edit_form_id,
+    ];
+    $cancel_remove_messages_expected_response = [
+      'command' => 'remove',
+      'selector' => '#' . $wrapper_html_id . ' .js-ajax-comments-messages',
+    ];
+    $cancel_remove_form_response_found = FALSE;
+    $cancel_remove_messages_response_found = FALSE;
+
+    $ajax_result = $this->drupalPostAjaxForm(
+    // Path.
+      'comment/' . $comment->id() . '/edit',
+      // Edit.
+      [
+        'comment_body[0][value]'  => '',
+      ],
+      // Triggering element.
+      ['op' => t('Cancel')],
+      // Ajax path.
+      'ajax_comments/' . $comment->id() . '/cancel',
+      // Options.
+      [],
+      // Headers.
+      [],
+      // HTML ID.
+      NULL,
+      // Ajax settings.
+      [
+        'url' => Url::fromRoute(
+          'ajax_comments.cancel',
+          [
+            'cid' => $comment->id(),
+          ]
+        )->toString(),
+        'wrapper' => $wrapper_html_id,
+        'method' => 'replace',
+        'effect' => 'fade',
+        'submit' => [
+          'form_html_id' => $edit_form_id,
+          'wrapper_html_id' => $wrapper_html_id,
+        ],
+      ]
+    );
+    foreach ($ajax_result as $index => $command) {
+      if ($command == $cancel_remove_form_expected_response) {
+        $cancel_remove_form_response_found = TRUE;
+      }
+      elseif ($command == $cancel_remove_messages_expected_response) {
+        $cancel_remove_messages_response_found = TRUE;
+      }
+    }
+    $this->assert($cancel_remove_form_response_found, 'Reply form removed.');
+    $this->assert($cancel_remove_messages_response_found, 'Messages removed.');
+
     // Test actually editing the comment.
     $revised_comment_body = $this->randomMachineName();
     $ajax_result = $this->drupalPostAjaxForm(
@@ -241,7 +308,7 @@ class AjaxCommentsTest extends CommentTestBase {
       // Triggering element.
       ['op' => t('Save')],
       // Ajax path.
-      'ajax_comments/' . $comment->id() . '/update',
+      'ajax_comments/' . $comment->id() . '/save',
       // Options.
       [],
       // Headers.
@@ -251,7 +318,7 @@ class AjaxCommentsTest extends CommentTestBase {
       // Ajax settings.
       [
         'url' => Url::fromRoute(
-          'ajax_comments.update',
+          'ajax_comments.save',
           [
             'comment' => $comment->id(),
           ]
@@ -269,6 +336,204 @@ class AjaxCommentsTest extends CommentTestBase {
     }
     $this->assertText($revised_comment_body, 'Comment edited.');
     $this->pass('Edited comment text: ' . $revised_comment_body);
+
+    // Test replying to comments.
+    // adminUser has the permission 'skip comment approval'.
+    $this->drupalLogin($this->adminUser);
+
+    // Initialize variables.
+    $this->container->get('entity_type.manager')->getStorage('node')->resetCache();
+    $this->node = Node::load($this->node->id());
+    $nid = $this->node->id();
+    $entity_type = $this->node->getEntityTypeId();
+    $pid = $this->node->get('comment')->get(0)->cid;
+    /** @var \Drupal\comment\CommentInterface $comment */
+    $parent_comment = Comment::load($pid);
+    $comment_field_name = $parent_comment->getFieldName();
+    // Get the wrapper id for the comment to edit.
+    $wrapper_html_id = Utility::getWrapperIdFromEntity($this->node, $comment_field_name);
+
+    // Return to the node page.
+    $this->drupalGet('node/' . $nid);
+
+    // Test opening the comment reply form.
+    $selector = AjaxCommentsController::getCommentSelectorPrefix() . $pid;
+    $reply_comment = $this->container->get('entity_type.manager')
+      ->getStorage('comment')
+      ->create(array(
+      'entity_id' => $nid,
+      'pid' => $pid,
+      'entity_type' => $entity_type,
+      'field_name' => $comment_field_name,
+    ));
+    $reply_form_render_array = $this->container
+      ->get('entity.form_builder')
+      ->getForm(
+        $reply_comment,
+        'default',
+        ['input' => [AjaxResponseSubscriber::AJAX_REQUEST_PARAMETER => TRUE]]
+      );
+    $reply_form_rendered = $this->container
+      ->get('renderer')
+      ->renderRoot($reply_form_render_array);
+
+    $reply_form_expected_response = [
+      'command' => 'insert',
+      'method' => 'after',
+      'selector' => $selector,
+      'data' => $reply_form_rendered,
+      'settings' => NULL,
+    ];
+
+    $reply_form_response_found = FALSE;
+    $reply_form_index = NULL;
+
+    $ajax_result = $this->drupalGetAjax(
+      'ajax_comments/reply/' . $entity_type . '/' . $nid . '/' . $comment_field_name . '/' . $pid,
+      [
+        'query' => [
+          MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax',
+          'wrapper_html_id' => $wrapper_html_id,
+        ],
+      ]
+    );
+    foreach ($ajax_result as $index => $command) {
+      if ($command['command'] === 'insert' && $command['method'] === 'after' && $command['selector'] === $selector) {
+        $reply_form_index = $index;
+        // Strip tags from the rendered content because the form build ID and
+        // token will never match.
+        $reply_form_expected_response['data'] = trim(Xss::filter(
+          $reply_form_expected_response['data'],
+          []
+        ));
+        $command['data'] = trim(Xss::filter(
+          $command['data'],
+          []
+        ));
+        if ($reply_form_expected_response === $command) {
+          $reply_form_response_found = TRUE;
+        }
+      }
+    }
+    $this->assertTrue($reply_form_response_found, 'The reply form loaded.');
+    $this->pass('Reply form: ' . $ajax_result[$reply_form_index]['data']);
+
+    // Test the cancel button.
+    $reply_form_dom_document = Html::load($ajax_result[$reply_form_index]['data']);
+    $reply_form_id = $reply_form_dom_document->getElementsByTagName('form')->item(0)->getAttribute('id');
+
+    $cancel_remove_form_expected_response = [
+      'command' => 'remove',
+      'selector' => '#' . $reply_form_id,
+    ];
+    $cancel_remove_messages_expected_response = [
+      'command' => 'remove',
+      'selector' => '#' . $wrapper_html_id . ' .js-ajax-comments-messages',
+    ];
+    $cancel_remove_form_response_found = FALSE;
+    $cancel_remove_messages_response_found = FALSE;
+
+    $path = 'comment/reply/' .
+      $entity_type .
+      '/' . $nid .
+      '/' . $comment_field_name .
+      '/' . $pid;
+    $ajax_result = $this->drupalPostAjaxForm(
+      // Path.
+      $path,
+      // Edit.
+      [
+        'comment_body[0][value]'  => '',
+      ],
+      // Triggering element.
+      ['op' => t('Cancel')],
+      // Ajax path.
+      'ajax_comments/0/cancel',
+      // Options.
+      [],
+      // Headers.
+      [],
+      // HTML ID.
+      NULL,
+      // Ajax settings.
+      [
+        'url' => Url::fromRoute(
+          'ajax_comments.cancel',
+          [
+            'cid' => 0,
+          ]
+        )->toString(),
+        'wrapper' => $wrapper_html_id,
+        'method' => 'replace',
+        'effect' => 'fade',
+        'submit' => [
+          'form_html_id' => $reply_form_id,
+          'wrapper_html_id' => $wrapper_html_id,
+        ],
+      ]
+    );
+    foreach ($ajax_result as $index => $command) {
+      if ($command == $cancel_remove_form_expected_response) {
+        $cancel_remove_form_response_found = TRUE;
+      }
+      elseif ($command == $cancel_remove_messages_expected_response) {
+        $cancel_remove_messages_response_found = TRUE;
+      }
+    }
+    $this->assert($cancel_remove_form_response_found, 'Reply form removed.');
+    $this->assert($cancel_remove_messages_response_found, 'Messages removed.');
+
+    // Test actually replying to the comment.
+    $comment_text_3 = $this->randomMachineName();
+    $path = 'comment/reply/' .
+      $entity_type .
+      '/' . $nid .
+      '/' . $comment_field_name .
+      '/' . $pid;
+    $ajax_path = 'ajax_comments/save_reply/' .
+      $entity_type .
+      '/' . $nid .
+      '/' . $comment_field_name .
+      '/' . $pid;
+    $ajax_result = $this->drupalPostAjaxForm(
+      // Path.
+      $path,
+      // Edit.
+      ['comment_body[0][value]' => $comment_text_3],
+      // Triggering element.
+      ['op' => t('Save')],
+      // Ajax path.
+      $ajax_path,
+      // Options.
+      [],
+      // Headers.
+      [],
+      // HTML ID.
+      NULL,
+      // Ajax settings.
+      [
+        'url' => Url::fromRoute(
+          'ajax_comments.save_reply',
+          [
+            'entity_type' => $entity_type,
+            'entity' => $nid,
+            'field_name' => $comment_field_name,
+            'pid' => $pid,
+          ]
+        )->toString(),
+        'wrapper' => $wrapper_html_id,
+        'method' => 'replace',
+        'effect' => 'fade',
+      ]
+    );
+    foreach ($ajax_result as $index => $command) {
+      if ($command['command'] === 'insert' && $command['method'] === 'replaceWith') {
+        $this->setRawContent($command['data']);
+        $this->pass('Ajax replacement content: ' . $command['data']);
+      }
+    }
+    $this->assertText($comment_text_3, 'Comment reply posted.');
+    $this->pass('Reply comment text: ' . $comment_text_3);
   }
 
 }
diff --git a/tests/src/FunctionalJavascript/AjaxCommentsFunctionalTest.php b/tests/src/FunctionalJavascript/AjaxCommentsFunctionalTest.php
index 8bad9cf..c204b33 100644
--- a/tests/src/FunctionalJavascript/AjaxCommentsFunctionalTest.php
+++ b/tests/src/FunctionalJavascript/AjaxCommentsFunctionalTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\ajax_comments\FunctionalJavascript;
 
+use Drupal\comment\Entity\Comment;
 use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface;
 use Drupal\comment\Tests\CommentTestTrait;
 use Drupal\editor\Entity\Editor;
@@ -178,6 +179,96 @@ JS;
     }
     $this->assertSession()->pageTextContains('Updated comment');
     $this->assertJsCondition($javascript_assertion);
+
+    // Test the cancel button.
+    $this->clickLink('Edit');
+    $this->assertSession()->assertWaitOnAjaxRequest(20000);
+    $this->assertSession()->elementExists('css', 'form.ajax-comments-form-edit');
+    if ($this->htmlOutputEnabled) {
+      $out = $page->getContent();
+      $html_output = $out . '<hr />' . $this->getHtmlOutputHeaders();
+      $this->htmlOutput($html_output);
+    }
+    $cancel_button = $page->find('css', 'form.ajax-comments-form-edit input[value=Cancel]');
+    $this->assertTrue(!empty($cancel_button));
+    $cancel_button->press();
+    $this->assertSession()->assertWaitOnAjaxRequest(20000);
+    if ($this->htmlOutputEnabled) {
+      $out = $page->getContent();
+      $html_output = $out . '<hr />' . $this->getHtmlOutputHeaders();
+      $this->htmlOutput($html_output);
+    }
+
+    // Test replying to a comment.
+    $this->clickLink('Reply');
+    $this->assertSession()->assertWaitOnAjaxRequest(20000);
+    $comment_body_id = $page->find('css', 'form.ajax-comments-form-reply textarea')->getAttribute('id');
+    $ckeditor_javascript = <<<JS
+    (function (){
+      CKEDITOR.instances['$comment_body_id'].setData('Comment reply');
+    }());
+JS;
+    $this->getSession()->executeScript($ckeditor_javascript);
+    if ($this->htmlOutputEnabled) {
+      $out = $page->getContent();
+      $html_output = $out . '<hr />' . $this->getHtmlOutputHeaders();
+      $this->htmlOutput($html_output);
+    }
+    $save_button = $page->find('css', 'form.ajax-comments-form-reply input[value=Save]');
+    $this->assertTrue(!empty($save_button));
+    $save_button->press();
+    $this->assertSession()->assertWaitOnAjaxRequest(20000);
+    if ($this->htmlOutputEnabled) {
+      $out = $page->getContent();
+      $html_output = $out . '<hr />' . $this->getHtmlOutputHeaders();
+      $this->htmlOutput($html_output);
+    }
+    $this->assertSession()->pageTextContains('Comment reply');
+    $this->assertJsCondition($javascript_assertion);
+
+    // Test deleting a comment.
+    $delete_link = $page->findLink('Delete');
+    $this->assertTrue(!empty($delete_link));
+    $delete_url = $delete_link->getAttribute('href');
+    $this->assertTrue(!empty($delete_url));
+
+    // Get the comment ID (in $matches[1]).
+    preg_match('/comment\/(.+)\/delete/i', $delete_url, $matches);
+    $this->assertTrue(isset($matches[1]));
+    $comment_to_delete = Comment::load($matches[1]);
+    $comment_to_delete_body = $comment_to_delete->get('comment_body')->value;
+
+    $delete_form = $this->container
+      ->get('entity_type.manager')
+      ->getFormObject(
+        $comment_to_delete->getEntityTypeId(), 'delete'
+      );
+    $delete_form->setEntity($comment_to_delete);
+    // The delete confirmation question has tags stripped and is truncated
+    // in the modal dialog box.
+    $confirm_question = substr(strip_tags($delete_form->getQuestion()), 0, 50);
+
+    $delete_link->click();
+    $this->assertSession()->assertWaitOnAjaxRequest(20000);
+
+    if ($this->htmlOutputEnabled) {
+      $out = $page->getContent();
+      $html_output = $out . '<hr />' . $this->getHtmlOutputHeaders();
+      $this->htmlOutput($html_output);
+    }
+    $this->assertSession()->pageTextContains($confirm_question);
+
+    $delete_button = $page->find('css', '.ui-dialog button.button--primary.js-form-submit');
+    $this->assertTrue(!empty($delete_button));
+    $delete_button->click();
+    $this->assertSession()->assertWaitOnAjaxRequest(20000);
+    if ($this->htmlOutputEnabled) {
+      $out = $page->getContent();
+      $html_output = $out . '<hr />' . $this->getHtmlOutputHeaders();
+      $this->htmlOutput($html_output);
+    }
+    $this->assertSession()->pageTextNotContains($comment_to_delete_body);
+    $this->assertJsCondition($javascript_assertion);
   }
 
 }
