diff --git a/reviewboard/actions/registry.py b/reviewboard/actions/registry.py
index 94610d9bb2df84f7311a90ddee2e4b108970cb6b..1403b41fd519a11058588920ac8e87871894a5eb 100644
--- a/reviewboard/actions/registry.py
+++ b/reviewboard/actions/registry.py
@@ -68,11 +68,15 @@ class ActionsRegistry(OrderedRegistry):
                                                  CloseMenuAction,
                                                  CloseCompletedAction,
                                                  CloseDiscardedAction,
+                                                 CreateReviewAction,
                                                  DeleteAction,
                                                  DownloadDiffAction,
+                                                 EditReviewAction,
                                                  LegacyEditReviewAction,
                                                  LegacyShipItAction,
                                                  MuteAction,
+                                                 ReviewMenuAction,
+                                                 ShipItAction,
                                                  StarAction,
                                                  UpdateMenuAction,
                                                  UploadDiffAction,
@@ -115,6 +119,12 @@ class ActionsRegistry(OrderedRegistry):
             LegacyEditReviewAction(),
             LegacyShipItAction(),
             AddGeneralCommentAction(),
+
+            # Unified banner actions
+            ReviewMenuAction(),
+            CreateReviewAction(),
+            EditReviewAction(),
+            ShipItAction(),
         ]
 
         for action in builtin_actions:
diff --git a/reviewboard/reviews/actions.py b/reviewboard/reviews/actions.py
index b4d290730af135cd344c88ec0cea8308a257b1c7..b43e46baa65a91cae7d527689fd467339e90a936 100644
--- a/reviewboard/reviews/actions.py
+++ b/reviewboard/reviews/actions.py
@@ -303,6 +303,111 @@ class DownloadDiffAction(BaseAction):
                 review_request.has_diffsets)
 
 
+class ReviewMenuAction(BaseMenuAction):
+    """The "Review" menu on the unified banner.
+
+    Version Added:
+        6.0
+    """
+
+    action_id = 'review-menu'
+    apply_to = all_review_request_url_names
+    attachment = AttachmentPoint.UNIFIED_BANNER
+    label = _('Review')
+    icon_class = 'rb-icon rb-icon-compose-review'
+
+    def should_render(
+        self,
+        context: Context,
+    ) -> bool:
+        """Return whether this action should render.
+
+        This menu only renders when the user is logged in and the unified
+        banner feature is enabled.
+
+        Args:
+            context (django.template.Context):
+                The current rendering context.
+
+        Returns:
+            bool:
+            ``True`` if the action should render.
+        """
+        request = context['request']
+        user = request.user
+
+        return (super().should_render(context=context) and
+                user.is_authenticated and
+                not is_site_read_only_for(user) and
+                unified_banner_feature.is_enabled(request=request))
+
+
+class CreateReviewAction(BaseAction):
+    """Action to create a new, blank review.
+
+    Version Added:
+        6.0
+    """
+
+    action_id = 'create-review'
+    parent_id = 'review-menu'
+    apply_to = all_review_request_url_names
+    attachment = AttachmentPoint.UNIFIED_BANNER
+    label = _('Create a new review')
+    description = [
+        _('Your review will start off blank, but you can add text and '
+          'general comments to it.'),
+        _('Adding comments to code or file attachments will automatically '
+          'create a new review for you.'),
+    ]
+    icon_class = 'rb-icon rb-icon-create-review'
+    js_view_class = 'RB.CreateReviewActionView'
+    template_name = 'actions/detailed_menuitem_action.html'
+
+
+class EditReviewAction(BaseAction):
+    """Action to edit an existing review.
+
+    Version Added:
+        6.0
+    """
+
+    action_id = 'edit-review'
+    parent_id = 'review-menu'
+    apply_to = all_review_request_url_names
+    attachment = AttachmentPoint.UNIFIED_BANNER
+    label = _('Edit your review')
+    description = [
+        _('Edit your comments and publish your review.'),
+    ]
+    icon_class = 'rb-icon rb-icon-compose-review'
+    js_view_class = 'RB.EditReviewActionView'
+    template_name = 'actions/detailed_menuitem_action.html'
+
+
+class ShipItAction(BaseAction):
+    """Action to mark a review request as "Ship It".
+
+    Version Added:
+        6.0
+    """
+
+    action_id = 'ship-it'
+    parent_id = 'review-menu'
+    apply_to = all_review_request_url_names
+    attachment = AttachmentPoint.UNIFIED_BANNER
+    label = _('Ship it!')
+    description = [
+        _("You're happy with what you're seeing, and would like to "
+          'approve it.'),
+        _('If you want to leave a comment with this, choose "Create '
+          'a new review" above.'),
+    ]
+    icon_class = 'rb-icon rb-icon-shipit'
+    js_view_name = 'RB.ShipItActionView'
+    template_name = 'actions/detailed_menuitem_action.html'
+
+
 class LegacyEditReviewAction(BaseAction):
     """The old-style "Edit Review" action.
 
@@ -313,7 +418,7 @@ class LegacyEditReviewAction(BaseAction):
         6.0
     """
 
-    action_id = 'edit-review'
+    action_id = 'legacy-edit-review'
     label = _('Review')
     apply_to = all_review_request_url_names
 
@@ -355,7 +460,7 @@ class LegacyShipItAction(BaseAction):
         6.0
     """
 
-    action_id = 'ship-it'
+    action_id = 'legacy-ship-it'
     label = _('Ship It!')
     apply_to = all_review_request_url_names
 
@@ -698,7 +803,7 @@ class BaseReviewRequestAction(BaseAction):
         removed in 7.0.
     """
 
-    apply_to = reviewable_url_names + review_request_url_names
+    apply_to = all_review_request_url_names
 
     def __init__(self) -> None:
         """Initialize this action.
@@ -832,7 +937,7 @@ class BaseReviewRequestMenuAction(BaseMenuAction):
         removed in 7.0.
     """
 
-    apply_to = reviewable_url_names + review_request_url_names
+    apply_to = all_review_request_url_names
 
     def __init__(
         self,
diff --git a/reviewboard/reviews/features.py b/reviewboard/reviews/features.py
index cdfd718efb3a28f563725be8639d79edd0765be8..7f4fa65f96a0d88ab4773cc028279d4c2ce1af51 100644
--- a/reviewboard/reviews/features.py
+++ b/reviewboard/reviews/features.py
@@ -83,7 +83,7 @@ class UnifiedBannerFeature(Feature):
 
     feature_id = 'reviews.unified_banner'
     name = _('Unified Banner')
-    level = FeatureLevel.EXPERIMENTAL
+    level = FeatureLevel.STABLE
     summary = _('A unified banner that offers the functionality for drafts '
                 'and reviews in a centralized place.')
 
diff --git a/reviewboard/reviews/tests/test_actions.py b/reviewboard/reviews/tests/test_actions.py
index a98687e78fd781cf2dcd06b5c18c00b753a7e81a..33627210a0f8c03bca67287840368b11f3d53749 100644
--- a/reviewboard/reviews/tests/test_actions.py
+++ b/reviewboard/reviews/tests/test_actions.py
@@ -128,13 +128,14 @@ class ReadOnlyActionTestsMixin(MixinParent):
             'site_read_only': True,
         }
 
-        with self.siteconfig_settings(settings):
-            if getattr(self, 'read_only_always_show', False):
-                self.assertTrue(
-                    self.action.should_render(context=request_context))
-            else:
-                self.assertFalse(
-                    self.action.should_render(context=request_context))
+        with override_feature_check(unified_banner_feature.feature_id, False):
+            with self.siteconfig_settings(settings):
+                if getattr(self, 'read_only_always_show', False):
+                    self.assertTrue(
+                        self.action.should_render(context=request_context))
+                else:
+                    self.assertFalse(
+                        self.action.should_render(context=request_context))
 
     def test_should_render_with_superuser_in_read_only(self) -> None:
         """Testing <ACTION>.should_render with superuser in read-only mode"""
@@ -148,9 +149,10 @@ class ReadOnlyActionTestsMixin(MixinParent):
             'site_read_only': True,
         }
 
-        with self.siteconfig_settings(settings):
-            self.assertTrue(
-                self.action.should_render(context=request_context))
+        with override_feature_check(unified_banner_feature.feature_id, False):
+            with self.siteconfig_settings(settings):
+                self.assertTrue(
+                    self.action.should_render(context=request_context))
 
 
 class ActionRegistrationTests(ActionsTestCase):
diff --git a/reviewboard/static/rb/css/pages/review-request.less b/reviewboard/static/rb/css/pages/review-request.less
index af5c7bd75ec2e22242cf21604b0d9213edbd69ec..d7ac78ec65fcca11bbe3383d2882fc8b1f49d7d8 100644
--- a/reviewboard/static/rb/css/pages/review-request.less
+++ b/reviewboard/static/rb/css/pages/review-request.less
@@ -62,6 +62,7 @@
 }
 
 .banner pre.field,
+.rb-c-unified-banner pre.field,
 .review-request-body pre.field {
   background-color: @textarea-editor-background;
   border: @textarea-border;
diff --git a/reviewboard/static/rb/css/ui/banners.less b/reviewboard/static/rb/css/ui/banners.less
index b7f759933e5e3ac9b160b7fecd8e922f2757d7b7..0538263e545a10297a70fcb44730779bbbe49f34 100644
--- a/reviewboard/static/rb/css/ui/banners.less
+++ b/reviewboard/static/rb/css/ui/banners.less
@@ -1,5 +1,8 @@
 @import (reference) "../defs.less";
 
+@banner-padding: 10px;
+@banner-padding-horizontal: 10px;
+
 
 .banner {
   background: @draft-color;
@@ -7,7 +10,7 @@
   border-color: @draft-border-color;
   border-style: solid;
   margin-bottom: 10px;
-  padding: 8px 10px;
+  padding: @banner-padding;
   z-index: @z-index-banner;
 
   &>h1, &>p {
@@ -33,3 +36,236 @@
     });
   }
 }
+
+
+/**
+ * The unified banner.
+ *
+ * This banner replaces a number of previous banners -- banners for the review
+ * request draft, review draft, and review reply drafts. It's split into two
+ * major parts:
+ *
+ * 1. The review area. This deals with reviews and drafts. It contains the main
+ *    "Review" menu, publish button, mode switcher (for choosing different
+ *    active drafts), and the change description field (when there's a review
+ *    request update draft).
+ *
+ * 2. The dock area. This is currently unused, but we have plans to use this
+ *    for an expandable file display on the diffviewer, as well as allow
+ *    extensions to drop in their own content.
+ *
+ * Modifiers:
+ *     -has-draft:
+ *         Whether there are any drafts present.
+ *
+ *     -has-multiple:
+ *         Whether there are multiple drafts present.
+ *
+ * Structure:
+ *     <div class="rb-c-unified-banner">
+ *      <div class="rb-c-unified-banner__review">...</div>
+ *      <div class="rb-c-unified-banner__dock">...</div>
+ *     </div>
+ */
+.rb-c-unified-banner {
+  display: none;
+  margin: -@page-container-padding -@page-container-padding
+          @page-container-padding -@page-container-padding;
+  z-index: @z-index-banner;
+
+  &.-has-multiple {
+    &::after {
+      background: @draft-color;
+      border-color: @draft-border-color;
+      border-style: solid;
+      border-width: 0 1px 1px 1px;
+      content: ' ';
+      display: block;
+      height: 2px;
+      margin: 0 0.3em;
+    }
+  }
+
+  a {
+    color: #rb-ns-ui.colors[@black];
+    cursor: pointer;
+    text-decoration: none;
+  }
+
+  /**
+   * The main section of the review banner relating to reviews and drafts.
+   *
+   * Modifiers:
+   *     -has-draft:
+   *         Whether there's any draft objects present.
+   *
+   * Structure:
+   *     <div class="rb-c-unified-banner__review">
+   *      <div class="rb-c-unified-banner__controls">
+   *      </div>
+   *      <div class="rb-c-unified-banner__changedesc">...</div>
+   *     </div>
+   */
+  &__review {
+    background: @review-request-bg;
+    border-bottom: 1px @review-request-border-color solid;
+    display: flex;
+    flex-direction: column;
+    padding: 0 @banner-padding;
+
+    .-has-draft & {
+      background: @draft-color;
+      border-bottom: 1px @draft-border-color solid;
+    }
+  }
+
+  /**
+   * The change description field.
+   *
+   * Structure:
+   *     <div clas="rb-c-unified-banner__changedesc">
+   *      <p>
+   *       <label for="field_change_description">
+   *        Describe your changes (optional):
+   *       </label>
+   *      </p>
+   *      <pre id="field_change_description" class="field field-text-area"
+   *           data-field-id="field_change_description"></pre>
+   *     </div>
+   */
+  &__changedesc {
+    padding-bottom: @banner-padding;
+
+    p {
+      margin: 0 0 @banner-padding;
+    }
+  }
+
+  /**
+   * The block of controls relating to reviews and drafts.
+   *
+   * Structure:
+   *     <div class="rb-c-unified-banner__controls">
+   *      <div class="rb-c-unified-banner__mode-selector">
+   *       ...
+   *      </div>
+   *      <div class="rb-c-unified-banner__draft-actions">
+   *       <input type="button" id="btn-review-request-discard"
+   *              value="Discard">
+   *      </div>
+   *      <menu class="rb-c-unified-banner__review-actions rb-c-actions"
+   *            role="menu">
+   *       ...
+   *      </menu>
+   *     </div>
+   */
+  &__controls {
+    align-items: baseline;
+    display: flex;
+    gap: @banner-padding;
+
+    > div:not(:empty) {
+      margin-right: 1em;
+    }
+  }
+
+  /**
+   * The draft mode selector.
+   *
+   * Structure:
+   *     <div class="rb-c-unified-banner__mode-selector">
+   *      <div class="rb-c-unified-banner__menu">
+   *       <a class="rb-c-unified-banner__mode">
+   *        <span class="rb-c-unified-banner__menu-label">...<?span>
+   *       </a>
+   *       <div class="rb-c-menu">...</div>
+   *      </div>
+   *     </div>
+   */
+  &__mode-selector {
+    margin-left: -@banner-padding;
+
+    .rb-c-menu {
+      background: @draft-color;
+      border-color: @draft-border-color;
+      font-size: 9pt;
+      font-weight: bold;
+      margin-left: -1px;
+      min-width: 30em;
+    }
+  }
+
+  /**
+   * The menu within the draft mode selector.
+   *
+   * Modifiers:
+   *     -is-open:
+   *         The menu is open.
+   */
+  &__menu {}
+
+  /**
+   * The mode label.
+   */
+  &__mode {}
+
+  &__menu.-is-open &__mode {
+    border-bottom: 1px @draft-color solid;
+    border-left: 1px @draft-border-color solid;
+    border-right: 1px @draft-border-color solid;
+    box-sizing: border-box;
+    margin: 0 -1px -1px -1px;
+    position: relative;
+    z-index: @z-index-menu + 1;
+  }
+
+  /**
+   * The "Review" menu and other actions.
+   */
+  &__review-actions {
+    margin: 0;
+    padding: 0;
+  }
+
+  .rb-c-actions {
+    list-style: none;
+  }
+
+  &__mode,
+  #action-review-menu > a {
+    box-sizing: border-box;
+    display: inline-block;
+    font-size: 9pt;
+    font-weight: bold;
+    padding: @banner-padding;
+  }
+
+  /**
+   * The "dock" portion of the unified banner.
+   */
+  &__dock:not(:empty) {
+    background: @review-request-bg;
+    border-bottom: 1px @review-request-border-color solid;
+    padding: @banner-padding;
+  }
+}
+
+.on-mobile-medium-screen-720({
+  .rb-c-unified-banner {
+    &__changedesc {
+      padding: 0 @banner-padding @banner-padding @banner-padding;
+    }
+
+    &__controls {
+      flex-wrap: wrap;
+    }
+
+    &__mode-selector {
+      margin-left: 0;
+    }
+
+    &__review {
+      padding: 0;
+    }
+  }
+});
diff --git a/reviewboard/static/rb/js/pages/views/diffViewerPageView.es6.js b/reviewboard/static/rb/js/pages/views/diffViewerPageView.es6.js
index 0645b381fdc05a8d30db3e1fb317281d2d1694ac..e30eec0d1d8a91b3f35042a4cee15c251a8023a9 100644
--- a/reviewboard/static/rb/js/pages/views/diffViewerPageView.es6.js
+++ b/reviewboard/static/rb/js/pages/views/diffViewerPageView.es6.js
@@ -174,7 +174,10 @@ RB.DiffViewerPageView = RB.ReviewablePageView.extend({
         RB.ReviewablePageView.prototype.remove.call(this);
 
         this._$window.off(`resize.${this.cid}`);
-        this._diffFileIndexView.remove();
+
+        if (this._diffFileIndexView) {
+            this._diffFileIndexView.remove();
+        }
 
         if (this._commitListView) {
             this._commitListView.remove();
@@ -653,7 +656,14 @@ RB.DiffViewerPageView = RB.ReviewablePageView.extend({
 
             let scrollAmount = this.DIFF_SCROLLDOWN_AMOUNT;
 
-            if (RB.DraftReviewBannerView.instance) {
+            if (RB.EnabledFeatures.unifiedBanner) {
+                const banner = RB.UnifiedBannerView.getInstance();
+
+                // This may be null if we're running in tests.
+                if (banner) {
+                    scrollAmount += banner.getHeight();
+                }
+            } else if (RB.DraftReviewBannerView.instance) {
                 scrollAmount += RB.DraftReviewBannerView.instance.getHeight();
             }
 
diff --git a/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js b/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js
index 9eec6cb3eacefb5eeb5d8cec4040d7dc20a8cf64..c86adfa5b3c1d55ddc9a99c3eb248cfd5de3ac00 100644
--- a/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js
+++ b/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js
@@ -123,7 +123,7 @@ RB.ReviewablePageView = RB.PageView.extend({
     events: _.defaults({
         'click #action-add-general-comment': '_onAddCommentClicked',
         'click #action-edit-review': '_onEditReviewClicked',
-        'click #action-ship-it': '_onShipItClicked',
+        'click #action-ship-it': 'shipIt',
         'click .rb-o-mobile-menu-label': '_onMenuClicked',
     }, RB.PageView.prototype.events),
 
@@ -167,6 +167,7 @@ RB.ReviewablePageView = RB.PageView.extend({
         this._favIconURL = null;
         this._favIconNotifyURL = null;
         this._logoNotificationsURL = null;
+        this._unifiedBanner = null;
 
         /*
          * Some extensions, like Power Pack and rbstopwatch, expect a few legacy
@@ -216,15 +217,31 @@ RB.ReviewablePageView = RB.PageView.extend({
         this._logoNotificationsURL = STATIC_URLS['rb/images/logo.png'];
 
         const pendingReview = this.model.get('pendingReview');
+        const reviewRequest = this.model.get('reviewRequest');
 
-        this.draftReviewBanner = RB.DraftReviewBannerView.create({
-            el: $('#review-banner'),
-            model: pendingReview,
-            reviewRequestEditor: this.model.reviewRequestEditor,
-        });
+        if (RB.EnabledFeatures.unifiedBanner) {
+            if (RB.UserSession.instance.get('authenticated')) {
+                this.unifiedBanner = new RB.UnifiedBannerView({
+                    el: $('#unified-banner'),
+                    model: new RB.UnifiedBanner({
+                        pendingReview: pendingReview,
+                        reviewRequest: reviewRequest,
+                        reviewRequestEditor: this.model.reviewRequestEditor,
+                    }),
+                    reviewRequestEditorView: this.reviewRequestEditorView,
+                });
+                this.unifiedBanner.render();
+            }
+        } else {
+            this.draftReviewBanner = RB.DraftReviewBannerView.create({
+                el: $('#review-banner'),
+                model: pendingReview,
+                reviewRequestEditor: this.model.reviewRequestEditor,
+            });
 
-        this.listenTo(pendingReview, 'destroy published',
-                      () => this.draftReviewBanner.hideAndReload());
+            this.listenTo(pendingReview, 'destroy published',
+                          () => this.draftReviewBanner.hideAndReload());
+        }
 
         this.reviewRequestEditorView.render();
 
@@ -235,8 +252,15 @@ RB.ReviewablePageView = RB.PageView.extend({
      * Remove this view from the page.
      */
     remove() {
-        this.draftReviewBanner.remove();
-        _super(this).remove.call(this);
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            this.draftReviewBanner.remove();
+        }
+
+        if (this._unifiedBanner) {
+            this._unifiedBanner.remove();
+        }
+
+        RB.PageView.prototype.remove.call(this);
     },
 
     /**
@@ -390,8 +414,10 @@ RB.ReviewablePageView = RB.PageView.extend({
             undefined,
             RB.UserSession.instance.get('commentsOpenAnIssue'));
 
-        this.listenTo(comment, 'saved',
-                      () => RB.DraftReviewBannerView.instance.show());
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            this.listenTo(comment, 'saved',
+                          () => RB.DraftReviewBannerView.instance.show());
+        }
 
         RB.CommentDialogView.create({
             comment: comment,
@@ -411,9 +437,12 @@ RB.ReviewablePageView = RB.PageView.extend({
      *    boolean:
      *    false, always.
      */
-    _onShipItClicked() {
+    async shipIt() {
         if (confirm(gettext('Are you sure you want to post this review?'))) {
-            this.model.markShipIt();
+            await this.model.markShipIt();
+
+            const reviewRequest = this.model.get('reviewRequest');
+            RB.navigateTo(reviewRequest.get('reviewURL'));
         }
 
         return false;
diff --git a/reviewboard/static/rb/js/pages/views/tests/diffViewerPageViewTests.es6.js b/reviewboard/static/rb/js/pages/views/tests/diffViewerPageViewTests.es6.js
index 86745931e86f9fac733fdf6aed482fb1b0ba4960..c79ebed3a1e76f8718ac62eed32280f8d8a3cada 100644
--- a/reviewboard/static/rb/js/pages/views/tests/diffViewerPageViewTests.es6.js
+++ b/reviewboard/static/rb/js/pages/views/tests/diffViewerPageViewTests.es6.js
@@ -62,9 +62,9 @@ suite('rb/pages/views/DiffViewerPageView', function() {
     const pageTemplate = dedent`
         <div>
          <div id="review-banner"></div>
+         <div id="unified-banner"></div>
          <div id="diff_commit_list">
-          <div class="commit-list-container">
-          </div>
+          <div class="commit-list-container"></div>
          </div>
          <div id="view_controls"></div>
          <div id="diffs"></div>
@@ -101,6 +101,12 @@ suite('rb/pages/views/DiffViewerPageView', function() {
                 parse: true,
             });
 
+        /* Don't communicate with the server for page updates. */
+        const reviewRequest = page.get('reviewRequest');
+        spyOn(reviewRequest, 'ready').and.resolveTo();
+        spyOn(reviewRequest.draft, 'ready').and.resolveTo();
+        spyOn(page.get('pendingReview'), 'ready').and.resolveTo();
+
         pageView = new RB.DiffViewerPageView({
             el: $(pageTemplate).appendTo($testsScratch),
             model: page,
@@ -127,6 +133,16 @@ suite('rb/pages/views/DiffViewerPageView', function() {
 
     afterEach(function() {
         RB.DnDUploader.instance = null;
+
+        if (RB.EnabledFeatures.unifiedBanner) {
+            RB.UnifiedBannerView.resetInstance();
+        }
+
+        if (pageView) {
+            pageView.remove();
+            pageView = null;
+        }
+
         Backbone.history.stop();
     });
 
@@ -399,9 +415,6 @@ suite('rb/pages/views/DiffViewerPageView', function() {
             });
 
             $diffs = pageView.$el.children('#diffs');
-
-            /* Don't communicate with the server for page updates. */
-            spyOn(page.get('reviewRequest'), 'ready').and.resolveTo();
         });
 
         describe('Anchors', function() {
@@ -445,7 +458,6 @@ suite('rb/pages/views/DiffViewerPageView', function() {
                     ],
                 }));
 
-                pageView.render();
                 pageView._updateAnchors(pageView.$el.find('table').eq(0));
 
                 expect(pageView._$anchors.length).toBe(4);
@@ -519,8 +531,6 @@ suite('rb/pages/views/DiffViewerPageView', function() {
                         }),
                     ]);
 
-                    pageView.render();
-
                     pageView.$el.find('table').each(function() {
                         pageView._updateAnchors($(this));
                     });
@@ -703,7 +713,6 @@ suite('rb/pages/views/DiffViewerPageView', function() {
                         }
 
                         it(label, function() {
-                            pageView.render();
                             spyOn(pageView, funcName);
                             triggerKeyPress(c);
                             expect(pageView[funcName]).toHaveBeenCalled();
@@ -741,8 +750,6 @@ suite('rb/pages/views/DiffViewerPageView', function() {
         describe('Reviewable Management', function() {
             beforeEach(function() {
                 spyOn(pageView, 'queueLoadDiff');
-
-                pageView.render();
             });
 
             it('File added', function() {
@@ -1111,7 +1118,6 @@ suite('rb/pages/views/DiffViewerPageView', function() {
             it('Anchor selection', function() {
                 const $anchor = $('<a name="test"/>');
 
-                pageView.render();
                 pageView.selectAnchor($anchor);
 
                 expect(router.navigate).toHaveBeenCalledWith(
@@ -1183,8 +1189,10 @@ suite('rb/pages/views/DiffViewerPageView', function() {
             $commitList = $testsScratch.find('#diff_commit_list');
 
             /* Don't communicate with the server for page updates. */
-            spyOn(page.get('reviewRequest'), 'ready')
-                .and.resolveTo();
+            const reviewRequest = page.get('reviewRequest');
+            spyOn(reviewRequest, 'ready').and.resolveTo();
+            spyOn(reviewRequest.draft, 'ready').and.resolveTo();
+            spyOn(page.get('pendingReview'), 'ready').and.resolveTo();
         });
 
         describe('Render', function() {
diff --git a/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.es6.js b/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.es6.js
index 04979ccc4a53a97a745bca5cd362e8f84d1e08c5..eefa04361322ff248c7eebf255a2b0bfa3afbe84 100644
--- a/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.es6.js
+++ b/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.es6.js
@@ -1,6 +1,7 @@
 suite('rb/pages/views/ReviewablePageView', function() {
     const pageTemplate = dedent`
         <div id="review-banner"></div>
+        <div id="unified-banner"></div>
         <a href="#" id="action-edit-review">Edit Review</a>
         <a href="#" id="action-ship-it">Ship It</a>
     `;
@@ -43,8 +44,9 @@ suite('rb/pages/views/ReviewablePageView', function() {
         });
 
         const reviewRequest = page.get('reviewRequest');
-
         spyOn(reviewRequest, 'ready').and.resolveTo();
+        spyOn(reviewRequest.draft, 'ready').and.resolveTo();
+        spyOn(page.get('pendingReview'), 'ready').and.resolveTo();
 
         pageView.render();
     });
@@ -52,6 +54,10 @@ suite('rb/pages/views/ReviewablePageView', function() {
     afterEach(function() {
         RB.DnDUploader.instance = null;
 
+        if (RB.EnabledFeatures.unifiedBanner) {
+            RB.UnifiedBannerView.resetInstance();
+        }
+
         pageView.remove();
     });
 
@@ -113,28 +119,39 @@ suite('rb/pages/views/ReviewablePageView', function() {
             });
 
             it('Confirmed', function(done) {
+                if (RB.EnabledFeatures.unifiedBanner) {
+                    pending();
+                    return;
+                }
+
                 spyOn(window, 'confirm').and.returnValue(true);
-                spyOn(pendingReview, 'ready').and.resolveTo();
                 spyOn(pendingReview, 'save').and.resolveTo();
                 spyOn(pendingReview, 'publish').and.callThrough();
-                spyOn(pageView.draftReviewBanner, 'hideAndReload')
-                    .and.callFake(() => {
-                        expect(window.confirm).toHaveBeenCalled();
-                        expect(pendingReview.ready).toHaveBeenCalled();
-                        expect(pendingReview.publish).toHaveBeenCalled();
-                        expect(pendingReview.save).toHaveBeenCalled();
-                        expect(pendingReview.get('shipIt')).toBe(true);
-                        expect(pendingReview.get('bodyTop')).toBe('Ship It!');
-
-                        done();
-                    });
+
+                if (!RB.EnabledFeatures.unifiedBanner) {
+                    spyOn(pageView.draftReviewBanner, 'hideAndReload')
+                        .and.callFake(() => {
+                            expect(window.confirm).toHaveBeenCalled();
+                            expect(pendingReview.ready).toHaveBeenCalled();
+                            expect(pendingReview.publish).toHaveBeenCalled();
+                            expect(pendingReview.save).toHaveBeenCalled();
+                            expect(pendingReview.get('shipIt')).toBe(true);
+                            expect(pendingReview.get('bodyTop')).toBe('Ship It!');
+
+                            done();
+                        });
+                }
 
                 $shipIt.click();
             });
 
             it('Canceled', function() {
+                if (RB.EnabledFeatures.unifiedBanner) {
+                    pending();
+                    return;
+                }
+
                 spyOn(window, 'confirm').and.returnValue(false);
-                spyOn(pendingReview, 'ready');
 
                 $shipIt.click();
 
diff --git a/reviewboard/static/rb/js/reviewRequestPage/index.ts b/reviewboard/static/rb/js/reviewRequestPage/index.ts
index c024f3f358aa7992697cbf5f48422bbc2a8f2447..9e16baa9476fd64dd075079cd9bdafc8ee1123b1 100644
--- a/reviewboard/static/rb/js/reviewRequestPage/index.ts
+++ b/reviewboard/static/rb/js/reviewRequestPage/index.ts
@@ -1,9 +1,11 @@
 import {
     ReviewReplyDraftBannerView,
+    ReviewReplyDraftStaticBannerView,
 } from './views/reviewReplyDraftBannerView';
 
 
 /* Define a namespace for RB.ReviewRequestPage. */
 export const ReviewRequestPage = {
     ReviewReplyDraftBannerView,
+    ReviewReplyDraftStaticBannerView,
 };
diff --git a/reviewboard/static/rb/js/reviewRequestPage/views/reviewReplyDraftBannerView.ts b/reviewboard/static/rb/js/reviewRequestPage/views/reviewReplyDraftBannerView.ts
index 88cdf69a571d00eeac55af46c559fe848bfac96d..cd2502c7752b83244c5aed173252ca406b50a681 100644
--- a/reviewboard/static/rb/js/reviewRequestPage/views/reviewReplyDraftBannerView.ts
+++ b/reviewboard/static/rb/js/reviewRequestPage/views/reviewReplyDraftBannerView.ts
@@ -1,4 +1,5 @@
-import { spina } from '@beanbag/spina';
+import { BaseView, spina } from '@beanbag/spina';
+
 import {
     FloatingBannerView,
     FloatingBannerViewOptions
@@ -44,6 +45,7 @@ export class ReviewReplyDraftBannerView extends FloatingBannerView<
     /**********************
      * Instance variables *
      **********************/
+
     #reviewRequestEditor: RB.ReviewRequestEditor;
     #template = _.template(dedent`
         <h1>${gettext('This reply is a draft.')}</h1>
@@ -58,7 +60,6 @@ export class ReviewReplyDraftBannerView extends FloatingBannerView<
          <label>
           <input type="checkbox" class="send-email" checked />
           ${gettext('Send E-Mail')}
-          <%- sendEmailText %>
         </label>
         <% } %>
     `);
@@ -112,3 +113,36 @@ export class ReviewReplyDraftBannerView extends FloatingBannerView<
         this.model.destroy();
     }
 }
+
+
+/**
+ * A static banner for review replies.
+ *
+ * This is used when the unified banner is enabled.
+ *
+ * Version Added:
+ *     6.0
+ */
+@spina
+export class ReviewReplyDraftStaticBannerView extends BaseView {
+    className = 'banner';
+
+    /**********************
+     * Instance variables *
+     **********************/
+
+    template = _.template(dedent`
+        <h1><%- draftText %></h1>
+        <p><%- reminderText %></p>
+    `);
+
+    /**
+     * Render the banner.
+     */
+    onInitialRender() {
+        this.$el.html(this.template({
+            draftText: _`This reply is a draft.`,
+            reminderText: _`Be sure to publish when finished.`,
+        }));
+    }
+}
diff --git a/reviewboard/static/rb/js/reviewRequestPage/views/reviewView.es6.js b/reviewboard/static/rb/js/reviewRequestPage/views/reviewView.es6.js
index 252f8546b6f427723f12c96bc937f986c4b6b7da..49bc8549e8f534effb6a43f02d32c642e8444ef5 100644
--- a/reviewboard/static/rb/js/reviewRequestPage/views/reviewView.es6.js
+++ b/reviewboard/static/rb/js/reviewRequestPage/views/reviewView.es6.js
@@ -56,6 +56,17 @@ RB.ReviewRequestPage.ReviewView = Backbone.View.extend({
         this._replyDraftsCount = 0;
 
         this.on('hasDraftChanged', hasDraft => {
+            if (RB.EnabledFeatures.unifiedBanner) {
+                const banner = RB.UnifiedBannerView.getInstance(false);
+
+                /*
+                 * We make this conditional to make unit tests easier to write.
+                 */
+                if (banner) {
+                    banner.model.updateReplyDraftState(this._reviewReply, hasDraft);
+                }
+            }
+
             if (hasDraft) {
                 this._showReplyDraftBanner();
             } else {
@@ -145,9 +156,7 @@ RB.ReviewRequestPage.ReviewView = Backbone.View.extend({
             }
         });
 
-        if (this._replyDraftsCount > 0) {
-            this.trigger('hasDraftChanged', true);
-        }
+        this.trigger('hasDraftChanged', this._replyDraftsCount > 0);
 
         /*
          * Load any diff fragments for comments made on this review. Each
@@ -341,15 +350,22 @@ RB.ReviewRequestPage.ReviewView = Backbone.View.extend({
      */
     _showReplyDraftBanner() {
         if (!this._draftBannerShown) {
-            this._bannerView =
-                new RB.ReviewRequestPage.ReviewReplyDraftBannerView({
-                    model: this._reviewReply,
-                    $floatContainer: this.options.$bannerFloatContainer,
-                    noFloatContainerClass:
-                        this.options.bannerNoFloatContainerClass,
-                    reviewRequestEditor: this.entryModel.get(
-                        'reviewRequestEditor'),
-                });
+            if (RB.EnabledFeatures.unifiedBanner) {
+                this._bannerView =
+                    new RB.ReviewRequestPage.ReviewReplyDraftStaticBannerView({
+                        model: this._reviewReply,
+                    });
+            } else {
+                this._bannerView =
+                    new RB.ReviewRequestPage.ReviewReplyDraftBannerView({
+                        model: this._reviewReply,
+                        $floatContainer: this.options.$bannerFloatContainer,
+                        noFloatContainerClass:
+                            this.options.bannerNoFloatContainerClass,
+                        reviewRequestEditor: this.entryModel.get(
+                            'reviewRequestEditor'),
+                    });
+            }
 
             this._bannerView.render();
             this._bannerView.$el.appendTo(this.options.$bannerParent);
diff --git a/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewRequestPageViewTests.es6.js b/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewRequestPageViewTests.es6.js
index 9121bd62c164972eab94435dbbed1a2cb149352e..44b90794f4f5fb94698c5e7f21b390cbb991cdb4 100644
--- a/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewRequestPageViewTests.es6.js
+++ b/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewRequestPageViewTests.es6.js
@@ -1,6 +1,7 @@
 suite('rb/reviewRequestPage/views/ReviewRequestPageView', function() {
     const template = dedent`
         <div id="review-banner"></div>
+        <div id="unified-banner"></div>
         <a id="collapse-all"></a>
         <a id="expand-all"></a>
         <div>
@@ -95,6 +96,11 @@ suite('rb/reviewRequestPage/views/ReviewRequestPageView', function() {
             el: $el.find('#review124'),
         }));
 
+        /* Don't communicate with the server for page updates. */
+        spyOn(reviewRequest, 'ready').and.resolveTo();
+        spyOn(reviewRequest.draft, 'ready').and.resolveTo();
+        spyOn(page.get('pendingReview'), 'ready').and.resolveTo();
+
         pageView.render();
 
         expect(pageView._entryViews.length).toBe(2);
@@ -104,6 +110,10 @@ suite('rb/reviewRequestPage/views/ReviewRequestPageView', function() {
 
     afterEach(function() {
         RB.DnDUploader.instance = null;
+
+        if (RB.EnabledFeatures.unifiedBanner) {
+            RB.UnifiedBannerView.resetInstance();
+        }
     });
 
     describe('Actions', function() {
diff --git a/reviewboard/static/rb/js/reviews/actions/views/reviewRequestActions.ts b/reviewboard/static/rb/js/reviews/views/reviewRequestActions.ts
similarity index 70%
rename from reviewboard/static/rb/js/reviews/actions/views/reviewRequestActions.ts
rename to reviewboard/static/rb/js/reviews/views/reviewRequestActions.ts
index 7a3d49c73b84f4ef54f4c00fc2585fc477f64dcc..4066971300338725f8f71a4b4d326fa3293d317a 100644
--- a/reviewboard/static/rb/js/reviews/actions/views/reviewRequestActions.ts
+++ b/reviewboard/static/rb/js/reviews/views/reviewRequestActions.ts
@@ -310,3 +310,168 @@ export class MuteActionView extends BaseVisibilityActionView {
                : _`Mute`;
     }
 }
+
+
+/**
+ * Action view to create a blank review.
+ *
+ * Version Added:
+ *     6.0
+ */
+@spina
+export class CreateReviewActionView extends Actions.ActionView {
+    events = {
+        'click': this.#onClick,
+    };
+
+    /**********************
+     * Instance variables *
+     **********************/
+
+    #pendingReview;
+
+    /**
+     * Initialize the view.
+     *
+     * Args:
+     *     options (object):
+     *         Options to pass through to the parent class.
+     */
+    initialize(options: object) {
+        super.initialize(options);
+
+        const page = RB.PageManager.getPage();
+        this.#pendingReview = page.pendingReview;
+    }
+
+    /**
+     * Render the action.
+     *
+     * Returns:
+     *     CreateReviewActionView:
+     *     This object, for chaining.
+     */
+    onInitialRender() {
+        this.listenTo(this.#pendingReview, 'saved destroy sync', this.#update);
+        this.#update();
+    }
+
+    /**
+     * Update the visibility state of the action.
+     *
+     * This will show the action only when there's no existing pending review.
+     */
+    #update() {
+        this.$el.parent().setVisible(this.#pendingReview.isNew());
+    }
+
+    /**
+     * Handle a click on the action.
+     *
+     * Args:
+     *     e (MouseEvent):
+     *         The event.
+     */
+    #onClick(e: MouseEvent) {
+        e.stopPropagation();
+        e.preventDefault();
+
+        this.#pendingReview.save();
+    }
+}
+
+
+/**
+ * Action view to pop up the edit review dialog.
+ *
+ * Version Added:
+ *     6.0
+ */
+@spina
+export class EditReviewActionView extends Actions.ActionView {
+    events = {
+        'click': this.#onClick,
+    };
+
+    /**********************
+     * Instance variables *
+     **********************/
+
+    #pendingReview;
+    #reviewRequestEditor;
+
+    /**
+     * Create the view.
+     *
+     * Args:
+     *     options (object):
+     *         Options to pass through to the parent class.
+     */
+    initialize(options: object) {
+        super.initialize(options);
+
+        const page = RB.PageManager.getPage();
+        this.#pendingReview = page.pendingReview;
+        this.#reviewRequestEditor = page.reviewRequestEditorView.model;
+    }
+
+    /**
+     * Render the action.
+     */
+    onInitialRender() {
+        this.listenTo(this.#pendingReview, 'saved destroy sync', this.#update);
+        this.#update();
+    }
+
+    /**
+     * Update the visibility state of the action.
+     */
+    #update() {
+        this.$el.parent().setVisible(!this.#pendingReview.isNew());
+    }
+
+    /**
+     * Handle a click on the action.
+     *
+     * Args:
+     *     e (MouseEvent):
+     *         The event.
+     */
+    #onClick(e: MouseEvent) {
+        e.stopPropagation();
+        e.preventDefault();
+
+        RB.ReviewDialogView.create({
+            review: this.#pendingReview,
+            reviewRequestEditor: this.#reviewRequestEditor,
+        });
+    }
+}
+
+
+/**
+ * Action view to mark a review request as "Ship It".
+ *
+ * Version Added:
+ *     6.0
+ */
+@spina
+export class ShipItActionView extends RB.Actions.ActionView {
+    events = {
+        'click': this.#onClick,
+    };
+
+    /**
+     * Handle a click on the action.
+     *
+     * Args:
+     *     e (MouseEvent):
+     *         The event.
+     */
+    #onClick(e: MouseEvent) {
+        e.preventDefault();
+        e.stopPropagation();
+
+        RB.PageManager.getPage().shipIt();
+    }
+}
diff --git a/reviewboard/static/rb/js/reviews/index.ts b/reviewboard/static/rb/js/reviews/index.ts
index 826f3609d5d95864ab294839d4009db44d039311..2e844f6682d3fd98c579d51470c03da199e5fcf6 100644
--- a/reviewboard/static/rb/js/reviews/index.ts
+++ b/reviewboard/static/rb/js/reviews/index.ts
@@ -1 +1,3 @@
-export * from './actions/views/reviewRequestActions';
+export * from './models/unifiedBanner';
+export * from './views/reviewRequestActions';
+export * from './views/unifiedBannerView';
diff --git a/reviewboard/static/rb/js/reviews/models/unifiedBanner.ts b/reviewboard/static/rb/js/reviews/models/unifiedBanner.ts
new file mode 100644
index 0000000000000000000000000000000000000000..547fc483359d403185d0a97a8ea0732f516b367a
--- /dev/null
+++ b/reviewboard/static/rb/js/reviews/models/unifiedBanner.ts
@@ -0,0 +1,304 @@
+/**
+ * The unified banner model.
+ */
+import { BaseModel, spina } from '@beanbag/spina';
+
+
+/**
+ * Information about a selectable draft mode.
+ *
+ * Version Added:
+ *     6.0
+ */
+interface DraftMode {
+    /** The user-visible text to display. */
+    text: string;
+
+    /** Whether this mode includes multiple items. */
+    multiple: boolean;
+
+    /** Whether this mode includes a review draft. */
+    hasReview: boolean;
+
+    /** Whether this mode includes one or more reply drafts. */
+    hasReviewReplies: boolean;
+
+    /** Whether this mode includes a review request draft. */
+    hasReviewRequest: boolean;
+
+    /** Whether this mode represents a single review reply. */
+    singleReviewReply?: number;
+}
+
+
+/**
+ * Attributes for the UnifiedBanner model.
+ *
+ * Version Added:
+ *     6.0
+ */
+interface UnifiedBannerAttrs {
+    /** The available draft modes. */
+    draftModes: DraftMode[];
+
+    /** The number of total drafts. */
+    numDrafts: number;
+
+    /** The pending review, used for any new review content. */
+    pendingReview: RB.Review;
+
+    /** The draft review replies. */
+    reviewReplyDrafts: RB.Review[];
+
+    /** The current review request. */
+    reviewRequest: RB.ReviewRequest;
+
+    /** The review request editor. */
+    reviewRequestEditor: RB.ReviewRequestEditor;
+
+    /** The currently selected draft mode (indexing into draftModes). */
+    selectedDraftMode: number;
+}
+
+
+/**
+ * State for the unified banner.
+ *
+ * Keeps track of drafts for the review request, review, and review replies.
+ *
+ * Version Added:
+ *     6.0
+ */
+@spina
+export class UnifiedBanner extends BaseModel<UnifiedBannerAttrs> {
+    defaults: UnifiedBannerAttrs = {
+        draftModes: [],
+        numDrafts: 0,
+        pendingReview: null,
+        reviewReplyDrafts: [],
+        reviewRequest: null,
+        reviewRequestEditor: null,
+        selectedDraftMode: 0,
+    };
+
+    /**
+     * Initialize the Unified Review Banner State.
+     *
+     * Sets listeners on the saved and destroy events for the review request
+     * and review to re-check if the state has at least one draft. At the end
+     * of initialization, checks if at least one draft exists already.
+     */
+    initialize() {
+        const reviewRequest = this.get('reviewRequest');
+        const pendingReview = this.get('pendingReview');
+        console.assert(reviewRequest, 'reviewRequest must be provided');
+        console.assert(pendingReview, 'pendingReview must be provided');
+
+        this.listenTo(reviewRequest.draft, 'saved destroy',
+                      this.#updateDraftModes);
+        this.listenTo(pendingReview, 'saved destroy', this.#updateDraftModes);
+
+        Promise.all([
+            reviewRequest.draft.ready(),
+            pendingReview.ready(),
+        ]).then(() => this.#updateDraftModes());
+    }
+
+    /**
+     * Update the draft state for the given review reply.
+     *
+     * Args:
+     *     reviewReply (RB.ReviewReply):
+     *         The review reply model.
+     *
+     *     hasReviewReplyDraft (boolean):
+     *          Whether the reviewReply passed in has a draft.
+     */
+    updateReplyDraftState(
+        reviewReply: RB.ReviewReply,
+        hasReviewReplyDraft: boolean,
+    ) {
+        const reviewReplyDrafts = this.get('reviewReplyDrafts');
+
+        if (hasReviewReplyDraft) {
+            if (!reviewReplyDrafts.includes(reviewReply)) {
+                reviewReplyDrafts.push(reviewReply);
+                this.set('reviewReplyDrafts', reviewReplyDrafts);
+            }
+        } else {
+            this.set('reviewReplyDrafts',
+                     _.without(reviewReplyDrafts, reviewReply));
+        }
+
+        this.#updateDraftModes();
+    }
+
+    /**
+     * Update the list of available draft modes.
+     */
+    #updateDraftModes() {
+        const reviewRequest = this.get('reviewRequest');
+        const pendingReview = this.get('pendingReview');
+        const reviewReplyDrafts = this.get('reviewReplyDrafts');
+
+        const reviewRequestPublic = reviewRequest.get('public');
+        const reviewRequestDraft = !reviewRequest.draft.isNew();
+        const reviewDraft = !pendingReview.isNew();
+        const numReplies = reviewReplyDrafts.length;
+        const numDrafts = (numReplies +
+                           (reviewRequestDraft ? 1 : 0) +
+                           (reviewDraft ? 1 : 0));
+
+        const draftModes: DraftMode[] = [];
+
+        if (!reviewRequestPublic) {
+            /* Review request has never been published */
+            draftModes.push({
+                hasReview: false,
+                hasReviewReplies: false,
+                hasReviewRequest: true,
+                multiple: false,
+                text: _`This review request is a draft`,
+            });
+        } else if (reviewRequestDraft) {
+            /* Review request draft */
+
+            if (reviewDraft) {
+                /* Review request draft + review draft */
+                draftModes.push({
+                    hasReview: false,
+                    hasReviewReplies: false,
+                    hasReviewRequest: true,
+                    multiple: false,
+                    text: _`Review request changes`,
+                });
+                draftModes.push({
+                    hasReview: true,
+                    hasReviewReplies: false,
+                    hasReviewRequest: false,
+                    multiple: false,
+                    text: _`Review of the change`,
+                });
+
+                if (numReplies > 0) {
+                    /* Review request draft + review draft + reply drafts */
+                    draftModes.unshift({
+                        hasReview: true,
+                        hasReviewReplies: true,
+                        hasReviewRequest: true,
+                        multiple: true,
+                        text: ngettext(
+                            `Changes, review, and ${numReplies} reply`,
+                            `Changes, review, and ${numReplies} replies`,
+                            numReplies),
+                    });
+                } else {
+                    draftModes.unshift({
+                        hasReview: true,
+                        hasReviewReplies: false,
+                        hasReviewRequest: true,
+                        multiple: true,
+                        text: _`Changes and review`,
+                    });
+                }
+            } else {
+                if (numReplies > 0) {
+                    /* Review request draft + reply drafts */
+                    draftModes.push({
+                        hasReview: false,
+                        hasReviewReplies: true,
+                        hasReviewRequest: true,
+                        multiple: true,
+                        text: ngettext(`Changes and ${numReplies} reply`,
+                                       `Changes and ${numReplies} replies`,
+                                       numReplies),
+                    });
+                    draftModes.push({
+                        hasReview: false,
+                        hasReviewReplies: false,
+                        hasReviewRequest: true,
+                        multiple: false,
+                        text: _`Review request changes`,
+                    });
+                } else {
+                    /* Review request draft only */
+                    draftModes.push({
+                        hasReview: false,
+                        hasReviewReplies: false,
+                        hasReviewRequest: true,
+                        multiple: false,
+                        text: _`Your review request has changed`,
+                    });
+                }
+            }
+        } else if (reviewDraft) {
+            /* Review draft */
+
+            if (numReplies > 0) {
+                /* Review draft + reply drafts */
+                draftModes.push({
+                    hasReview: true,
+                    hasReviewReplies: true,
+                    hasReviewRequest: false,
+                    multiple: true,
+                    text: ngettext(`Review and ${numReplies} reply`,
+                                   `Review and ${numReplies} replies`,
+                                   numReplies),
+                });
+                draftModes.push({
+                    hasReview: true,
+                    hasReviewReplies: false,
+                    hasReviewRequest: false,
+                    multiple: false,
+                    text: _`Review of the change`,
+                });
+            } else {
+                /* Review draft only */
+                draftModes.push({
+                    hasReview: true,
+                    hasReviewReplies: false,
+                    hasReviewRequest: false,
+                    multiple: false,
+                    text: _`Reviewing this change`,
+                });
+            }
+        } else {
+            if (numReplies > 1) {
+                /* Multiple reply drafts */
+                draftModes.push({
+                    hasReview: false,
+                    hasReviewReplies: true,
+                    hasReviewRequest: false,
+                    multiple: true,
+                    text: _`${numReplies} replies`,
+                });
+            }
+        }
+
+        for (let i = 0; i < reviewReplyDrafts.length; i++) {
+            const replyDraft = reviewReplyDrafts[i];
+            const review = replyDraft.get('parentObject');
+
+            draftModes.push({
+                hasReview: false,
+                hasReviewReplies: true,
+                hasReviewRequest: false,
+                multiple: false,
+                singleReviewReply: i,
+                text: _`Replying to ${review.get('authorName')}'s review`,
+            });
+        }
+
+        let selectedDraftMode = this.get('selectedDraftMode');
+
+        if (selectedDraftMode >= draftModes.length) {
+            selectedDraftMode = 0;
+        }
+
+        this.set({
+            draftModes,
+            numDrafts,
+            selectedDraftMode,
+        });
+    }
+}
diff --git a/reviewboard/static/rb/js/reviews/views/unifiedBannerView.ts b/reviewboard/static/rb/js/reviews/views/unifiedBannerView.ts
new file mode 100644
index 0000000000000000000000000000000000000000..88a1530ade117156a765f711258fa1326d7467af
--- /dev/null
+++ b/reviewboard/static/rb/js/reviews/views/unifiedBannerView.ts
@@ -0,0 +1,645 @@
+/**
+ * The unified banner view.
+ */
+import { BaseView, spina } from '@beanbag/spina';
+
+import { FloatingBannerView } from 'reviewboard/ui/views/floatingBannerView';
+import { MenuButtonView } from 'reviewboard/ui/views/menuButtonView';
+import { MenuType, MenuView } from 'reviewboard/ui/views/menuView';
+
+import { UnifiedBanner } from '../models/unifiedBanner';
+
+
+/**
+ * A view for a dropdown menu within the unified banner.
+ *
+ * Version Added:
+ *     6.0
+ */
+@spina
+class DraftModeMenu extends BaseView<UnifiedBanner> {
+    className = 'rb-c-unified-banner__menu';
+
+    /**********************
+     * Instance variables *
+     **********************/
+
+    #$arrow: JQuery;
+    #$label: JQuery;
+    #menuView: MenuView;
+
+    /**
+     * The events to listen to.
+     */
+    events = {
+        'focusout': this.#onFocusOut,
+        'keydown': this.#onKeyDown,
+        'mouseenter': this.#openMenu,
+        'mouseleave': this.#closeMenu,
+    };
+
+    modelEvents = {
+        'change:draftModes change:selectedDraftMode': this.#update,
+    };
+
+    /**
+     * Render the view.
+     */
+    onInitialRender() {
+        const label = _`Mode`;
+
+        this.#menuView = new MenuView({
+            $controller: this.$el,
+            ariaLabel: label,
+        });
+
+        this.$el.html(dedent`
+            <a class="rb-c-unified-banner__mode" tabindex="0">
+             <span class="rb-c-unified-banner__menu-label">
+              <span class="rb-icon rb-icon-edit-review"></span>
+              ${label}
+             </span>
+             <span class="rb-icon rb-icon-dropdown-arrow"></span>
+            </a>
+        `);
+
+        this.#menuView.renderInto(this.$el);
+
+        this.#$label = this.$('.rb-c-unified-banner__menu-label');
+        this.#$arrow = this.$('.rb-icon-dropdown-arrow');
+    }
+
+    /**
+     * Open the menu.
+     */
+    #openMenu() {
+        if (this.#menuView.$el.children().length > 0) {
+            this.#menuView.open({
+                animate: false,
+            });
+        }
+    }
+
+    /**
+     * Close the menu.
+     */
+    #closeMenu() {
+        if (this.#menuView.$el.children().length > 0) {
+            this.#menuView.close({
+                animate: false,
+            });
+        }
+    }
+
+    /**
+     * Handle a focus-out event.
+     *
+     * Args:
+     *     evt (FocusEvent):
+     *         The event object.
+     */
+    #onFocusOut(evt: FocusEvent) {
+        evt.stopPropagation();
+
+        /*
+         * Only close the menu if the focus has moved to something outside of
+         * this component.
+         */
+        const currentTarget = evt.currentTarget as Element;
+
+        if (!currentTarget.contains(evt.relatedTarget as Element)) {
+            this.#menuView.close({
+                animate: false,
+            });
+        }
+    }
+
+    /**
+     * Handle a key down event.
+     *
+     * When the menu has focus, this will take care of handling keyboard
+     * operations, allowing the menu to be opened or closed. Opening the menu
+     * will transfer the focus to the menu items.
+     *
+     * Args:
+     *     evt (KeyboardEvent):
+     *         The keydown event.
+     */
+    #onKeyDown(evt: KeyboardEvent) {
+        if (evt.key === 'ArrowDown' ||
+            evt.key === 'ArrowUp' ||
+            evt.key === 'Enter' ||
+            evt.key === ' ') {
+            evt.preventDefault();
+            evt.stopPropagation();
+
+            this.#menuView.open({
+                animate: false,
+            });
+            this.#menuView.focusFirstItem();
+        } else if (evt.key === 'Escape') {
+            evt.preventDefault();
+            evt.stopPropagation();
+
+            this.#menuView.close({
+                animate: false,
+            });
+        }
+    }
+
+    /**
+     * Update the state of the draft mode selector.
+     */
+    #update() {
+        const draftModes = this.model.get('draftModes');
+        const selectedDraftMode = this.model.get('selectedDraftMode');
+
+        this.#menuView.clearItems();
+
+        for (let i = 0; i < draftModes.length; i++) {
+            const text = draftModes[i].text;
+
+            if (i === selectedDraftMode) {
+                this.#$label.html(dedent`
+                    <span class="rb-icon rb-icon-edit-review"></span>
+                    ${text}
+                    `);
+            } else {
+                this.#menuView.addItem({
+                    onClick: () => this.model.set('selectedDraftMode', i),
+                    text: text,
+                });
+            }
+        }
+
+        this.#$arrow.setVisible(draftModes.length > 1);
+    }
+}
+
+
+/**
+ * The publish button.
+ *
+ * Version Added:
+ *     6.0
+ */
+@spina
+class PublishButtonView extends MenuButtonView<UnifiedBanner> {
+    modelEvents = {
+        'change:draftModes change:selectedDraftMode': this.#update,
+    };
+
+    /**********************
+     * Instance variables *
+     **********************/
+
+    #$archiveCheckbox: JQuery;
+    #$trivialCheckbox: JQuery;
+
+    /**
+     * Initialize the view.
+     */
+    initialize() {
+        super.initialize({
+            ariaMenuLabel: _`Publish All`,
+            hasPrimaryButton: true,
+            menuIconClass: 'fa fa-gear',
+            menuType: MenuType.Button,
+            onPrimaryButtonClick: this.#onPublishClicked,
+            text: _`Publish All`,
+        });
+    }
+
+    /**
+     * Render the view.
+     */
+    onInitialRender() {
+        super.onInitialRender();
+
+        this.#$trivialCheckbox = $(
+            '<input checked type="checkbox" id="publish-button-trivial">');
+        this.#$archiveCheckbox = $(
+            '<input type="checkbox" id="publish-button-archive">');
+
+        const reviewRequestEditor = this.model.get('reviewRequestEditor');
+
+        if (reviewRequestEditor.get('showSendEmail')) {
+            const $onlyEmail = this.menu.addItem()
+                .append(this.#$trivialCheckbox);
+
+            $('<label for="publish-button-trivial">')
+                .text(_`Send E-Mail`)
+                .appendTo($onlyEmail);
+        }
+
+        const $archive = this.menu.addItem()
+            .append(this.#$archiveCheckbox);
+
+        $('<label for="publish-button-archive">')
+            .text(_`Archive after publishing`)
+            .appendTo($archive);
+
+        this.#update();
+    }
+
+    /**
+     * Callback for when the publish button is clicked.
+     */
+    #onPublishClicked() {
+        this.trigger('publish', {
+            archive: this.#$archiveCheckbox.is(':checked'),
+            trivial: !this.#$trivialCheckbox.is(':checked'),
+        });
+    }
+
+    /**
+     * Update the state of the publish button.
+     */
+    #update() {
+        const draftModes = this.model.get('draftModes');
+        const selectedDraftMode = this.model.get('selectedDraftMode');
+
+        if (!this.rendered || draftModes.length === 0) {
+            return;
+        }
+
+        if (draftModes[selectedDraftMode].multiple) {
+            this.$primaryButton.text(_`Publish All`);
+        } else {
+            this.$primaryButton.text(_`Publish`);
+        }
+    }
+}
+
+
+/**
+ * Options for the unified banner view.
+ *
+ * Version Added:
+ *     6.0
+ */
+interface UnifiedBannerViewOptions {
+    /** The review request editor. */
+    reviewRequestEditorView: RB.ReviewRequestEditorView;
+}
+
+
+/**
+ * The unified banner.
+ *
+ * This is a unified, multi-mode banner that provides basic support for
+ * publishing, editing, and discarding reviews, review requests, and
+ * review replies.
+ *
+ * The banner displays at the top of the page under the topbar and floats to
+ * the top of the browser window when the user scrolls down.
+ *
+ * Version Added:
+ *     6.0
+ */
+@spina
+export class UnifiedBannerView extends FloatingBannerView<
+    UnifiedBanner,
+    HTMLDivElement,
+    UnifiedBannerViewOptions
+> {
+    static instance: UnifiedBannerView = null;
+
+    events = {
+        'click #btn-review-request-discard': this.#discardDraft,
+    };
+
+    modelEvents = {
+        'change': this.#update,
+    };
+
+    /**********************
+     * Instance variables *
+     **********************/
+
+    #$changedesc: JQuery;
+    #$discardButton: JQuery;
+    #$draftActions: JQuery;
+    #$modeSelector: JQuery;
+    #$reviewActions: JQuery;
+    #modeMenu: DraftModeMenu;
+    #publishButton: PublishButtonView;
+    #reviewRequestEditorView: RB.ReviewRequestEditorView;
+
+    /**
+     * Reset the UnifiedBannerView instance.
+     *
+     * This is used in unit tests to reset the state after tests run.
+     */
+    static resetInstance() {
+        if (this.instance !== null) {
+            this.instance.remove();
+            this.instance = null;
+        }
+    }
+
+    /**
+     * Return the UnifiedBannerView instance.
+     *
+     * If the banner does not yet exist, this will create it.
+     *
+     * Args:
+     *     required (boolean, optional):
+     *         Whether the instance is required to exist.
+     *
+     * Returns:
+     *     RB.UnifiedBannerView:
+     *     The banner view.
+     */
+    static getInstance(
+        required = false,
+    ): UnifiedBannerView {
+        if (required) {
+            console.assert(
+                this.instance,
+                'Unified banner instance has not been created');
+        }
+
+        return this.instance;
+    }
+
+    /**
+     * Initialize the banner.
+     *
+     * Args:
+     *     options (object):
+     *         Options for the banner. See :js:class:`RB.FloatingBannerView`
+     *         for details.
+     */
+    initialize(options: UnifiedBannerViewOptions) {
+        super.initialize(_.defaults(options, {
+            $floatContainer: $('#page-container'),
+            noFloatContainerClass: 'collapsed',
+        }));
+
+        this.#reviewRequestEditorView = options.reviewRequestEditorView;
+        UnifiedBannerView.instance = this;
+    }
+
+    /**
+     * Render the banner.
+     */
+    onInitialRender() {
+        if (!RB.UserSession.instance.get('authenticated')) {
+            return;
+        }
+
+        super.onInitialRender();
+
+        const model = this.model;
+
+        this.#$modeSelector = this.$('.rb-c-unified-banner__mode-selector');
+        this.#$draftActions = this.$('.rb-c-unified-banner__draft-actions');
+        this.#$reviewActions = this.$('.rb-c-unified-banner__review-actions');
+        this.#$changedesc = this.$('.rb-c-unified-banner__changedesc');
+
+        this.#modeMenu = new DraftModeMenu({
+            model: model,
+        });
+        this.#modeMenu.renderInto(this.$el);
+
+        this.#publishButton = new PublishButtonView({
+            model: model,
+        });
+        this.#publishButton.$el.prependTo(this.#$draftActions);
+        this.listenTo(this.#publishButton, 'publish', this.publish);
+        this.#publishButton.render();
+
+        this.#$discardButton = this.$('#btn-review-request-discard');
+
+        const reviewRequestEditor = model.get('reviewRequestEditor');
+        const reviewRequest = model.get('reviewRequest');
+
+        const $changeDescription = this.$('#field_change_description')
+            .html(reviewRequestEditor.get('changeDescriptionRenderedText'))
+            .toggleClass('editable', reviewRequestEditor.get('mutableByUser'))
+            .toggleClass('rich-text',
+                         reviewRequest.get('changeDescriptionRichText'));
+
+        this.#reviewRequestEditorView.addFieldView(
+            new RB.ReviewRequestFields.ChangeDescriptionFieldView({
+                el: $changeDescription,
+                fieldID: 'change_description',
+                model: reviewRequestEditor,
+            }));
+    }
+
+    /**
+     * Handle re-renders.
+     */
+    onRender() {
+        this.#update();
+    }
+
+    /**
+     * Update the state of the banner.
+     */
+    #update() {
+        if (!this.rendered) {
+            return;
+        }
+
+        const model = this.model;
+        const draftModes = model.get('draftModes');
+        const selectedDraftMode = model.get('selectedDraftMode');
+        const numDrafts = model.get('numDrafts');
+
+        const reviewRequest = model.get('reviewRequest');
+        const reviewRequestState = reviewRequest.get('state');
+        const reviewRequestPublic = reviewRequest.get('public');
+        const reviewRequestDraft = !reviewRequest.draft.isNew();
+
+        this.#$discardButton.setVisible(
+            draftModes.length > 0 &&
+            !draftModes[selectedDraftMode].multiple);
+        this.#$modeSelector.setVisible(numDrafts > 0);
+        this.#$draftActions.setVisible(numDrafts > 0);
+        this.#$changedesc.setVisible(
+            reviewRequestPublic && reviewRequestDraft);
+
+        this.$el
+            .toggleClass('-has-draft',
+                         (reviewRequestPublic === false || numDrafts > 0))
+            .toggleClass('-has-multiple', numDrafts > 1)
+            .setVisible(reviewRequestState === RB.ReviewRequest.PENDING);
+    }
+
+    /**
+     * Return the height of the banner.
+     *
+     * Returns:
+     *     number:
+     *     The height of the banner, in pixels.
+     */
+    getHeight(): number {
+        return this.$el.outerHeight();
+    }
+
+    /**
+     * Publish the current draft.
+     *
+     * This triggers an event which is handled by RB.ReviewRequestEditorView.
+     */
+    async publish(
+        options: {
+            archive: boolean,
+            trivial: boolean,
+        },
+    ): Promise<void> {
+        const model = this.model;
+        const selectedDraftMode = model.get('selectedDraftMode');
+        const draftModes = model.get('draftModes');
+        const draftMode = draftModes[selectedDraftMode];
+        const reviewRequestEditor = model.get('reviewRequestEditor');
+        const reviewRequest = reviewRequestEditor.get('reviewRequest');
+        const pendingReview = model.get('pendingReview');
+        const reviewReplyDrafts = model.get('reviewReplyDrafts');
+
+        const reviews: number[] = [];
+        const reviewRequests: number[] = [];
+
+        if (draftMode.hasReviewRequest) {
+            await reviewRequest.ready();
+            reviewRequests.push(reviewRequest.get('id'));
+        }
+
+        if (draftMode.hasReview) {
+            await pendingReview.ready();
+            reviews.push(pendingReview.get('id'));
+        }
+
+        if (draftMode.hasReviewReplies) {
+            for (const reply of reviewReplyDrafts) {
+                await reply.ready();
+                reviews.push(reply.get('id'));
+            }
+        } else if (draftMode.singleReviewReply !== undefined) {
+            const reply = reviewReplyDrafts[draftMode.singleReviewReply];
+            await reply.ready();
+            reviews.push(reply.get('id'));
+        }
+
+        try {
+            await this.#runPublishBatch(reviewRequest.get('localSitePrefix'),
+                                        reviewRequests,
+                                        reviews,
+                                        !!options.trivial,
+                                        !!options.archive);
+        } catch (err) {
+            alert(err);
+        }
+
+        RB.navigateTo(reviewRequest.get('reviewURL'));
+    }
+
+    /**
+     * Run the publish batch operation.
+     *
+     * Args:
+     *     localSitePrefix (string):
+     *         The URL prefix for the local site, if present.
+     *
+     *     reviewRequests (Array of number):
+     *         The set of review request IDs to publish.
+     *
+     *     reviews (Array of number):
+     *         The set of review IDs to publish.
+     *
+     *     trivial (boolean):
+     *         Whether to suppress notification e-mails.
+     *
+     *     archive (boolean):
+     *         Whether to archive the affected review request after publishing.
+     *
+     * Returns:
+     *     Promise:
+     *     A promise which resolves when the operation is complete or rejects
+     *     with an error string.
+     */
+    #runPublishBatch(
+        localSitePrefix: string,
+        reviewRequests: number[],
+        reviews: number[],
+        trivial: boolean,
+        archive: boolean,
+    ): Promise<void> {
+        return new Promise((resolve, reject) => {
+            RB.apiCall({
+                data: {
+                    batch: JSON.stringify({
+                        archive: archive,
+                        op: 'publish',
+                        review_requests: reviewRequests,
+                        reviews: reviews,
+                        trivial: trivial,
+                    }),
+                },
+                prefix: localSitePrefix,
+                url: '/r/_batch/',
+
+                error: xhr => {
+                    const rsp = xhr.responseJSON;
+
+                    if (rsp && rsp.stat) {
+                        reject(rsp.error);
+                    } else {
+                        console.error(
+                            'Failed to run publish batch operation', xhr);
+                        reject(xhr.statusText);
+                    }
+                },
+                success: () => {
+                    resolve();
+                },
+            });
+        });
+    }
+
+    /**
+     * Discard the current draft.
+     *
+     * Depending on the selected view mode, this will either discard the
+     * pending review, discard the current review request draft, or close the
+     * (unpublished) review request as discarded.
+     */
+    async #discardDraft() {
+        const model = this.model;
+        const selectedDraftMode = model.get('selectedDraftMode');
+        const draftModes = model.get('draftModes');
+        const draftMode = draftModes[selectedDraftMode];
+        const reviewRequest = model.get('reviewRequest');
+
+        try {
+            if (draftMode.hasReview) {
+                const pendingReview = model.get('pendingReview');
+                await pendingReview.destroy();
+
+                RB.navigateTo(reviewRequest.get('reviewURL'));
+            } else if (draftMode.hasReviewRequest) {
+                if (!reviewRequest.get('public')) {
+                    await reviewRequest.close({
+                        type: RB.ReviewRequest.CLOSE_DISCARDED,
+                    });
+                } else if (!reviewRequest.draft.isNew()) {
+                    await reviewRequest.draft.destroy();
+                }
+
+                RB.navigateTo(reviewRequest.get('reviewURL'));
+            } else if (draftMode.singleReviewReply !== undefined) {
+                const reviewReplyDrafts = model.get('reviewReplyDrafts');
+                const reply = reviewReplyDrafts[draftMode.singleReviewReply];
+
+                await reply.destroy();
+            } else {
+                console.error('Discard reached with no active drafts.');
+            }
+        } catch(err) {
+            alert(err.xhr.errorText);
+        }
+    }
+}
diff --git a/reviewboard/static/rb/js/ui/views/floatingBannerView.ts b/reviewboard/static/rb/js/ui/views/floatingBannerView.ts
index ce5b8cc969634f24df1aaadfec8b6901de2df916..6ef8ce1ccbaa80de88ac1ca1b5f27c3ba57ee3d9 100644
--- a/reviewboard/static/rb/js/ui/views/floatingBannerView.ts
+++ b/reviewboard/static/rb/js/ui/views/floatingBannerView.ts
@@ -103,7 +103,7 @@ export class FloatingBannerView<
 
                 this.$el.width(
                     Math.ceil(rect.width) -
-                    this.$el.getExtents('bpm', 'lr'));
+                    Math.max(this.$el.getExtents('bpm', 'lr'), 0));
             } else {
                 this.$el.width('auto');
             }
@@ -134,7 +134,6 @@ export class FloatingBannerView<
         const windowTop = $(window).scrollTop();
         const topOffset = this.#$floatSpacer.offset().top - windowTop;
         const outerHeight = this.$el.outerHeight(true);
-
         const wasFloating = this.$el.hasClass('floating');
 
         if (!this.#$floatContainer.hasClass(this.#noFloatContainerClass) &&
@@ -164,7 +163,10 @@ export class FloatingBannerView<
 
                 this.$el
                     .addClass('floating')
-                    .css('position', 'fixed');
+                    .css({
+                        'margin-top': 0,
+                        'position': 'fixed',
+                    });
             }
 
             this.$el.css('top',
@@ -182,8 +184,9 @@ export class FloatingBannerView<
             this.$el
                 .removeClass('floating')
                 .css({
-                    position: '',
-                    top: '',
+                    'margin-top': '',
+                    'position': '',
+                    'top': '',
                 });
             this.#$floatSpacer
                 .height('auto')
diff --git a/reviewboard/static/rb/js/ui/views/menuButtonView.ts b/reviewboard/static/rb/js/ui/views/menuButtonView.ts
index 7ebec4975c09086c58b32d987965e00c514b9bac..3c607a521f7142341738e66b82a68fc5ca32d203 100644
--- a/reviewboard/static/rb/js/ui/views/menuButtonView.ts
+++ b/reviewboard/static/rb/js/ui/views/menuButtonView.ts
@@ -136,7 +136,7 @@ export class MenuButtonView<
                   id="<%- labelID %>"
                   type="button"
                   aria-label="<%- menuLabel %>">
-           <span class="rb-icon rb-icon-dropdown-arrow"></span>
+           <span class="<%- menuIconClass %>"></span>
           </button>
          </div>
         <% } else { %>
@@ -144,7 +144,7 @@ export class MenuButtonView<
                  id="<%- labelID %>"
                  type="button">
           <%- buttonText %>
-          <span class="rb-icon rb-icon-dropdown-arrow"></span>
+          <span class="<%- menuIconClass %>"></span>
          </button>
         <% } %>
     `);
diff --git a/reviewboard/static/rb/js/ui/views/menuView.ts b/reviewboard/static/rb/js/ui/views/menuView.ts
index c7377a2a999be0186042764a52eb9318c15daaac..2cc5b33e146f039a39ed1644c50cb51bed60be64 100644
--- a/reviewboard/static/rb/js/ui/views/menuView.ts
+++ b/reviewboard/static/rb/js/ui/views/menuView.ts
@@ -310,6 +310,13 @@ export class MenuView extends BaseView<
         return $el;
     }
 
+    /**
+     * Clear all the menu items.
+     */
+    clearItems() {
+        this.$('.rb-c-menu__item').remove();
+    }
+
     /**
      * Open the menu.
      *
@@ -410,11 +417,7 @@ export class MenuView extends BaseView<
             this.trigger(opened ? 'opening' : 'closing');
         }
 
-        if (opened) {
-            this.$el.addClass('-is-open');
-        } else {
-            this.$el.removeClass('-is-open');
-        }
+        this.$el.toggleClass('-is-open', opened);
 
         if (this.$controller) {
             this.$controller
diff --git a/reviewboard/static/rb/js/views/abstractCommentBlockView.es6.js b/reviewboard/static/rb/js/views/abstractCommentBlockView.es6.js
index 143f0b6fde2aeb4b51dfa48b32f73ea45a62e288..0e978f88191d7d8eb7e6c4412f7ec464b8f0f5a4 100644
--- a/reviewboard/static/rb/js/views/abstractCommentBlockView.es6.js
+++ b/reviewboard/static/rb/js/views/abstractCommentBlockView.es6.js
@@ -208,7 +208,9 @@ RB.AbstractCommentBlockView = Backbone.View.extend({
                 this.notify(gettext('Comment Saved'));
             }
 
-            RB.DraftReviewBannerView.instance.show();
+            if (!RB.EnabledFeatures.unifiedBanner) {
+                RB.DraftReviewBannerView.instance.show();
+            }
         });
 
         this.$el.addClass('draft');
diff --git a/reviewboard/static/rb/js/views/reviewDialogView.es6.js b/reviewboard/static/rb/js/views/reviewDialogView.es6.js
index fd21389c20cdee2c3ef7a7c4387bcb904ba0edbe..404db560fa1b6b659b1477c91a8235a16a69987c 100644
--- a/reviewboard/static/rb/js/views/reviewDialogView.es6.js
+++ b/reviewboard/static/rb/js/views/reviewDialogView.es6.js
@@ -1261,7 +1261,10 @@ RB.ReviewDialogView = Backbone.View.extend({
             .click(async () => {
                 this.close();
                 await this.model.destroy();
-                RB.DraftReviewBannerView.instance.hideAndReload();
+
+                if (!RB.EnabledFeatures.unifiedBanner) {
+                    RB.DraftReviewBannerView.instance.hideAndReload();
+                }
             });
 
         $('<p/>')
@@ -1352,17 +1355,19 @@ RB.ReviewDialogView = Backbone.View.extend({
         });
 
         $.funcQueue('reviewForm').add(() => {
-            const reviewBanner = RB.DraftReviewBannerView.instance;
-
             this.close();
 
-            if (reviewBanner) {
-                if (publish) {
-                    reviewBanner.hideAndReload();
-                } else if (this.model.isNew() && !madeChanges) {
-                    reviewBanner.hide();
-                } else {
-                    reviewBanner.show();
+            if (!RB.EnabledFeatures.unifiedBanner) {
+                const reviewBanner = RB.DraftReviewBannerView.instance;
+
+                if (reviewBanner) {
+                    if (publish) {
+                        reviewBanner.hideAndReload();
+                    } else if (this.model.isNew() && !madeChanges) {
+                        reviewBanner.hide();
+                    } else {
+                        reviewBanner.show();
+                    }
                 }
             }
 
diff --git a/reviewboard/static/rb/js/views/reviewRequestEditorView.es6.js b/reviewboard/static/rb/js/views/reviewRequestEditorView.es6.js
index 314ce20f1cee938a85c24b94d0272932007cb80b..685976b4a02bcfafabe6a4c9e5a0070f338d67e6 100644
--- a/reviewboard/static/rb/js/views/reviewRequestEditorView.es6.js
+++ b/reviewboard/static/rb/js/views/reviewRequestEditorView.es6.js
@@ -460,6 +460,7 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
                 .html(err.errorText)
                 .show();
         });
+
         this.listenTo(view, 'fieldSaved', this.showBanner);
 
         if (this.rendered) {
@@ -579,6 +580,7 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
         this.model.on('saved', this.showBanner, this);
         this.model.on('published', this._refreshPage, this);
         reviewRequest.on('closed reopened', this._refreshPage, this);
+
         draft.on('destroyed', this._refreshPage, this);
 
         window.onbeforeunload = this._onBeforeUnload.bind(this);
@@ -635,7 +637,8 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
         } else if (state === RB.ReviewRequest.CLOSE_DISCARDED) {
             BannerClass = DiscardedBannerView;
         } else if (state === RB.ReviewRequest.PENDING &&
-                   this.model.get('hasDraft')) {
+                   this.model.get('hasDraft') &&
+                   !RB.EnabledFeatures.unifiedBanner) {
             BannerClass = DraftBannerView;
         } else {
             return;
@@ -805,7 +808,10 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
 
         view.on('beginEdit', () => this.model.incr('editCount'));
         view.on('endEdit', () => this.model.decr('editCount'));
-        view.on('commentSaved', () => RB.DraftReviewBannerView.instance.show());
+
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            view.on('commentSaved', () => RB.DraftReviewBannerView.instance.show());
+        }
     },
 
     /**
diff --git a/reviewboard/static/rb/js/views/tests/reviewRequestEditorViewTests.es6.js b/reviewboard/static/rb/js/views/tests/reviewRequestEditorViewTests.es6.js
index 632b3067d7d71f591a0101aefdad8be9c9215a69..72d67b6144c5b26142298fe0d4d10dc3fc171cfa 100644
--- a/reviewboard/static/rb/js/views/tests/reviewRequestEditorViewTests.es6.js
+++ b/reviewboard/static/rb/js/views/tests/reviewRequestEditorViewTests.es6.js
@@ -1,6 +1,10 @@
 suite('rb/views/ReviewRequestEditorView', function() {
     const template = dedent`
         <div>
+         <div class="rb-c-unified-banner" id="unified-banner">
+          <pre id="field_change_description" class="field field-text-area"
+               data-field-id="field_change_description"></pre>
+         </div>
          <div id="review-request-banners"></div>
          <div id="review-request-warning"></div>
          <div class="actions">
@@ -200,6 +204,25 @@ suite('rb/views/ReviewRequestEditorView', function() {
                 model: editor,
             }));
 
+        spyOn(reviewRequest.draft, 'ready').and.resolveTo();
+
+        if (RB.EnabledFeatures.unifiedBanner) {
+            const pendingReview = reviewRequest.createReview();
+            spyOn(pendingReview, 'ready').and.resolveTo();
+            spyOn(reviewRequest, 'ready').and.resolveTo();
+
+            const banner = new RB.UnifiedBannerView({
+                el: $el.find('#unified-banner'),
+                model: new RB.UnifiedBanner({
+                    pendingReview: pendingReview,
+                    reviewRequest: reviewRequest,
+                    reviewRequestEditor: editor,
+                }),
+                reviewRequestEditorView: view,
+            });
+            banner.render();
+        }
+
         $filesContainer = $testsScratch.find('#file-list');
         $screenshotsContainer = $testsScratch.find('#screenshot-thumbnails');
 
@@ -208,42 +231,40 @@ suite('rb/views/ReviewRequestEditorView', function() {
          *     function will go away.
          */
         spyOn(view, '_refreshPage');
-
-        spyOn(reviewRequest.draft, 'ready').and.resolveTo();
     });
 
     afterEach(function() {
         RB.DnDUploader.instance = null;
+
+        if (RB.EnabledFeatures.unifiedBanner) {
+            RB.UnifiedBannerView.resetInstance();
+        }
     });
 
     describe('Actions bar', function() {
         it('ReviewRequestActionHooks', function() {
-            var MyExtension,
-                extension,
-                $action;
-
-            MyExtension = RB.Extension.extend({
+            const MyExtension = RB.Extension.extend({
                 initialize: function() {
                     RB.Extension.prototype.initialize.call(this);
 
                     new RB.ReviewRequestActionHook({
-                        extension: this,
                         callbacks: {
                             '#my-action': _.bind(function() {
                                 this.actionClicked = true;
-                            }, this)
-                        }
+                            }, this),
+                        },
+                        extension: this,
                     });
-                }
+                },
             });
 
-            extension = new MyExtension();
+            const extension = new MyExtension();
 
             /*
              * Actions are rendered server-side, not client-side, so we won't
              * get the action added through the hook above.
              */
-            $action = $('<a href="#" id="my-action" />')
+            const $action = $('<a href="#" id="my-action" />')
                 .appendTo(view.$('.actions'));
 
             view.render();
@@ -261,12 +282,22 @@ suite('rb/views/ReviewRequestEditorView', function() {
         describe('Draft banner', function() {
             describe('Visibility', function() {
                 it('Hidden when saving', function() {
+                    if (RB.EnabledFeatures.unifiedBanner) {
+                        pending();
+                        return;
+                    }
+
                     expect(view.banner).toBe(null);
                     editor.trigger('saving');
                     expect(view.banner).toBe(null);
                 });
 
                 it('Show when saved', function(done) {
+                    if (RB.EnabledFeatures.unifiedBanner) {
+                        pending();
+                        return;
+                    }
+
                     const summaryField = view.getFieldView('summary');
                     const summaryEditor = summaryField.inlineEditorView;
 
@@ -299,6 +330,11 @@ suite('rb/views/ReviewRequestEditorView', function() {
                     });
                 });
                 it('Discard Draft', function() {
+                    if (RB.EnabledFeatures.unifiedBanner) {
+                        pending();
+                        return;
+                    }
+
                     view.model.set('hasDraft', true);
                     view.showBanner();
 
@@ -310,6 +346,11 @@ suite('rb/views/ReviewRequestEditorView', function() {
                 });
 
                 it('Discard Review Request', function() {
+                    if (RB.EnabledFeatures.unifiedBanner) {
+                        pending();
+                        return;
+                    }
+
                     reviewRequest.set('public', false);
                     view.model.set('hasDraft', true);
                     view.showBanner();
@@ -350,6 +391,11 @@ suite('rb/views/ReviewRequestEditorView', function() {
                     });
 
                     it('Basic publishing', function(done) {
+                        if (RB.EnabledFeatures.unifiedBanner) {
+                            pending();
+                            return;
+                        }
+
                         view.showBanner();
 
                         reviewRequest.draft.publish.and.callFake(() => {
@@ -364,6 +410,11 @@ suite('rb/views/ReviewRequestEditorView', function() {
                     });
 
                     it('With submitter changed', function(done) {
+                        if (RB.EnabledFeatures.unifiedBanner) {
+                            pending();
+                            return;
+                        }
+
                         reviewRequest.draft.set({
                             links: {
                                 submitter: {
@@ -388,6 +439,11 @@ suite('rb/views/ReviewRequestEditorView', function() {
                     });
 
                     it('With Send E-Mail turned on', function(done) {
+                        if (RB.EnabledFeatures.unifiedBanner) {
+                            pending();
+                            return;
+                        }
+
                         view.model.set('showSendEmail', true);
                         view.showBanner();
 
@@ -405,6 +461,11 @@ suite('rb/views/ReviewRequestEditorView', function() {
                     });
 
                     it('With Send E-Mail turned off', function(done) {
+                        if (RB.EnabledFeatures.unifiedBanner) {
+                            pending();
+                            return;
+                        }
+
                         view.model.set('showSendEmail', true);
                         view.showBanner();
 
@@ -435,16 +496,31 @@ suite('rb/views/ReviewRequestEditorView', function() {
                 });
 
                 it('Enabled by default', function() {
+                    if (RB.EnabledFeatures.unifiedBanner) {
+                        pending();
+                        return;
+                    }
+
                     expect($buttons.prop('disabled')).toBe(false);
                 });
 
                 it('Disabled when saving', function() {
+                    if (RB.EnabledFeatures.unifiedBanner) {
+                        pending();
+                        return;
+                    }
+
                     expect($buttons.prop('disabled')).toBe(false);
                     editor.trigger('saving');
                     expect($buttons.prop('disabled')).toBe(true);
                 });
 
                 it('Enabled when saved', function() {
+                    if (RB.EnabledFeatures.unifiedBanner) {
+                        pending();
+                        return;
+                    }
+
                     expect($buttons.prop('disabled')).toBe(false);
                     editor.trigger('saving');
                     expect($buttons.prop('disabled')).toBe(true);
@@ -1000,15 +1076,22 @@ suite('rb/views/ReviewRequestEditorView', function() {
             describe('Draft review requests', function() {
                 beforeEach(function() {
                     view.model.set('hasDraft', true);
-                    view.showBanner();
+
+                    if (!RB.EnabledFeatures.unifiedBanner) {
+                        view.showBanner();
+                    }
                 });
 
+                const selector = RB.EnabledFeatures.unifiedBanner
+                    ? '#unified-banner #field_change_description'
+                    : '#draft-banner #field_change_description';
+
                 setupFieldTests({
                     supportsRichText: true,
                     fieldID: 'change_description',
                     fieldName: 'changeDescription',
                     jsonFieldName: 'changedescription',
-                    selector: '#draft-banner #field_change_description',
+                    selector: selector,
                 });
 
                 hasEditorTest();
diff --git a/reviewboard/templates/actions/detailed_menuitem_action.html b/reviewboard/templates/actions/detailed_menuitem_action.html
new file mode 100644
index 0000000000000000000000000000000000000000..429d3c937fde056ce6c51c6d4d9507ecdfa2ecc2
--- /dev/null
+++ b/reviewboard/templates/actions/detailed_menuitem_action.html
@@ -0,0 +1,16 @@
+{% spaceless %}
+<a id="{{action.get_dom_element_id}}"
+   href="{{url}}"
+   role="menuitem"
+   {% if not visible or has_parent %} style="display: none;"{% endif %}>
+ <h4>
+  <span class="{{action.icon_class}}"></span>
+  {{label}}
+ </h4>
+{%  for description_part in action.description %}
+ <p>
+  {{description_part}}
+ </p>
+{%  endfor %}
+</a>
+{% endspaceless %}
diff --git a/reviewboard/templates/actions/menu_action.html b/reviewboard/templates/actions/menu_action.html
index 9bcc4c7ba193aea5c4e3dea204c15b5ff3d96442..1249e083825f7f0aaa02525d3bcb4652b344a12d 100644
--- a/reviewboard/templates/actions/menu_action.html
+++ b/reviewboard/templates/actions/menu_action.html
@@ -1,8 +1,16 @@
 {% load actions %}
+{% spaceless %}
 <li class="rb-c-actions__action" id="{{action.get_dom_element_id}}"
     {% if not visible %} style="display: none;"{% endif %}
     role="menuitem">
  <a href="#" role="presentation"
-    aria-label="{{label}}">{{label}} <span class="rb-icon rb-icon-dropdown-arrow"></span></a>
-{% child_actions_html %}
+    aria-label="{{label}}">
+{%  if action.icon_class %}
+  <span class="{{action.icon_class}}"></span>
+{%  endif %}
+  {{label}}
+  <span class="rb-icon rb-icon-dropdown-arrow"></span>
+ </a>
+{%  child_actions_html %}
 </li>
+{% endspaceless %}
diff --git a/reviewboard/templates/base.html b/reviewboard/templates/base.html
index 505bb3d391f742b4e8395824d5ff3585fea343c9..a5f54128cba2150586a5b0c655e4072657e418e8 100644
--- a/reviewboard/templates/base.html
+++ b/reviewboard/templates/base.html
@@ -99,6 +99,7 @@
 {%  include "base/page_sidebar.html" %}
 
    <div id="page-container">
+{% block unified_banner %}{% endblock %}
     <div id="error"></div>
     <div id="content_container">
      <main id="content">
diff --git a/reviewboard/templates/reviews/reviewable_base.html b/reviewboard/templates/reviews/reviewable_base.html
index 80cc7947e8ce90aa29514f1945de9d4fcbead8e8..32ddd9968140b7fbff8da8cca68cf2586e4fe76c 100644
--- a/reviewboard/templates/reviews/reviewable_base.html
+++ b/reviewboard/templates/reviews/reviewable_base.html
@@ -1,5 +1,5 @@
 {% extends "base.html" %}
-{% load i18n pipeline reviewtags %}
+{% load actions i18n features pipeline reviewtags %}
 
 {% block extrahead %}
 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
@@ -50,11 +50,42 @@
 }{% endblock js-page-model-attrs %}
 {% block js-page-model-options %}{parse: true}{% endblock %}
 
+{%  block unified_banner %}
+{%   if_feature_enabled 'reviews.unified_banner' %}
+<div id="unified-banner" class="rb-c-unified-banner">
+ <div class="rb-c-unified-banner__review">
+  <div class="rb-c-unified-banner__controls">
+   <div class="rb-c-unified-banner__mode-selector"></div>
+   <div class="rb-c-unified-banner__draft-actions">
+    <input type="button" id="btn-review-request-discard"
+           value="{% trans "Discard" %}">
+   </div>
+   <menu class="rb-c-unified-banner__review-actions rb-c-actions" role="menu">
+{%    actions_html "unified-banner" %}
+   </menu>
+  </div>
+  <div class="rb-c-unified-banner__changedesc">
+   <p>
+    <label for="field_change_description">
+     {% trans "Describe your changes (optional):" %}
+    </label>
+   </p>
+   <pre id="field_change_description" class="field field-text-area"
+        data-field-id="field_change_description"></pre>
+  </div>
+ </div>
+ <div class="rb-c-unified-banner__dock"></div>
+</div>
+{%  endif_feature_enabled %}
+{{block.super}}
+{%  endblock %}
+
 
 {% block bodytag %}
 {{block.super}}
 
-{%  block review_banner %}
+{%  if_feature_disabled "reviews.unified_banner" %}
+{%   block review_banner %}
 <div id="review-banner"{% if not review %} hidden class="hidden"{% endif %}>
  <div class="banner">
   <h1>{% trans "You have a pending review." %}</h1>
@@ -77,5 +108,6 @@
   <input id="review-banner-discard" type="button" value="{% trans "Discard" %}" />
  </div>
 </div>
-{%  endblock %}
+{%   endblock %}
+{%  endif_feature_disabled %}
 {% endblock %}
