Refactor directory structure of components

There is no change in functionality. Only moving things around.

+ Separate html from the js.
+ Place the unit test for a component within the same folder.
+ Organize the components in subfolders.

Change-Id: I51fdc510db75fc1b33f040ca63decbbdfd4d5513
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
new file mode 100644
index 0000000..1593fab
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -0,0 +1,84 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+
+<link rel="import" href="../../shared/gr-ajax/gr-ajax.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-request/gr-request.html">
+
+<link rel="import" href="../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html">
+
+<dom-module id="gr-change-actions">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      gr-button {
+        display: block;
+        margin-bottom: .5em;
+      }
+      gr-button:before {
+        content: attr(data-label);
+      }
+      gr-button[loading]:before {
+        content: attr(data-loading-label);
+      }
+      @media screen and (max-width: 50em) {
+        .confirmDialog {
+          width: 90vw;
+        }
+      }
+    </style>
+    <gr-ajax id="actionsXHR"
+        url="[[_computeRevisionActionsPath(changeNum, patchNum)]]"
+        last-response="{{_revisionActions}}"
+        loading="{{_loading}}"></gr-ajax>
+    <div>
+      <template is="dom-repeat" items="[[_computeActionValues(actions, 'change')]]" as="action">
+        <gr-button title$="[[action.title]]"
+            primary$="[[_computePrimary(action.__key)]]"
+            hidden$="[[!action.enabled]]"
+            data-action-key$="[[action.__key]]"
+            data-action-type$="[[action.__type]]"
+            data-label$="[[action.label]]"
+            on-tap="_handleActionTap"></gr-button>
+      </template>
+      <template is="dom-repeat" items="[[_computeActionValues(_revisionActions, 'revision')]]" as="action">
+        <gr-button title$="[[action.title]]"
+            primary$="[[_computePrimary(action.__key)]]"
+            disabled$="[[!action.enabled]]"
+            data-action-key$="[[action.__key]]"
+            data-action-type$="[[action.__type]]"
+            data-label$="[[action.label]]"
+            data-loading-label$="[[_computeLoadingLabel(action.__key)]]"
+            on-tap="_handleActionTap"></gr-button>
+      </template>
+    </div>
+    <gr-overlay id="overlay" with-backdrop>
+      <gr-confirm-rebase-dialog id="confirmRebase"
+          class="confirmDialog"
+          on-confirm="_handleRebaseConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          hidden></gr-confirm-rebase-dialog>
+    </gr-overlay>
+  </template>
+  <script src="gr-change-actions.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
new file mode 100644
index 0000000..a89c0aa
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -0,0 +1,225 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  // TODO(davido): Add the rest of the change actions.
+  var ChangeActions = {
+    ABANDON: 'abandon',
+    DELETE: '/',
+    RESTORE: 'restore',
+  };
+
+  // TODO(andybons): Add the rest of the revision actions.
+  var RevisionActions = {
+    DELETE: '/',
+    PUBLISH: 'publish',
+    REBASE: 'rebase',
+    SUBMIT: 'submit',
+  };
+
+  Polymer({
+    is: 'gr-change-actions',
+
+    /**
+     * Fired when the change should be reloaded.
+     *
+     * @event reload-change
+     */
+
+    properties: {
+      actions: {
+        type: Object,
+      },
+      changeNum: String,
+      patchNum: String,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _revisionActions: Object,
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    observers: [
+      '_actionsChanged(actions, _revisionActions)',
+    ],
+
+    reload: function() {
+      if (!this.changeNum || !this.patchNum) {
+        return Promise.resolve();
+      }
+      return this.$.actionsXHR.generateRequest().completes;
+    },
+
+    _actionsChanged: function(actions, revisionActions) {
+      this.hidden =
+          revisionActions.rebase == null &&
+          revisionActions.submit == null &&
+          revisionActions.publish == null &&
+          actions.abandon == null &&
+          actions.restore == null;
+    },
+
+    _computeRevisionActionsPath: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/actions';
+    },
+
+    _getValuesFor: function(obj) {
+      return Object.keys(obj).map(function(key) {
+        return obj[key];
+      });
+    },
+
+    _computeActionValues: function(actions, type) {
+      var result = [];
+      var values = this._getValuesFor(
+          type == 'change' ? ChangeActions : RevisionActions);
+      for (var a in actions) {
+        if (values.indexOf(a) == -1) { continue; }
+        actions[a].__key = a;
+        actions[a].__type = type;
+        result.push(actions[a]);
+      }
+      return result;
+    },
+
+    _computeLoadingLabel: function(action) {
+      return {
+        'rebase': 'Rebasing...',
+        'submit': 'Submitting...',
+      }[action];
+    },
+
+    _computePrimary: function(actionKey) {
+      return actionKey == 'submit';
+    },
+
+    _computeButtonClass: function(action) {
+      if ([RevisionActions.SUBMIT,
+          RevisionActions.PUBLISH].indexOf(action) != -1) {
+        return 'primary';
+      }
+      return '';
+    },
+
+    _handleActionTap: function(e) {
+      e.preventDefault();
+      var el = Polymer.dom(e).rootTarget;
+      var key = el.getAttribute('data-action-key');
+      var type = el.getAttribute('data-action-type');
+      if (type == 'revision') {
+        if (key == RevisionActions.REBASE) {
+          this._showRebaseDialog();
+          return;
+        }
+        this._fireRevisionAction(this._prependSlash(key),
+            this._revisionActions[key]);
+      } else {
+        this._fireChangeAction(this._prependSlash(key), this.actions[key]);
+      }
+    },
+
+    _prependSlash: function(key) {
+      return key == '/' ? key : '/' + key;
+    },
+
+    _handleConfirmDialogCancel: function() {
+      var dialogEls =
+          Polymer.dom(this.root).querySelectorAll('.confirmDialog');
+      for (var i = 0; i < dialogEls.length; i++) {
+        dialogEls[i].hidden = true;
+      }
+      this.$.overlay.close();
+    },
+
+    _handleRebaseConfirm: function() {
+      var payload = {};
+      var el = this.$.confirmRebase;
+      if (el.clearParent) {
+        // There is a subtle but important difference between setting the base
+        // to an empty string and omitting it entirely from the payload. An
+        // empty string implies that the parent should be cleared and the
+        // change should be rebased on top of the target branch. Leaving out
+        // the base implies that it should be rebased on top of its current
+        // parent.
+        payload.base = '';
+      } else if (el.base && el.base.length > 0) {
+        payload.base = el.base;
+      }
+      this.$.overlay.close();
+      el.hidden = false;
+      this._fireRevisionAction('/rebase', this._revisionActions.rebase,
+          payload);
+    },
+
+    _fireChangeAction: function(endpoint, action) {
+      this._send(action.method, {}, endpoint).then(
+        function() {
+          // We can’t reload a change that was deleted.
+          if (endpoint == ChangeActions.DELETE) {
+            page.show('/');
+          } else {
+            this.fire('reload-change', null, {bubbles: false});
+          }
+        }.bind(this)).catch(function(err) {
+          alert('Oops. Something went wrong. Check the console and bug the ' +
+              'PolyGerrit team for assistance.');
+          throw err;
+        });
+    },
+
+    _fireRevisionAction: function(endpoint, action, opt_payload) {
+      var buttonEl = this.$$('[data-action-key="' + action.__key + '"]');
+      buttonEl.setAttribute('loading', true);
+      buttonEl.disabled = true;
+      function enableButton() {
+        buttonEl.removeAttribute('loading');
+        buttonEl.disabled = false;
+      }
+
+      this._send(action.method, opt_payload, endpoint, true).then(
+        function() {
+          this.fire('reload-change', null, {bubbles: false});
+          enableButton();
+        }.bind(this)).catch(function(err) {
+          // TODO(andybons): Handle merge conflict (409 status);
+          alert('Oops. Something went wrong. Check the console and bug the ' +
+              'PolyGerrit team for assistance.');
+          enableButton();
+          throw err;
+        });
+    },
+
+    _showRebaseDialog: function() {
+      this.$.confirmRebase.hidden = false;
+      this.$.overlay.open();
+    },
+
+    _send: function(method, payload, actionEndpoint, revisionAction) {
+      var xhr = document.createElement('gr-request');
+      this._xhrPromise = xhr.send({
+        method: method,
+        url: this.changeBaseURL(this.changeNum,
+            revisionAction ? this.patchNum : null) + actionEndpoint,
+        body: payload,
+      });
+
+      return this._xhrPromise;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
new file mode 100644
index 0000000..a89a7a5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -0,0 +1,155 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-actions</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-actions.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-actions></gr-change-actions>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-actions tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      server = sinon.fakeServer.create();
+
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/2/actions',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({
+            cherrypick: {
+              method: 'POST',
+              label: 'Cherry Pick',
+              title: 'Cherry pick change to a different branch',
+              enabled: true
+            },
+            rebase: {
+              method: 'POST',
+              label: 'Rebase',
+              title: 'Rebase onto tip of branch or parent change'
+            },
+            submit: {
+              method: 'POST',
+              label: 'Submit',
+              title: 'Submit patch set 1 into master',
+              enabled: true
+            }
+          }),
+        ]
+      );
+
+      server.respondWith(
+        'POST',
+        '/changes/42/revisions/2/submit',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n{}',  // The response is not used by the element.
+        ]
+      );
+
+      server.respondWith(
+        'POST',
+        '/changes/42/revisions/2/rebase',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n{}',  // The response is not used by the element.
+        ]
+      );
+
+      element.changeNum = '42';
+      element.patchNum = '2';
+      element.reload();
+
+      server.respond();
+    });
+
+    test('submit and rebase buttons show', function(done) {
+      element.async(function() {
+        var buttonEls = Polymer.dom(element.root).querySelectorAll('gr-button');
+        assert.equal(buttonEls.length, 2);
+        assert.isFalse(element.hidden);
+        done();
+      }, 1);
+    });
+
+    test('submit change', function(done) {
+      element.async(function() {
+        var submitButton = element.$$('gr-button[data-action-key="submit"]');
+        assert.ok(submitButton);
+        MockInteractions.tap(submitButton);
+        server.respond();
+
+        // Upon success it should fire the reload-change event.
+        element.addEventListener('reload-change', function(e) {
+          done();
+        });
+      }, 1);
+    });
+
+    test('rebase change', function(done) {
+      element.async(function() {
+        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+        MockInteractions.tap(rebaseButton);
+
+        element.$.confirmRebase.base = '1234';
+        element._handleRebaseConfirm();
+        server.respond();
+        var lastRequest = server.requests[server.requests.length - 1];
+        assert.equal(lastRequest.requestBody, '{"base":"1234"}');
+
+        element.$.confirmRebase.base = '';
+        element._handleRebaseConfirm();
+        server.respond();
+        lastRequest = server.requests[server.requests.length - 1];
+        assert.equal(lastRequest.requestBody, '{}');
+
+        element.$.confirmRebase.base = 'does not matter';
+        element.$.confirmRebase.clearParent = true;
+        element._handleRebaseConfirm();
+        server.respond();
+        lastRequest = server.requests[server.requests.length - 1];
+        assert.equal(lastRequest.requestBody, '{"base":""}');
+
+        // Upon each request success it should fire the reload-change event.
+        var numEvents = 0;
+        element.addEventListener('reload-change', function(e) {
+          if (++numEvents == 3) { done(); }
+        });
+      }, 1);
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
new file mode 100644
index 0000000..7b1a2f1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -0,0 +1,127 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
+
+<script src="../../../scripts/fake-app.js"></script>
+
+<dom-module id="gr-change-metadata">
+  <template>
+    <style>
+      section:not(:first-of-type) {
+        margin-top: 1em;
+      }
+      .title,
+      .value {
+        display: block;
+      }
+      .title {
+        color: #666;
+        font-weight: bold;
+      }
+      .labelValueContainer:not(:first-of-type) {
+        margin-top: .25em;
+      }
+      .labelValueContainer .approved,
+      .labelValueContainer .notApproved {
+        display: inline-block;
+        padding: .1em .3em;
+        border-radius: 3px;
+      }
+      .labelValue {
+        display: inline-block;
+      }
+      .approved {
+        background-color: #d4ffd4;
+      }
+      .notApproved {
+        background-color: #ffd4d4;
+      }
+      @media screen and (max-width: 50em), screen and (min-width: 75em) {
+        section {
+          display: flex;
+        }
+        section:not(:first-of-type) {
+          margin-top: .25em;
+        }
+        .title {
+          min-width: 9em;
+        }
+      }
+    </style>
+    <section>
+      <span class="title">Updated</span>
+      <span class="value">
+        <gr-date-formatter
+            date-str="[[change.updated]]"></gr-date-formatter>
+      </span>
+    </section>
+    <section>
+      <span class="title">Owner</span>
+      <span class="value">
+        <gr-account-link account="[[change.owner]]"></gr-account-link>
+      </span>
+    </section>
+    <section>
+      <span class="title">Reviewers</span>
+      <span class="value">
+        <gr-reviewer-list
+            change="[[change]]"
+            mutable="[[mutable]]"
+            suggest-from="[[serverConfig.suggest.from]]"></gr-reviewer-list>
+      </span>
+    </section>
+    <section>
+      <span class="title">Project</span>
+      <span class="value">[[change.project]]</span>
+    </section>
+    <section>
+      <span class="title">Branch</span>
+      <span class="value">[[change.branch]]</span>
+    </section>
+    <section>
+      <span class="title">Topic</span>
+      <span class="value">[[change.topic]]</span>
+    </section>
+    <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
+      <span class="title">Strategy</span>
+      <span class="value">[[_computeStrategy(change)]]</span>
+    </section>
+    <template is="dom-repeat"
+        items="[[_computeLabelNames(change.labels)]]" as="labelName">
+      <section>
+        <span class="title">[[labelName]]</span>
+        <span class="value">
+          <template is="dom-repeat"
+              items="[[_computeLabelValues(labelName, change.labels)]]"
+              as="label">
+            <div class="labelValueContainer">
+              <span class$="[[label.className]]">
+                <span class="labelValue">[[label.value]]</span>
+                <gr-account-link account="[[label.account]]"></gr-account-link>
+              </span>
+            </div>
+          </template>
+        </span>
+      </section>
+    </template>
+  </template>
+  <script src="gr-change-metadata.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
new file mode 100644
index 0000000..3d2633b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -0,0 +1,76 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var SubmitTypeLabel = {
+    FAST_FORWARD_ONLY: 'Fast Forward Only',
+    MERGE_IF_NECESSARY: 'Merge if Necessary',
+    REBASE_IF_NECESSARY: 'Rebase if Necessary',
+    MERGE_ALWAYS: 'Always Merge',
+    CHERRY_PICK: 'Cherry Pick',
+  };
+
+  Polymer({
+    is: 'gr-change-metadata',
+
+    properties: {
+      change: Object,
+      mutable: Boolean,
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    _computeHideStrategy: function(change) {
+      var open = change.status == this.ChangeStatus.NEW ||
+          change.status == this.ChangeStatus.DRAFT;
+      return !open;
+    },
+
+    _computeStrategy: function(change) {
+      return SubmitTypeLabel[change.submit_type];
+    },
+
+    _computeLabelNames: function(labels) {
+      return Object.keys(labels).sort();
+    },
+
+    _computeLabelValues: function(labelName, labels) {
+      var result = [];
+      var t = labels[labelName];
+      if (!t) { return result; }
+      var approvals = t.all || [];
+      approvals.forEach(function(label) {
+        if (label.value && label.value != labels[labelName].default_value) {
+          var labelClassName;
+          var labelValPrefix = '';
+          if (label.value > 0) {
+            labelValPrefix = '+';
+            labelClassName = 'approved';
+          } else if (label.value < 0) {
+            labelClassName = 'notApproved';
+          }
+          result.push({
+            value: labelValPrefix + label.value,
+            className: labelClassName,
+            account: label,
+          });
+        }
+      });
+      return result;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
new file mode 100644
index 0000000..6c97b5a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -0,0 +1,68 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-metadata</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-metadata.html">
+<script src="../../../scripts/util.js"></script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-metadata></gr-change-metadata>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-metadata tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('computed fields', function() {
+      assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
+      assert.isFalse(element._computeHideStrategy({status: 'DRAFT'}));
+      assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
+      assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
+      assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
+          'Cherry Pick');
+    });
+
+    test('show strategy for open change', function() {
+      element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
+      flushAsynchronousOperations();
+      var strategy = element.$$('.strategy');
+      assert.ok(strategy);
+      assert.isFalse(strategy.hasAttribute('hidden'));
+      assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
+    });
+
+    test('hide strategy for closed change', function() {
+      element.change = {status: 'MERGED', labels: {}};
+      flushAsynchronousOperations();
+      assert.isTrue(element.$$('.strategy').hasAttribute('hidden'));
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
new file mode 100644
index 0000000..41cb058
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -0,0 +1,319 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-ajax/gr-ajax.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+
+<link rel="import" href="../gr-change-actions/gr-change-actions.html">
+<link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
+<link rel="import" href="../gr-download-dialog/gr-download-dialog.html">
+<link rel="import" href="../gr-file-list/gr-file-list.html">
+<link rel="import" href="../gr-messages-list/gr-messages-list.html">
+<link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html">
+<link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html">
+
+<dom-module id="gr-change-view">
+  <template>
+    <style>
+      .container {
+        margin: 1em var(--default-horizontal-margin);
+      }
+      .container:not(.loading) {
+        background-color: var(--view-background-color);
+      }
+      .container.loading {
+        color: #666;
+      }
+      .headerContainer {
+        height: 4.1em;
+        margin-bottom: .5em;
+      }
+      .header {
+        align-items: center;
+        background-color: var(--view-background-color);
+        border-bottom: 1px solid #ddd;
+        display: flex;
+        padding: 1em var(--default-horizontal-margin);
+        z-index: 99;  /* Less than gr-overlay's backdrop */
+      }
+      .header.pinned {
+        border-bottom-color: transparent;
+        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+        position: fixed;
+        top: 0;
+        transition: box-shadow 250ms linear;
+        width: calc(100% - (2 * var(--default-horizontal-margin)));
+      }
+      .header-title {
+        flex: 1;
+        font-size: 1.2em;
+        font-weight: bold;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      gr-change-star {
+        margin-right: .25em;
+        vertical-align: -.425em;
+      }
+      .download,
+      .patchSelectLabel {
+        margin-left: var(--default-horizontal-margin);
+      }
+      .header select {
+        margin-left: .5em;
+      }
+      .header .reply {
+        margin-left: var(--default-horizontal-margin);
+      }
+      gr-reply-dialog {
+        min-width: 30em;
+        max-width: 50em;
+      }
+      .changeStatus {
+        color: #999;
+        text-transform: capitalize;
+      }
+      section {
+        margin: 10px 0;
+        padding: 10px var(--default-horizontal-margin);
+      }
+      /* Strong specificity here is needed due to
+         https://github.com/Polymer/polymer/issues/2531 */
+      .container section.changeInfo {
+        border-bottom: 1px solid #ddd;
+        display: flex;
+        margin-top: 0;
+        padding-top: 0;
+      }
+      .changeInfo-column:not(:last-of-type) {
+        margin-right: 1em;
+        padding-right: 1em;
+      }
+      .changeMetadata {
+        border-right: 1px solid #ddd;
+        font-size: .9em;
+      }
+      gr-change-actions {
+        margin-top: 1em;
+      }
+      .commitMessage {
+        font-family: var(--monospace-font-family);
+        flex: 0 0 72ch;
+        margin-right: 2em;
+        margin-bottom: 1em;
+      }
+      .commitMessage h4 {
+        font-family: var(--font-family);
+        font-weight: bold;
+        margin-bottom: .25em;
+      }
+      .commitAndRelated {
+        align-content: flex-start;
+        display: flex;
+        flex: 1;
+        flex-wrap: wrap;
+      }
+      gr-file-list {
+        margin-bottom: 1em;
+        padding: 0 var(--default-horizontal-margin);
+      }
+      @media screen and (max-width: 50em) {
+        .container {
+          margin: .5em 0 !important;
+        }
+        .container.loading {
+          margin: 1em var(--default-horizontal-margin) !important;
+        }
+        .headerContainer {
+          height: 5.15em;
+        }
+        .header {
+          align-items: flex-start;
+          flex-direction: column;
+          padding: .5em var(--default-horizontal-margin) !important;
+        }
+        gr-change-star {
+          vertical-align: middle;
+        }
+        .header-title,
+        .header-actions,
+        .header.pinned {
+          width: 100% !important;
+        }
+        .header-title {
+          font-size: 1.1em;
+        }
+        .header-actions {
+          align-items: center;
+          display: flex;
+          justify-content: space-between;
+          margin-top: .5em;
+        }
+        gr-reply-dialog {
+          min-width: initial;
+          width: 90vw;
+        }
+        .download {
+          display: none;
+        }
+        .patchSelectLabel {
+          margin-left: 0 !important;
+          margin-right: .5em;
+        }
+        .header select {
+          margin-left: 0 !important;
+          margin-right: .5em;
+        }
+        .header .reply {
+          margin-left: 0 !important;
+          margin-right: .5em;
+        }
+        .changeInfo-column:not(:last-of-type) {
+          margin-right: 0;
+          padding-right: 0;
+        }
+        .changeInfo,
+        .commitAndRelated {
+          flex-direction: column;
+          flex-wrap: nowrap;
+        }
+        .changeMetadata {
+          font-size: 1em;
+          border-right: none;
+          margin-bottom: 1em;
+          margin-top: .25em;
+          max-width: none;
+        }
+        .commitMessage {
+          flex: initial;
+          margin-right: 0;
+        }
+      }
+    </style>
+    <gr-ajax id="detailXHR"
+        url="[[_computeDetailPath(_changeNum)]]"
+        params="[[_computeDetailQueryParams()]]"
+        last-response="{{_change}}"
+        loading="{{_loading}}"></gr-ajax>
+    <gr-ajax id="commentsXHR"
+        url="[[_computeCommentsPath(_changeNum)]]"
+        last-response="{{_comments}}"></gr-ajax>
+    <gr-ajax id="commitInfoXHR"
+        url="[[_computeCommitInfoPath(_changeNum, _patchNum)]]"
+        last-response="{{_commitInfo}}"></gr-ajax>
+    <!-- TODO(andybons): Cache the project config. -->
+    <gr-ajax id="configXHR"
+        auto
+        url="[[_computeProjectConfigPath(_change.project)]]"
+        last-response="{{_projectConfig}}"></gr-ajax>
+    <div class="container loading" hidden$="{{!_loading}}">Loading...</div>
+    <div class="container" hidden$="{{_loading}}">
+      <div class="headerContainer">
+        <div class="header">
+          <span class="header-title">
+            <gr-change-star change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
+            <a href$="[[_computeChangePermalink(_change._number)]]">[[_change._number]]</a><span>:</span>
+            <span>[[_change.subject]]</span>
+            <span class="changeStatus">[[_computeChangeStatus(_change, _patchNum)]]</span>
+          </span>
+          <span class="header-actions">
+            <gr-button class="reply" hidden$="[[!_loggedIn]]" hidden on-tap="_handleReplyTap">Reply</gr-button>
+            <gr-button link class="download" on-tap="_handleDownloadTap">Download</gr-button>
+            <span>
+              <label class="patchSelectLabel" for="patchSetSelect">Patch set</label>
+              <select id="patchSetSelect" on-change="_handlePatchChange">
+                <template is="dom-repeat" items="{{_allPatchSets}}" as="patchNumber">
+                  <option value$="[[patchNumber]]" selected$="[[_computePatchIndexIsSelected(index, _patchNum)]]">
+                    <span>[[patchNumber]]</span>
+                    /
+                    <span>[[_computeLatestPatchNum(_change)]]</span>
+                  </option>
+                </template>
+              </select>
+            </span>
+          </span>
+        </div>
+      </div>
+      <section class="changeInfo">
+        <div class="changeInfo-column changeMetadata">
+          <gr-change-metadata
+              change="[[_change]]"
+              mutable="[[_loggedIn]]"></gr-change-metadata>
+          <gr-change-actions id="actions"
+              actions="[[_change.actions]]"
+              change-num="[[_changeNum]]"
+              patch-num="[[_patchNum]]"
+              on-reload-change="_handleReloadChange"></gr-change-actions>
+        </div>
+        <div class="changeInfo-column commitAndRelated">
+          <div class="commitMessage">
+            <h4>Commit message</h4>
+            <gr-linked-text pre
+                content="[[_commitInfo.message]]"
+                config="[[_projectConfig.commentlinks]]"></gr-linked-text>
+          </div>
+          <div class="relatedChanges">
+            <gr-related-changes-list id="relatedChanges"
+              change="[[_change]]"
+              server-config="[[serverConfig]]"
+              patch-num="[[_patchNum]]"></gr-related-changes-list>
+          </div>
+        </div>
+      </section>
+      <gr-file-list id="fileList"
+          change-num="[[_changeNum]]"
+          patch-num="[[_patchNum]]"
+          comments="[[_comments]]"
+          selected-index="{{viewState.selectedFileIndex}}"></gr-file-list>
+      <gr-messages-list id="messageList"
+          change-num="[[_changeNum]]"
+          messages="[[_change.messages]]"
+          comments="[[_comments]]"
+          project-config="[[_projectConfig]]"
+          show-reply-buttons="[[_loggedIn]]"
+          on-reply="_handleMessageReply"></gr-messages-list>
+    </div>
+    <gr-overlay id="downloadOverlay" with-backdrop>
+      <gr-download-dialog
+          change="[[_change]]"
+          patch-num="[[_patchNum]]"
+          config="[[serverConfig.download]]"
+          on-close="_handleDownloadDialogClose"></gr-download-dialog>
+    </gr-overlay>
+    <gr-overlay id="replyOverlay"
+        on-iron-overlay-opened="_handleReplyOverlayOpen"
+        with-backdrop>
+      <gr-reply-dialog id="replyDialog"
+          change-num="[[_changeNum]]"
+          patch-num="[[_patchNum]]"
+          labels="[[_change.labels]]"
+          permitted-labels="[[_change.permitted_labels]]"
+          on-send="_handleReplySent"
+          on-cancel="_handleReplyCancel"
+          hidden$="[[!_loggedIn]]">Reply</gr-reply-dialog>
+    </gr-overlay>
+  </template>
+  <script src="gr-change-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
new file mode 100644
index 0000000..a42a379
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -0,0 +1,354 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-change-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+      viewState: {
+        type: Object,
+        notify: true,
+        value: function() { return {}; },
+      },
+      serverConfig: Object,
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+
+      _comments: Object,
+      _change: {
+        type: Object,
+        observer: '_changeChanged',
+      },
+      _commitInfo: Object,
+      _changeNum: String,
+      _patchNum: String,
+      _allPatchSets: {
+        type: Array,
+        computed: '_computeAllPatchSets(_change)',
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: Boolean,
+      _headerContainerEl: Object,
+      _headerEl: Object,
+      _projectConfig: Object,
+      _boundScrollHandler: {
+        type: Function,
+        value: function() { return this._handleBodyScroll.bind(this); },
+      },
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.RESTClientBehavior,
+    ],
+
+    ready: function() {
+      app.accountReady.then(function() {
+        this._loggedIn = app.loggedIn;
+      }.bind(this));
+      this._headerEl = this.$$('.header');
+    },
+
+    attached: function() {
+      window.addEventListener('scroll', this._boundScrollHandler);
+    },
+
+    detached: function() {
+      window.removeEventListener('scroll', this._boundScrollHandler);
+    },
+
+    _handleBodyScroll: function(e) {
+      var containerEl = this._headerContainerEl ||
+          this.$$('.headerContainer');
+
+      // Calculate where the header is relative to the window.
+      var top = containerEl.offsetTop;
+      for (var offsetParent = containerEl.offsetParent;
+           offsetParent;
+           offsetParent = offsetParent.offsetParent) {
+        top += offsetParent.offsetTop;
+      }
+      // The element may not be displayed yet, in which case do nothing.
+      if (top == 0) { return; }
+
+      this._headerEl.classList.toggle('pinned', window.scrollY >= top);
+    },
+
+    _resetHeaderEl: function() {
+      var el = this._headerEl || this.$$('.header');
+      this._headerEl = el;
+      el.classList.remove('pinned');
+    },
+
+    _handlePatchChange: function(e) {
+      var patchNum = e.target.value;
+      var currentPatchNum =
+          this._change.revisions[this._change.current_revision]._number;
+      if (patchNum == currentPatchNum) {
+        page.show(this._computeChangePath(this._changeNum));
+        return;
+      }
+      page.show(this._computeChangePath(this._changeNum) + '/' + patchNum);
+    },
+
+    _handleReplyTap: function(e) {
+      e.preventDefault();
+      this.$.replyOverlay.open();
+    },
+
+    _handleDownloadTap: function(e) {
+      e.preventDefault();
+      this.$.downloadOverlay.open();
+    },
+
+    _handleDownloadDialogClose: function(e) {
+      this.$.downloadOverlay.close();
+    },
+
+    _handleMessageReply: function(e) {
+      var msg = e.detail.message.message;
+      var quoteStr = msg.split('\n').map(
+          function(line) { return '> ' + line; }).join('\n') + '\n\n';
+      this.$.replyDialog.draft += quoteStr;
+      this.$.replyOverlay.open();
+    },
+
+    _handleReplyOverlayOpen: function(e) {
+      this.$.replyDialog.reload().then(function() {
+        this.async(function() { this.$.replyOverlay.center() }, 1);
+      }.bind(this));
+      this.$.replyDialog.focus();
+    },
+
+    _handleReplySent: function(e) {
+      this.$.replyOverlay.close();
+      this._reload();
+    },
+
+    _handleReplyCancel: function(e) {
+      this.$.replyOverlay.close();
+    },
+
+    _paramsChanged: function(value) {
+      if (value.view != this.tagName.toLowerCase()) { return; }
+
+      this._changeNum = value.changeNum;
+      this._patchNum = value.patchNum;
+      if (this.viewState.changeNum != this._changeNum ||
+          this.viewState.patchNum != this._patchNum) {
+        this.set('viewState.selectedFileIndex', 0);
+        this.set('viewState.changeNum', this._changeNum);
+        this.set('viewState.patchNum', this._patchNum);
+      }
+      if (!this._changeNum) {
+        return;
+      }
+      this._reload().then(function() {
+        this.$.messageList.topMargin = this._headerEl.offsetHeight;
+
+        // Allow the message list to render before scrolling.
+        this.async(function() {
+          var msgPrefix = '#message-';
+          var hash = window.location.hash;
+          if (hash.indexOf(msgPrefix) == 0) {
+            this.$.messageList.scrollToMessage(hash.substr(msgPrefix.length));
+          }
+        }.bind(this), 1);
+
+        app.accountReady.then(function() {
+          if (!this._loggedIn) { return; }
+
+          if (this.viewState.showReplyDialog) {
+            this.$.replyOverlay.open();
+            this.set('viewState.showReplyDialog', false);
+          }
+        }.bind(this));
+      }.bind(this));
+    },
+
+    _changeChanged: function(change) {
+      if (!change) { return; }
+      this._patchNum = this._patchNum ||
+          change.revisions[change.current_revision]._number;
+
+      var title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+      this.fire('title-change', {title: title});
+    },
+
+    _computeChangePath: function(changeNum) {
+      return '/c/' + changeNum;
+    },
+
+    _computeChangePermalink: function(changeNum) {
+      return '/' + changeNum;
+    },
+
+    _computeChangeStatus: function(change, patchNum) {
+      var status = change.status;
+      if (status == this.ChangeStatus.NEW) {
+        var rev = this._getRevisionNumber(change, patchNum);
+        // TODO(davido): Figure out, why sometimes revision is not there
+        if (rev == undefined || !rev.draft) { return ''; }
+        status = this.ChangeStatus.DRAFT;
+      }
+      return '(' + status.toLowerCase() + ')';
+    },
+
+    _computeDetailPath: function(changeNum) {
+      return '/changes/' + changeNum + '/detail';
+    },
+
+    _computeCommitInfoPath: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/commit?links';
+    },
+
+    _computeCommentsPath: function(changeNum) {
+      return '/changes/' + changeNum + '/comments';
+    },
+
+    _computeProjectConfigPath: function(project) {
+      return '/projects/' + encodeURIComponent(project) + '/config';
+    },
+
+    _computeDetailQueryParams: function() {
+      var options = this.listChangesOptionsToHex(
+          this.ListChangesOption.ALL_REVISIONS,
+          this.ListChangesOption.CHANGE_ACTIONS,
+          this.ListChangesOption.DOWNLOAD_COMMANDS
+      );
+      return {O: options};
+    },
+
+    _computeLatestPatchNum: function(change) {
+      return change.revisions[change.current_revision]._number;
+    },
+
+    _computeAllPatchSets: function(change) {
+      var patchNums = [];
+      for (var rev in change.revisions) {
+        patchNums.push(change.revisions[rev]._number);
+      }
+      return patchNums.sort(function(a, b) {
+        return a - b;
+      });
+    },
+
+    _getRevisionNumber: function(change, patchNum) {
+      for (var rev in change.revisions) {
+        if (change.revisions[rev]._number == patchNum) {
+          return change.revisions[rev];
+        }
+      }
+    },
+
+    _computePatchIndexIsSelected: function(index, patchNum) {
+      return this._allPatchSets[index] == patchNum;
+    },
+
+    _computeLabelNames: function(labels) {
+      return Object.keys(labels).sort();
+    },
+
+    _computeLabelValues: function(labelName, labels) {
+      var result = [];
+      var t = labels[labelName];
+      if (!t) { return result; }
+      var approvals = t.all || [];
+      approvals.forEach(function(label) {
+        if (label.value && label.value != labels[labelName].default_value) {
+          var labelClassName;
+          var labelValPrefix = '';
+          if (label.value > 0) {
+            labelValPrefix = '+';
+            labelClassName = 'approved';
+          } else if (label.value < 0) {
+            labelClassName = 'notApproved';
+          }
+          result.push({
+            value: labelValPrefix + label.value,
+            className: labelClassName,
+            account: label,
+          });
+        }
+      });
+      return result;
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+
+      switch (e.keyCode) {
+        case 65:  // 'a'
+          e.preventDefault();
+          this.$.replyOverlay.open();
+          break;
+        case 85:  // 'u'
+          e.preventDefault();
+          page.show('/');
+          break;
+      }
+    },
+
+    _handleReloadChange: function() {
+      page.show(this._computeChangePath(this._changeNum));
+    },
+
+    _reload: function() {
+      var detailCompletes = this.$.detailXHR.generateRequest().completes;
+      this.$.commentsXHR.generateRequest();
+      var reloadPatchNumDependentResources = function() {
+        return Promise.all([
+          this.$.commitInfoXHR.generateRequest().completes,
+          this.$.actions.reload(),
+          this.$.fileList.reload(),
+        ]);
+      }.bind(this);
+      var reloadDetailDependentResources = function() {
+        return this.$.relatedChanges.reload();
+      }.bind(this);
+
+      this._resetHeaderEl();
+
+      if (this._patchNum) {
+        return reloadPatchNumDependentResources().then(function() {
+          return detailCompletes;
+        }).then(reloadDetailDependentResources);
+      } else {
+        // The patch number is reliant on the change detail request.
+        return detailCompletes.then(reloadPatchNumDependentResources).then(
+            reloadDetailDependentResources);
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
new file mode 100644
index 0000000..ed9d28d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -0,0 +1,179 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../scripts/fake-app.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-view.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-view></gr-change-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-view tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      element.$.configXHR.auto = false;
+
+      server = sinon.fakeServer.create();
+      // Eat any requests made by elements in this suite.
+      server.respondWith(
+        'GET',
+        /\/changes\/(.*)/,
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n{}',
+        ]
+      );
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    test('keyboard shortcuts', function() {
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/'),
+          'Should navigate to /');
+      showStub.restore();
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      var overlayEl = element.$.replyOverlay;
+      assert.isTrue(overlayEl.opened);
+      overlayEl.close();
+      assert.isFalse(overlayEl.opened);
+    });
+
+    test('patch num change', function(done) {
+      element._changeNum = '42';
+      element._patchNum = 2;
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2},
+          rev1: {_number: 1},
+          rev13: {_number: 13},
+          rev3: {_number: 3},
+        },
+        current_revision: 'rev3',
+        status: 'NEW',
+        labels: {},
+      };
+      flushAsynchronousOperations();
+      var selectEl = element.$$('.header select');
+      assert.ok(selectEl);
+      var optionEls =
+          Polymer.dom(element.root).querySelectorAll('.header option');
+      assert.equal(optionEls.length, 4);
+      assert.isFalse(
+          element.$$('.header option[value="1"]').hasAttribute('selected'));
+      assert.isTrue(
+          element.$$('.header option[value="2"]').hasAttribute('selected'));
+      assert.isFalse(
+          element.$$('.header option[value="3"]').hasAttribute('selected'));
+      assert.equal(optionEls[3].value, 13);
+
+      var showStub = sinon.stub(page, 'show');
+
+      var numEvents = 0;
+      selectEl.addEventListener('change', function(e) {
+        numEvents++;
+        if (numEvents == 1) {
+          assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+              'Should navigate to /c/42/1');
+          selectEl.value = '3';
+          element.fire('change', {}, {node: selectEl});
+        } else if (numEvents == 2) {
+          assert(showStub.lastCall.calledWithExactly('/c/42'),
+              'Should navigate to /c/42');
+          showStub.restore();
+          done();
+        }
+      });
+      selectEl.value = '1';
+      element.fire('change', {}, {node: selectEl});
+    });
+
+    test('change status new', function() {
+      element._changeNum = '1';
+      element._patchNum = 1;
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+      };
+      var status = element._computeChangeStatus(element._change, '1');
+      assert.equal(status, '');
+    });
+
+    test('change status draft', function() {
+      element._changeNum = '1';
+      element._patchNum = 1;
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'DRAFT',
+        labels: {},
+      };
+      var status = element._computeChangeStatus(element._change, '1');
+      assert.equal(status, '(draft)');
+    });
+
+    test('revision status draft', function() {
+      element._changeNum = '1';
+      element._patchNum = 2;
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {
+            _number: 2,
+            draft: true,
+          },
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+      };
+      var status = element._computeChangeStatus(element._change, '2');
+      assert.equal(status, '(draft)');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
new file mode 100644
index 0000000..263fb28
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -0,0 +1,68 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-comment-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      .file {
+        border-top: 1px solid #ddd;
+        font-weight: bold;
+        margin: 10px 0 3px;
+        padding: 10px 0 5px;
+      }
+      .container {
+        display: flex;
+        margin: 5px 0;
+      }
+      .lineNum {
+        margin-right: .35em;
+        min-width: 7em;
+      }
+      .message {
+        flex: 1;
+        white-space: pre-wrap;
+        word-wrap: break-word;
+      }
+    </style>
+    <template is="dom-repeat" items="{{_files}}" as="file">
+      <div class="file">
+        <a href$="[[_computeFileDiffURL(file, changeNum, patchNum)]]">[[file]]</a>:
+      </div>
+      <template is="dom-repeat"
+                items="[[_computeCommentsForFile(file)]]" as="comment">
+        <div class="container">
+          <a class="lineNum"
+             href$="[[_computeDiffLineURL(file, changeNum, comment.patch_set, comment)]]">
+             <span hidden$="[[!comment.line]]">
+               <span>[[_computePatchDisplayName(comment)]]</span>
+               Line <span>[[comment.line]]</span>:
+             </span>
+             <span hidden$="[[comment.line]]">
+               File comment:
+             </span>
+          </a>
+          <div class="message">[[comment.message]]</div>
+        </div>
+      </template>
+    </template>
+  </template>
+  <script src="gr-comment-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
new file mode 100644
index 0000000..b40c18e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-comment-list',
+
+    properties: {
+      changeNum: Number,
+      comments: {
+        type: Object,
+        observer: '_commentsChanged',
+      },
+      patchNum: Number,
+
+      _files: Array,
+    },
+
+    _commentsChanged: function(value) {
+      this._files = Object.keys(value || {}).sort();
+    },
+
+    _computeFileDiffURL: function(file, changeNum, patchNum) {
+      return '/c/' + changeNum + '/' + patchNum + '/' + file;
+    },
+
+    _computeDiffLineURL: function(file, changeNum, patchNum, comment) {
+      var diffURL = this._computeFileDiffURL(file, changeNum, patchNum);
+      if (comment.line) {
+        // TODO(andybons): This is not correct if the comment is on the base.
+        diffURL += '#' + comment.line;
+      }
+      return diffURL;
+    },
+
+    _computeCommentsForFile: function(file) {
+      return this.comments[file];
+    },
+
+    _computePatchDisplayName: function(comment) {
+      if (comment.side == 'PARENT') {
+        return 'Base, ';
+      }
+      if (comment.patch_set != this.patchNum) {
+        return 'PS' + comment.patch_set + ', ';
+      }
+      return '';
+    }
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
new file mode 100644
index 0000000..3896ffa
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -0,0 +1,75 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+
+<dom-module id="gr-confirm-rebase-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+        width: 30em;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+      }
+      .parentRevisionContainer label,
+      .parentRevisionContainer input[type="text"] {
+        display: block;
+        font: inherit;
+        width: 100%;
+      }
+      .parentRevisionContainer label {
+        margin-bottom: .2em;
+      }
+      .clearParentContainer {
+        margin: .5em 0;
+      }
+    </style>
+    <gr-confirm-dialog
+        confirm-label="Rebase"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header">Confirm rebase</div>
+      <div class="main">
+        <div class="parentRevisionContainer">
+          <label for="parentInput">
+            Parent revision (optional)
+          </label>
+          <input is="iron-input"
+              type="text"
+              id="parentInput"
+              bind-value="{{base}}"
+              placeholder="Change number">
+        </div>
+        <div class="clearParentContainer">
+          <input id="clearParent"
+              type="checkbox"
+              on-tap="_handleClearParentTap">
+          <label for="clearParent">
+            Rebase on top of current branch (clear parent revision).
+          </label>
+        </div>
+      </div>
+    </gr-confirm-dialog>
+  </template>
+  <script src="gr-confirm-rebase-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
new file mode 100644
index 0000000..42f2167
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -0,0 +1,56 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-rebase-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      base: String,
+      clearParent: Boolean,
+    },
+
+    _handleConfirmTap: function(e) {
+      e.preventDefault();
+      this.fire('confirm', null, {bubbles: false});
+    },
+
+    _handleCancelTap: function(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+
+    _handleClearParentTap: function(e) {
+      var clear = Polymer.dom(e).rootTarget.checked;
+      if (clear) {
+        this.base = '';
+      }
+      this.$.parentInput.disabled = clear;
+      this.clearParent = clear;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
new file mode 100644
index 0000000..c02e11e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-rebase-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-confirm-rebase-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-rebase-dialog></gr-confirm-rebase-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-rebase-dialog tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('controls', function() {
+      assert.isFalse(element.$.parentInput.hasAttribute('disabled'));
+      assert.isFalse(element.$.clearParent.checked);
+      element.base = 'something great';
+      MockInteractions.tap(element.$.clearParent);
+      assert.isTrue(element.$.parentInput.hasAttribute('disabled'));
+      assert.isTrue(element.$.clearParent.checked);
+      assert.equal(element.base, '');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
new file mode 100644
index 0000000..77a262d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -0,0 +1,144 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+<dom-module id="gr-download-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+        padding: 1em;
+      }
+      ul {
+        list-style: none;
+        margin-bottom: .5em;
+      }
+      li {
+        display: inline-block;
+        margin: 0;
+        padding: 0;
+      }
+      li gr-button {
+        margin-right: 1em;
+      }
+      label,
+      input {
+        display: block;
+      }
+      label {
+        font-weight: bold;
+      }
+      input {
+        font-family: var(--monospace-font-family);
+        font-size: inherit;
+        margin-bottom: .5em;
+        width: 60em;
+      }
+      li[selected] gr-button {
+        color: #000;
+        font-weight: bold;
+        text-decoration: none;
+      }
+      header {
+        display: flex;
+        justify-content: space-between;
+      }
+      main {
+        border-bottom: 1px solid #ddd;
+        border-top: 1px solid #ddd;
+        padding: .5em;
+      }
+      footer {
+        display: flex;
+        justify-content: space-between;
+        padding-top: .75em;
+      }
+      .closeButtonContainer {
+        display: flex;
+        flex: 1;
+        justify-content: flex-end;
+      }
+      .patchFiles {
+        margin-right: 2em;
+      }
+      .patchFiles a,
+      .archives a {
+        display: inline-block;
+        margin-right: 1em;
+      }
+      .patchFiles a:last-of-type,
+      .archives a:last-of-type {
+        margin-right: 0;
+      }
+    </style>
+    <header>
+      <ul hidden$="[[!_schemes.length]]" hidden>
+        <template is="dom-repeat" items="[[_schemes]]" as="scheme">
+          <li selected$="[[_computeSchemeSelected(scheme, _selectedScheme)]]">
+            <gr-button link data-scheme$="[[scheme]]" on-tap="_handleSchemeTap">
+              [[scheme]]
+            </gr-button>
+          </li>
+        </template>
+      </ul>
+      <span class="closeButtonContainer">
+        <gr-button link on-tap="_handleCloseTap">Close</gr-button>
+      </span>
+    </header>
+    <main hidden$="[[!_schemes.length]]" hidden>
+      <template is="dom-repeat"
+          items="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
+          as="command">
+        <div class="command">
+          <label>[[command.title]]</label>
+          <input is="iron-input"
+              type="text"
+              bind-value="[[command.command]]"
+              on-tap="_handleInputTap"
+              readonly>
+        </div>
+      </template>
+    </main>
+    <footer>
+      <div class="patchFiles">
+        <label>Patch file</label>
+        <div>
+          <a href$="[[_computeDownloadLink(change, patchNum)]]">
+            [[_computeDownloadFilename(change, patchNum)]]
+          </a>
+          <a href$="[[_computeZipDownloadLink(change, patchNum)]]">
+            [[_computeZipDownloadFilename(change, patchNum)]]
+          </a>
+        </div>
+      </div>
+      <div class="archivesContainer" hidden$="[[!config.archives.length]]" hidden>
+        <label>Archive</label>
+        <div class="archives">
+          <template is="dom-repeat" items="[[config.archives]]" as="format">
+            <a href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]">
+              [[format]]
+            </a>
+          </template>
+        </div>
+      </div>
+    </footer>
+  </template>
+  <script src="gr-download-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
new file mode 100644
index 0000000..6677d62
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -0,0 +1,136 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-download-dialog',
+
+    /**
+     * Fired when the user presses the close button.
+     *
+     * @event close
+     */
+
+    properties: {
+      change: Object,
+      patchNum: String,
+      config: Object,
+
+      _schemes: {
+        type: Array,
+        value: function() { return []; },
+        computed: '_computeSchemes(change, patchNum)',
+        observer: '_schemesChanged',
+      },
+      _selectedScheme: String,
+    },
+
+    hostAttributes: {
+      role: 'dialog',
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    _computeDownloadCommands: function(change, patchNum, _selectedScheme) {
+      var commandObj;
+      for (var rev in change.revisions) {
+        if (change.revisions[rev]._number == patchNum) {
+          commandObj = change.revisions[rev].fetch[_selectedScheme].commands;
+          break;
+        }
+      }
+      var commands = [];
+      for (var title in commandObj) {
+        commands.push({
+          title: title,
+          command: commandObj[title],
+        });
+      }
+      return commands;
+    },
+
+    _computeZipDownloadLink: function(change, patchNum) {
+      return this._computeDownloadLink(change, patchNum, true);
+    },
+
+    _computeZipDownloadFilename: function(change, patchNum) {
+      return this._computeDownloadFilename(change, patchNum, true);
+    },
+
+    _computeDownloadLink: function(change, patchNum, zip) {
+      return this.changeBaseURL(change._number, patchNum) + '/patch?' +
+          (zip ? 'zip' : 'download');
+    },
+
+    _computeDownloadFilename: function(change, patchNum, zip) {
+      var shortRev;
+      for (var rev in change.revisions) {
+        if (change.revisions[rev]._number == patchNum) {
+          shortRev = rev.substr(0, 7);
+          break;
+        }
+      }
+      return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
+    },
+
+    _computeArchiveDownloadLink: function(change, patchNum, format) {
+      return this.changeBaseURL(change._number, patchNum) +
+          '/archive?format=' + format;
+    },
+
+    _computeSchemes: function(change, patchNum) {
+      for (var rev in change.revisions) {
+        if (change.revisions[rev]._number == patchNum) {
+          var fetch = change.revisions[rev].fetch;
+          if (fetch) {
+            return Object.keys(fetch).sort();
+          }
+          break;
+        }
+      }
+      return [];
+    },
+
+    _computeSchemeSelected: function(scheme, selectedScheme) {
+      return scheme == selectedScheme;
+    },
+
+    _handleSchemeTap: function(e) {
+      e.preventDefault();
+      var el = Polymer.dom(e).rootTarget;
+      // TODO(andybons): Save as default scheme in preferences.
+      this._selectedScheme = el.getAttribute('data-scheme');
+    },
+
+    _handleInputTap: function(e) {
+      e.preventDefault();
+      Polymer.dom(e).rootTarget.select();
+    },
+
+    _handleCloseTap: function(e) {
+      e.preventDefault();
+      this.fire('close', null, {bubbles: false});
+    },
+
+    _schemesChanged: function(schemes) {
+      if (schemes.length == 0) { return; }
+      if (schemes.indexOf(this._selectedScheme) == -1) {
+        this._selectedScheme = schemes.sort()[0];
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
new file mode 100644
index 0000000..2480c4a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -0,0 +1,123 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-download-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-download-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-download-dialog></gr-download-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-download-dialog tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+      element.change = {
+        current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+        revisions: {
+          '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+            _number: 1,
+            fetch: {
+              repo: {
+                commands: {
+                  repo: 'repo download test-project 5/1'
+                }
+              },
+              ssh: {
+                commands: {
+                  'Checkout': 'git fetch ssh://andybons@localhost:29418/test-project refs/changes/05/5/1 && git checkout FETCH_HEAD',
+                  'Cherry Pick': 'git fetch ssh://andybons@localhost:29418/test-project refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+                  'Format Patch': 'git fetch ssh://andybons@localhost:29418/test-project refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD',
+                  'Pull': 'git pull ssh://andybons@localhost:29418/test-project refs/changes/05/5/1'
+                }
+              },
+              http: {
+                commands: {
+                  'Checkout': 'git fetch http://andybons@localhost:8080/a/test-project refs/changes/05/5/1 && git checkout FETCH_HEAD',
+                  'Cherry Pick': 'git fetch http://andybons@localhost:8080/a/test-project refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+                  'Format Patch': 'git fetch http://andybons@localhost:8080/a/test-project refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD',
+                  'Pull': 'git pull http://andybons@localhost:8080/a/test-project refs/changes/05/5/1'
+                }
+              }
+            }
+          }
+        }
+      };
+      element.patchNum = 1;
+      element.config = {
+        schemes: {
+          'anonymous http': {},
+          http: {},
+          repo: {},
+          ssh: {},
+        },
+        archives: ['tgz', 'tar', 'tbz2', 'txz'],
+      };
+    });
+
+    test('element visibility', function() {
+      assert.isFalse(element.$$('ul').hasAttribute('hidden'));
+      assert.isFalse(element.$$('main').hasAttribute('hidden'));
+      assert.isFalse(element.$$('.archivesContainer').hasAttribute('hidden'));
+
+      element.set('config.archives', []);
+      assert.isTrue(element.$$('.archivesContainer').hasAttribute('hidden'));
+    });
+
+    test('computed fields', function() {
+      assert.equal(element._computeArchiveDownloadLink(
+          {_number: 123}, 2, 'tgz'),
+          '/changes/123/revisions/2/archive?format=tgz');
+    });
+
+    test('close event', function(done) {
+      element.addEventListener('close', function() {
+        done();
+      });
+      MockInteractions.tap(element.$$('.closeButtonContainer gr-button'));
+    });
+
+    test('tab selection', function() {
+      flushAsynchronousOperations();
+      var el = element.$$('[data-scheme="http"]').parentElement;
+      assert.isTrue(el.hasAttribute('selected'));
+      ['repo', 'ssh'].forEach(function(scheme) {
+        var el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
+        assert.isFalse(el.hasAttribute('selected'));
+      });
+
+      MockInteractions.tap(element.$$('[data-scheme="ssh"]'));
+      el = element.$$('[data-scheme="ssh"]').parentElement;
+      assert.isTrue(el.hasAttribute('selected'));
+      ['http', 'repo'].forEach(function(scheme) {
+        var el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
+        assert.isFalse(el.hasAttribute('selected'));
+      });
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
new file mode 100644
index 0000000..e010468
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -0,0 +1,159 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-ajax/gr-ajax.html">
+<link rel="import" href="../../shared/gr-request/gr-request.html">
+
+<dom-module id="gr-file-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      .row {
+        display: flex;
+        padding: .1em .25em;
+      }
+      .header {
+        font-weight: bold;
+      }
+      .positionIndicator,
+      .reviewed,
+      .status {
+        align-items: center;
+        display: inline-flex;
+      }
+      .reviewed,
+      .status {
+        justify-content: center;
+        width: 1.5em;
+      }
+      .positionIndicator {
+        justify-content: flex-start;
+        visibility: hidden;
+        width: 1.25em;
+      }
+      .row[selected] {
+        background-color: #ebf5fb;
+      }
+      .row[selected] .positionIndicator {
+        visibility: visible;
+      }
+      .path {
+        flex: 1;
+        overflow: hidden;
+        padding-left: .35em;
+        text-decoration: none;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .row:not(.header) .path:hover {
+        text-decoration: underline;
+      }
+      .comments,
+      .stats {
+        text-align: right;
+      }
+      .comments {
+        min-width: 10em;
+      }
+      .stats {
+        min-width: 7em;
+      }
+      .invisible {
+        visibility: hidden;
+      }
+      .row:not(.header) .stats {
+        font-family: var(--monospace-font-family);
+      }
+      .added {
+        color: #388E3C;
+      }
+      .removed {
+        color: #D32F2F;
+      }
+      .reviewed input[type="checkbox"] {
+        display: inline-block;
+      }
+      .drafts {
+        color: #C62828;
+        font-weight: bold;
+      }
+      @media screen and (max-width: 50em) {
+        .row[selected] {
+          background-color: transparent;
+        }
+        .positionIndicator,
+        .stats {
+          display: none;
+        }
+        .reviewed,
+        .status {
+          justify-content: flex-start;
+        }
+        .comments {
+          min-width: initial;
+        }
+      }
+    </style>
+    <gr-ajax id="filesXHR"
+        url="[[_computeFilesURL(changeNum, patchNum)]]"
+        on-response="_handleResponse"></gr-ajax>
+    <gr-ajax id="draftsXHR"
+        url="[[_computeDraftsURL(changeNum, patchNum)]]"
+        last-response="{{_drafts}}"></gr-ajax>
+    <gr-ajax id="reviewedXHR"
+        url="[[_computeReviewedURL(changeNum, patchNum)]]"
+        last-response="{{_reviewed}}"></gr-ajax>
+    </gr-ajax>
+
+    <div class="row header">
+      <div class="positionIndicator"></div>
+      <div class="reviewed" hidden$="[[!_loggedIn]]" hidden></div>
+      <div class="status"></div>
+      <div class="path">Path</div>
+      <div class="comments">Comments</div>
+      <div class="stats">Stats</div>
+    </div>
+    <template is="dom-repeat" items="{{files}}" as="file">
+      <div class="row" selected$="[[_computeFileSelected(index, selectedIndex)]]">
+        <div class="positionIndicator">&#x25b6;</div>
+        <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
+          <input type="checkbox" checked$="[[_computeReviewed(file, _reviewed)]]"
+              data-path$="[[file.__path]]" on-change="_handleReviewedChange">
+        </div>
+        <div class$="[[_computeClass('status', file.__path)]]">
+          [[_computeFileStatus(file.status)]]
+        </div>
+        <a class="path" href$="[[_computeDiffURL(changeNum, patchNum, file.__path)]]">
+          [[_computeFileDisplayName(file.__path)]]
+        </a>
+        <div class="comments">
+          <span class="drafts">[[_computeDraftsString(_drafts, file.__path)]]</span>
+          [[_computeCommentsString(comments, patchNum, file.__path)]]
+        </div>
+        <div class$="[[_computeClass('stats', file.__path)]]">
+          <span class="added">+[[file.lines_inserted]]</span>
+          <span class="removed">-[[file.lines_deleted]]</span>
+        </div>
+      </div>
+    </template>
+  </template>
+  <script src="gr-file-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
new file mode 100644
index 0000000..9fe5ca1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -0,0 +1,205 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+
+  Polymer({
+    is: 'gr-file-list',
+
+    properties: {
+      patchNum: String,
+      changeNum: String,
+      comments: Object,
+      files: Array,
+      selectedIndex: {
+        type: Number,
+        notify: true,
+      },
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _drafts: Object,
+      _reviewed: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _xhrPromise: Object,  // Used for testing.
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.RESTClientBehavior,
+    ],
+
+    reload: function() {
+      if (!this.changeNum || !this.patchNum) {
+        return Promise.resolve();
+      }
+      return Promise.all([
+        this.$.filesXHR.generateRequest().completes,
+        app.accountReady.then(function() {
+          this._loggedIn = app.loggedIn;
+          if (!app.loggedIn) { return; }
+          this.$.draftsXHR.generateRequest();
+          this.$.reviewedXHR.generateRequest();
+        }.bind(this)),
+      ]);
+    },
+
+    _computeFilesURL: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/files';
+    },
+
+    _computeCommentsString: function(comments, patchNum, path) {
+      var patchComments = (comments[path] || []).filter(function(c) {
+        return c.patch_set == patchNum;
+      });
+      var num = patchComments.length;
+      if (num == 0) { return ''; }
+      if (num == 1) { return '1 comment'; }
+      if (num > 1) { return num + ' comments'; }
+    },
+
+    _computeReviewedURL: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/files?reviewed';
+    },
+
+    _computeReviewed: function(file, _reviewed) {
+      return _reviewed.indexOf(file.__path) != -1;
+    },
+
+    _handleReviewedChange: function(e) {
+      var path = Polymer.dom(e).rootTarget.getAttribute('data-path');
+      var index = this._reviewed.indexOf(path);
+      var reviewed = index != -1;
+      if (reviewed) {
+        this.splice('_reviewed', index, 1);
+      } else {
+        this.push('_reviewed', path);
+      }
+
+      var method = reviewed ? 'DELETE' : 'PUT';
+      var url = this.changeBaseURL(this.changeNum, this.patchNum) +
+          '/files/' + encodeURIComponent(path) + '/reviewed';
+      this._send(method, url).catch(function(err) {
+        alert('Couldn’t change file review status. Check the console ' +
+            'and contact the PolyGerrit team for assistance.');
+        throw err;
+      }.bind(this));
+    },
+
+    _computeDraftsURL: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/drafts';
+    },
+
+    _computeDraftsString: function(drafts, path) {
+      var num = (drafts[path] || []).length;
+      if (num == 0) { return ''; }
+      if (num == 1) { return '1 draft'; }
+      if (num > 1) { return num + ' drafts'; }
+    },
+
+    _handleResponse: function(e, req) {
+      var result = e.detail.response;
+      var paths = Object.keys(result).sort();
+      var files = [];
+      for (var i = 0; i < paths.length; i++) {
+        var info = result[paths[i]];
+        info.__path = paths[i];
+        info.lines_inserted = info.lines_inserted || 0;
+        info.lines_deleted = info.lines_deleted || 0;
+        files.push(info);
+      }
+      this.files = files;
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+
+      switch (e.keyCode) {
+        case 74:  // 'j'
+          e.preventDefault();
+          this.selectedIndex =
+              Math.min(this.files.length - 1, this.selectedIndex + 1);
+          break;
+        case 75:  // 'k'
+          e.preventDefault();
+          this.selectedIndex = Math.max(0, this.selectedIndex - 1);
+          break;
+        case 219:  // '['
+          e.preventDefault();
+          this._openSelectedFile(this.files.length - 1);
+          break;
+        case 221:  // ']'
+          e.preventDefault();
+          this._openSelectedFile(0);
+          break;
+        case 13:  // <enter>
+        case 79:  // 'o'
+          e.preventDefault();
+          this._openSelectedFile();
+          break;
+      }
+    },
+
+    _openSelectedFile: function(opt_index) {
+      if (opt_index != null) {
+        this.selectedIndex = opt_index;
+      }
+      page.show(this._computeDiffURL(this.changeNum, this.patchNum,
+          this.files[this.selectedIndex].__path));
+    },
+
+    _computeFileSelected: function(index, selectedIndex) {
+      return index == selectedIndex;
+    },
+
+    _computeFileStatus: function(status) {
+      return status || 'M';
+    },
+
+    _computeDiffURL: function(changeNum, patchNum, path) {
+      return '/c/' + changeNum + '/' + patchNum + '/' + path;
+    },
+
+    _computeFileDisplayName: function(path) {
+      return path == COMMIT_MESSAGE_PATH ? 'Commit message' : path;
+    },
+
+    _computeClass: function(baseClass, path) {
+      var classes = [baseClass];
+      if (path == COMMIT_MESSAGE_PATH) {
+        classes.push('invisible');
+      }
+      return classes.join(' ');
+    },
+
+    _send: function(method, url) {
+      var xhr = document.createElement('gr-request');
+      this._xhrPromise = xhr.send({
+        method: method,
+        url: url,
+      });
+      return this._xhrPromise;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
new file mode 100644
index 0000000..06a01c6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -0,0 +1,320 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-file-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../scripts/fake-app.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-file-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-file-list></gr-file-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-file-list tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/1/files',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({
+            '/COMMIT_MSG': {
+              status: 'A',
+              lines_inserted: 9,
+              size_delta: 317,
+              size: 317
+            },
+            'myfile.txt': {
+              lines_inserted: 35,
+              size_delta: 1146,
+              size: 1167
+            }
+          }),
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/2/files',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({
+            '/COMMIT_MSG': {
+              status: 'A',
+              lines_inserted: 9,
+              size_delta: 317,
+              size: 317
+            },
+            'myfile.txt': {
+              lines_inserted: 35,
+              size_delta: 1146,
+              size: 1167
+            },
+            'file_added_in_rev2.txt': {
+              lines_inserted: 98,
+              size_delta: 234,
+              size: 136
+            }
+          }),
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/1/drafts',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          '{}',
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/2/drafts',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          '{}',
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/1/files?reviewed',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          '["/COMMIT_MSG"]',
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/2/files?reviewed',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          '["/COMMIT_MSG","myfile.txt"]',
+        ]
+      );
+      server.respondWith(
+        'PUT',
+        '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed',
+        [
+          201,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          '""',
+        ]
+      );
+      server.respondWith(
+        'DELETE',
+        '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed',
+        [
+          204,
+          {'Content-Type': 'application/json'},
+          '',
+        ]
+      );
+
+      app.loggedIn = true;
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    test('requests', function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      element.reload();
+      server.respond();
+
+      element.async(function() {
+        var filenames = element.files.map(function(f) {
+          return f.__path;
+        });
+        assert.deepEqual(filenames, ['/COMMIT_MSG', 'myfile.txt']);
+        assert.deepEqual(element._reviewed, ['/COMMIT_MSG']);
+
+        element.patchNum = '2';
+        element.reload();
+        server.respond();
+        element.async(function() {
+          filenames = element.files.map(function(f) {
+            return f.__path;
+          });
+          assert.deepEqual(filenames,
+              ['/COMMIT_MSG', 'file_added_in_rev2.txt', 'myfile.txt']);
+          assert.deepEqual(element._reviewed, ['/COMMIT_MSG', 'myfile.txt']);
+          done();
+        }, 1);
+      }, 1);
+    });
+
+    test('keyboard shortcuts', function(done) {
+      element.changeNum = '42';
+      element.patchNum = '2';
+      element.selectedIndex = 0;
+      element.reload();
+      server.respond();
+
+      element.async(function() {
+        var elementItems = Polymer.dom(element.root).querySelectorAll(
+            '.row:not(.header)');
+        assert.equal(elementItems.length, 3);
+        assert.isTrue(elementItems[0].hasAttribute('selected'));
+        assert.isFalse(elementItems[1].hasAttribute('selected'));
+        assert.isFalse(elementItems[2].hasAttribute('selected'));
+        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+
+        var showStub = sinon.stub(page, 'show');
+        assert.equal(element.selectedIndex, 2);
+        MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+        assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
+            'Should navigate to /c/42/2/myfile.txt');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 79);  // 'o'
+        assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
+            'Should navigate to /c/42/2/file_added_in_rev2.txt');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        assert.equal(element.selectedIndex, 0);
+
+        showStub.restore();
+        done();
+      }, 1);
+    });
+
+    test('comment filtering', function() {
+      var comments = {
+        '/COMMIT_MSG': [
+          {patch_set: 1, message: 'Done'},
+          {patch_set: 1, message: 'oh hay'},
+          {patch_set: 2, message: 'hello'},
+        ],
+        'myfile.txt': [
+          {patch_set: 1, message: 'good news!'},
+          {patch_set: 2, message: 'wat!?'},
+          {patch_set: 2, message: 'hi'},
+        ],
+      };
+      assert.equal(
+          element._computeCommentsString(comments, '1', '/COMMIT_MSG'),
+          '2 comments');
+      assert.equal(
+          element._computeCommentsString(comments, '1', 'myfile.txt'),
+          '1 comment');
+      assert.equal(
+          element._computeCommentsString(comments, '1',
+              'file_added_in_rev2.txt'),
+          '');
+      assert.equal(
+          element._computeCommentsString(comments, '2', '/COMMIT_MSG'),
+          '1 comment');
+      assert.equal(
+          element._computeCommentsString(comments, '2', 'myfile.txt'),
+          '2 comments');
+      assert.equal(
+          element._computeCommentsString(comments, '2',
+              'file_added_in_rev2.txt'),
+          '');
+    });
+
+    test('computed properties', function() {
+      assert.equal(element._computeFileStatus('A'), 'A');
+      assert.equal(element._computeFileStatus(undefined), 'M');
+      assert.equal(element._computeFileStatus(null), 'M');
+
+      assert.equal(element._computeFileDisplayName('/foo/bar/baz'),
+          '/foo/bar/baz');
+      assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
+          'Commit message');
+
+      assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
+      assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
+          'clazz invisible');
+    });
+
+    test('file review status', function(done) {
+      element.changeNum = '42';
+      element.patchNum = '2';
+      element.reload();
+      server.respond();
+
+      element.async(function() {
+        var fileRows =
+            Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
+        var commitMsg = fileRows[0].querySelector('input[type="checkbox"]');
+        var fileAdded = fileRows[1].querySelector('input[type="checkbox"]');
+        var myFile = fileRows[2].querySelector('input[type="checkbox"]');
+
+        assert.isTrue(commitMsg.checked);
+        assert.isFalse(fileAdded.checked);
+        assert.isTrue(myFile.checked);
+
+        assert.equal(element._reviewed.length, 2);
+
+        MockInteractions.tap(commitMsg);
+        server.respond();
+        element._xhrPromise.then(function(req) {
+          assert.equal(element._reviewed.length, 1);
+          assert.equal(req.status, 204);
+          assert.equal(req.url,
+              '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed');
+
+          MockInteractions.tap(commitMsg);
+          server.respond();
+        }).then(function() {
+          element._xhrPromise.then(function(req) {
+            assert.equal(element._reviewed.length, 2);
+            assert.equal(req.status, 201);
+            assert.equal(req.url,
+                '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed');
+
+            done();
+          });
+        });
+      }, 1);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
new file mode 100644
index 0000000..5733acd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -0,0 +1,125 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+
+<link rel="import" href="../gr-comment-list/gr-comment-list.html">
+
+<dom-module id="gr-message">
+  <template>
+    <style>
+      :host {
+        border-top: 1px solid #ddd;
+        display: block;
+        position: relative;
+      }
+      :host(:not([expanded])) {
+        cursor: pointer;
+      }
+      gr-avatar {
+        position: absolute;
+        left: var(--default-horizontal-margin);
+      }
+      .collapsed .contentContainer {
+        color: #777;
+        white-space: nowrap;
+        overflow-x: hidden;
+        text-overflow: ellipsis;
+      }
+      .showAvatar.expanded .contentContainer {
+        margin-left: calc(var(--default-horizontal-margin) + 2.5em);
+        padding: 10px 0;
+      }
+      .showAvatar.collapsed .contentContainer {
+        margin-left: calc(var(--default-horizontal-margin) + 1.75em);
+        padding: 10px 75px 10px 0;
+      }
+      .hideAvatar.collapsed .contentContainer,
+      .hideAvatar.expanded .contentContainer {
+        margin-left: 0;
+        padding: 10px 75px 10px 0;
+      }
+      .collapsed gr-avatar {
+        top: 8px;
+        height: 1.75em;
+        width: 1.75em;
+      }
+      .expanded gr-avatar {
+        top: 12px;
+        height: 2.5em;
+        width: 2.5em;
+      }
+      .name {
+        font-weight: bold;
+      }
+      .content {
+        font-family: var(--monospace-font-family);
+      }
+      .collapsed .name,
+      .collapsed .content,
+      .collapsed .message {
+        display: inline;
+      }
+      .collapsed gr-comment-list,
+      .collapsed .replyContainer {
+        display: none;
+      }
+      .collapsed .name {
+        color: var(--default-text-color);
+      }
+      .expanded .name {
+        cursor: pointer;
+      }
+      .date {
+        color: #666;
+        position: absolute;
+        right: var(--default-horizontal-margin);
+        top: 10px;
+      }
+      .replyContainer {
+        padding: .5em 0 1em;
+      }
+    </style>
+    <div class$="[[_computeClass(expanded, showAvatar)]]">
+      <gr-avatar account="[[message.author]]" image-size="100"></gr-avatar>
+      <div class="contentContainer">
+        <div class="name" on-tap="_handleNameTap">[[message.author.name]]</div>
+        <div class="content">
+          <gr-linked-text class="message"
+              pre="[[expanded]]"
+              content="[[message.message]]"
+              disabled="[[!expanded]]"
+              config="[[projectConfig.commentlinks]]"></gr-linked-text>
+          <gr-comment-list
+              comments="[[comments]]"
+              change-num="[[changeNum]]"
+              patch-num="[[message._revision_number]]"></gr-comment-list>
+        </div>
+        <a class="date" href$="[[_computeMessageHash(message)]]" on-tap="_handleLinkTap">
+          <gr-date-formatter date-str="[[message.date]]"></gr-date-formatter>
+        </a>
+      </div>
+      <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
+        <gr-button small on-tap="_handleReplyTap">Reply</gr-button>
+      </div>
+    </div>
+  </template>
+  <script src="gr-message.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
new file mode 100644
index 0000000..1ab5e6c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -0,0 +1,111 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-message',
+
+    /**
+     * Fired when this message's permalink is tapped.
+     *
+     * @event scroll-to
+     */
+
+    /**
+     * Fired when this message's reply link is tapped.
+     *
+     * @event reply
+     */
+
+    listeners: {
+      'tap': '_handleTap',
+    },
+
+    properties: {
+      changeNum: Number,
+      message: Object,
+      comments: {
+        type: Object,
+        observer: '_commentsChanged',
+      },
+      expanded: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+      },
+      showAvatar: {
+        type: Boolean,
+        value: false,
+      },
+      showReplyButton: {
+        type: Boolean,
+        value: false,
+      },
+      projectConfig: Object,
+    },
+
+    ready: function() {
+      app.configReady.then(function(cfg) {
+        this.showAvatar = !!(cfg && cfg.plugin && cfg.plugin.has_avatars) &&
+            this.message && this.message.author;
+      }.bind(this));
+    },
+
+    _commentsChanged: function(value) {
+      this.expanded = Object.keys(value || {}).length > 0;
+    },
+
+    _handleTap: function(e) {
+      if (this.expanded) { return; }
+      this.expanded = true;
+    },
+
+    _handleNameTap: function(e) {
+      if (!this.expanded) { return; }
+      e.stopPropagation();
+      this.expanded = false;
+    },
+
+    _computeClass: function(expanded, showAvatar) {
+      var classes = [];
+      classes.push(expanded ? 'expanded' : 'collapsed');
+      classes.push(showAvatar ? 'showAvatar' : 'hideAvatar');
+      return classes.join(' ');
+    },
+
+    _computeMessageHash: function(message) {
+      return '#message-' + message.id;
+    },
+
+    _handleLinkTap: function(e) {
+      e.preventDefault();
+
+      this.fire('scroll-to', {message: this.message}, {bubbles: false});
+
+      var hash = this._computeMessageHash(this.message);
+      // Don't add the hash to the window history if it's already there.
+      // Otherwise you mess up expected back button behavior.
+      if (window.location.hash == hash) { return; }
+      // Change the URL but don’t trigger a nav event. Otherwise it will
+      // reload the page.
+      page.show(window.location.pathname + hash, null, false);
+    },
+
+    _handleReplyTap: function(e) {
+      e.preventDefault();
+      this.fire('reply', {message: this.message});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
new file mode 100644
index 0000000..0f09b70
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-message</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/fake-app.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-message.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-message></gr-message>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-message tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('reply event', function(done) {
+      element.message = {
+        'id': '47c43261_55aa2c41',
+        'author': {
+          '_account_id': 1115495,
+          'name': 'Andrew Bonventre',
+          'email': 'andybons@chromium.org',
+        },
+        'date': '2016-01-12 20:24:49.448000000',
+        'message': 'Uploaded patch set 1.',
+        '_revision_number': 1
+      };
+
+      element.addEventListener('reply', function(e) {
+        assert.deepEqual(e.detail.message, element.message);
+        done();
+      });
+      MockInteractions.tap(element.$$('.replyContainer gr-button'));
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
new file mode 100644
index 0000000..8a66d03
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -0,0 +1,62 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../gr-message/gr-message.html">
+
+<dom-module id="gr-messages-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      .header {
+        display: flex;
+        justify-content: space-between;
+        margin-bottom: .35em;
+      }
+      .header,
+      gr-message {
+        padding: 0 var(--default-horizontal-margin);
+      }
+      .highlighted {
+        animation: 3s fadeOut;
+      }
+      @keyframes fadeOut {
+        0% { background-color: #fff9c4; }
+        100% { background-color: #fff; }
+      }
+    </style>
+    <div class="header">
+      <h3>Messages</h3>
+      <gr-button link on-tap="_handleExpandCollapseTap">
+        [[_computeExpandCollapseMessage(_expanded)]]
+      </gr-button>
+    </div>
+    <template is="dom-repeat" items="[[messages]]" as="message">
+      <gr-message
+          change-num="[[changeNum]]"
+          message="[[message]]"
+          comments="[[_computeCommentsForMessage(comments, message, index)]]"
+          project-config="[[projectConfig]]"
+          show-reply-button="[[showReplyButtons]]"
+          on-scroll-to="_handleScrollTo"
+          data-message-id$="[[message.id]]"></gr-message>
+    </template>
+  </template>
+  <script src="gr-messages-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
new file mode 100644
index 0000000..1b9ce14
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -0,0 +1,111 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-messages-list',
+
+    properties: {
+      changeNum: Number,
+      messages: {
+        type: Array,
+        value: function() { return []; },
+      },
+      comments: Object,
+      projectConfig: Object,
+      topMargin: Number,
+      showReplyButtons: {
+        type: Boolean,
+        value: false,
+      },
+
+      _expanded: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    scrollToMessage: function(messageID) {
+      var el = this.$$('[data-message-id="' + messageID + '"]');
+      if (!el) { return; }
+
+      el.expanded = true;
+      var top = el.offsetTop;
+      for (var offsetParent = el.offsetParent;
+           offsetParent;
+           offsetParent = offsetParent.offsetParent) {
+        top += offsetParent.offsetTop;
+      }
+      window.scrollTo(0, top - this.topMargin);
+      this._highlightEl(el);
+    },
+
+    _highlightEl: function(el) {
+      var highlightedEls =
+          Polymer.dom(this.root).querySelectorAll('.highlighted');
+      for (var i = 0; i < highlightedEls.length; i++) {
+        highlightedEls[i].classList.remove('highlighted');
+      }
+      function handleAnimationEnd() {
+        el.removeEventListener('animationend', handleAnimationEnd);
+        el.classList.remove('highlighted');
+      }
+      el.addEventListener('animationend', handleAnimationEnd);
+      el.classList.add('highlighted');
+    },
+
+    _handleExpandCollapseTap: function(e) {
+      e.preventDefault();
+      this._expanded = !this._expanded;
+      var messageEls = Polymer.dom(this.root).querySelectorAll('gr-message');
+      for (var i = 0; i < messageEls.length; i++) {
+        messageEls[i].expanded = this._expanded;
+      }
+    },
+
+    _handleScrollTo: function(e) {
+      this.scrollToMessage(e.detail.message.id);
+    },
+
+    _computeExpandCollapseMessage: function(expanded) {
+      return expanded ? 'Collapse all' : 'Expand all';
+    },
+
+    _computeCommentsForMessage: function(comments, message, index) {
+      comments = comments || {};
+      var messages = this.messages || [];
+      var msgComments = {};
+      var mDate = util.parseDate(message.date);
+      var nextMDate;
+      if (index < messages.length - 1) {
+        nextMDate = util.parseDate(messages[index + 1].date);
+      }
+      for (var file in comments) {
+        var fileComments = comments[file];
+        for (var i = 0; i < fileComments.length; i++) {
+          var cDate = util.parseDate(fileComments[i].updated);
+          if (cDate >= mDate) {
+            if (nextMDate && cDate >= nextMDate) {
+              continue;
+            }
+            msgComments[file] = msgComments[file] || [];
+            msgComments[file].push(fileComments[i]);
+          }
+        }
+      }
+      return msgComments;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
new file mode 100644
index 0000000..5a562ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -0,0 +1,133 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-messages-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/fake-app.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-messages-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-messages-list></gr-messages-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-messages-list tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+      element.messages = [
+        {
+          'id': '47c43261_55aa2c41',
+          'author': {
+            '_account_id': 1115495,
+            'name': 'Andrew Bonventre',
+            'email': 'andybons@chromium.org',
+          },
+          'date': '2016-01-12 20:24:49.448000000',
+          'message': 'Uploaded patch set 1.',
+          '_revision_number': 1
+        },
+        {
+          'id': '47c43261_9593e420',
+          'author': {
+            '_account_id': 1115495,
+            'name': 'Andrew Bonventre',
+            'email': 'andybons@chromium.org',
+          },
+          'date': '2016-01-12 20:28:33.038000000',
+          'message': 'Patch Set 1:\n\n(1 comment)',
+          '_revision_number': 1
+        },
+        {
+          'id': '87b2aaf4_f73260c5',
+          'author': {
+            '_account_id': 1143760,
+            'name': 'Mark Mentovai',
+            'email': 'mark@chromium.org',
+          },
+          'date': '2016-01-12 21:17:07.554000000',
+          'message': 'Patch Set 1:\n\n(3 comments)',
+          '_revision_number': 1
+        }
+      ];
+      flushAsynchronousOperations();
+    });
+
+    test('expand/collapse all', function() {
+      var allMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message');
+      for (var i = 0; i < allMessageEls.length; i++) {
+        allMessageEls[i].expanded = false;
+      }
+      MockInteractions.tap(allMessageEls[1]);
+      assert.isTrue(allMessageEls[1].expanded);
+
+      MockInteractions.tap(element.$$('.header gr-button'));
+      allMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message');
+      for (var i = 0; i < allMessageEls.length; i++) {
+        assert.isTrue(allMessageEls[i].expanded);
+      }
+
+      MockInteractions.tap(element.$$('.header gr-button'));
+      allMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message');
+      for (var i = 0; i < allMessageEls.length; i++) {
+        assert.isFalse(allMessageEls[i].expanded);
+      }
+    });
+
+    test('scroll to message', function() {
+      var allMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message');
+      for (var i = 0; i < allMessageEls.length; i++) {
+        allMessageEls[i].expanded = false;
+      }
+
+      var scrollToStub = sinon.stub(window, 'scrollTo');
+      var highlightStub = sinon.stub(element, '_highlightEl');
+
+      element.scrollToMessage('invalid');
+
+      for (var i = 0; i < allMessageEls.length; i++) {
+        assert.isFalse(allMessageEls[i].expanded,
+            'expected gr-message ' + i + ' to not be expanded');
+      }
+
+      var messageID = '47c43261_9593e420';
+      element.scrollToMessage(messageID);
+      assert.isTrue(
+          element.$$('[data-message-id="' + messageID + '"]').expanded);
+
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+
+      scrollToStub.restore();
+      highlightStub.restore();
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
new file mode 100644
index 0000000..e93d008
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -0,0 +1,132 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-ajax/gr-ajax.html">
+
+<dom-module id="gr-related-changes-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      h3 {
+        margin: .5em 0 0;
+      }
+      section {
+        margin-bottom: 1em;
+      }
+      a {
+        display: block;
+      }
+      .relatedChanges a {
+        display: inline-block;
+      }
+      .strikethrough {
+        color: #666;
+        text-decoration: line-through;
+      }
+      .status {
+        color: #666;
+        font-weight: bold;
+      }
+      .notCurrent {
+        color: #e65100;
+      }
+      .indirectAncestor {
+        color: #33691e;
+      }
+      .submittable {
+        color: #1b5e20;
+      }
+      .hidden {
+        display: none;
+      }
+    </style>
+    <gr-ajax id="relatedXHR"
+        url="[[_computeRelatedURL(change._number, patchNum)]]"
+        last-response="{{_relatedResponse}}"></gr-ajax>
+    <gr-ajax id="submittedTogetherXHR"
+        url="[[_computeSubmittedTogetherURL(change._number)]]"
+        last-response="{{_submittedTogether}}"></gr-ajax>
+    <gr-ajax id="conflictsXHR"
+        url="/changes/"
+        params="[[_computeConflictsQueryParams(change._number)]]"
+        last-response="{{_conflicts}}"></gr-ajax>
+    <gr-ajax id="cherryPicksXHR"
+        url="/changes/"
+        params="[[_computeCherryPicksQueryParams(change.project, change.change_id, change._number)]]"
+        last-response="{{_cherryPicks}}"></gr-ajax>
+    <gr-ajax id="sameTopicXHR"
+        url="/changes/"
+        params="[[_computeSameTopicQueryParams(change.topic)]]"
+        last-response="{{_sameTopic}}"></gr-ajax>
+
+    <div hidden$="[[!_loading]]">Loading...</div>
+    <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
+      <h4>Relation Chain</h4>
+      <template is="dom-repeat" items="[[_relatedResponse.changes]]" as="change">
+        <div>
+          <a href$="[[_computeChangeURL(change._change_number, change._revision_number)]]"
+              class$="[[_computeLinkClass(change)]]">
+            [[change.commit.subject]]
+          </a>
+          <span class$="[[_computeChangeStatusClass(change)]]">
+            ([[_computeChangeStatus(change)]])
+          </span>
+        </div>
+      </template>
+    </section>
+    <section hidden$="[[!_submittedTogether.length]]" hidden>
+      <h4>Submitted together</h4>
+      <template is="dom-repeat" items="[[_submittedTogether]]" as="change">
+        <a href$="[[_computeChangeURL(change._number)]]"
+            class$="[[_computeLinkClass(change)]]">
+          [[change.project]]: [[change.branch]]: [[change.subject]]
+        </a>
+      </template>
+    </section>
+    <section hidden$="[[!_sameTopic.length]]" hidden>
+      <h4>Same topic</h4>
+      <template is="dom-repeat" items="[[_sameTopic]]" as="change">
+        <a href$="[[_computeChangeURL(change._number)]]"
+            class$="[[_computeLinkClass(change)]]">
+          [[change.project]]: [[change.branch]]: [[change.subject]]
+        </a>
+      </template>
+    </section>
+    <section hidden$="[[!_conflicts.length]]" hidden>
+      <h4>Merge conflicts</h4>
+      <template is="dom-repeat" items="[[_conflicts]]" as="change">
+        <a href$="[[_computeChangeURL(change._number)]]"
+            class$="[[_computeLinkClass(change)]]">
+          [[change.subject]]
+        </a>
+      </template>
+    </section>
+    <section hidden$="[[!_cherryPicks.length]]" hidden>
+      <h4>Cherry picks</h4>
+      <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
+        <a href$="[[_computeChangeURL(change._number)]]"
+            class$="[[_computeLinkClass(change)]]">
+          [[change.subject]]
+        </a>
+      </template>
+    </section>
+  </template>
+  <script src="gr-related-changes-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
new file mode 100644
index 0000000..f3a298e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -0,0 +1,242 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-related-changes-list',
+
+    properties: {
+      change: Object,
+      patchNum: String,
+      serverConfig: {
+        type: Object,
+        observer: '_serverConfigChanged',
+      },
+      hidden: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+
+      _loading: Boolean,
+      _resolveServerConfigReady: Function,
+      _serverConfigReady: {
+        type: Object,
+        value: function() {
+          return new Promise(function(resolve) {
+            this._resolveServerConfigReady = resolve;
+          }.bind(this));
+        }
+      },
+      _connectedRevisions: {
+        type: Array,
+        computed: '_computeConnectedRevisions(change, patchNum, ' +
+            '_relatedResponse.changes)',
+      },
+      _relatedResponse: Object,
+      _submittedTogether: Array,
+      _conflicts: Array,
+      _cherryPicks: Array,
+      _sameTopic: Array,
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    observers: [
+      '_resultsChanged(_relatedResponse.changes, _submittedTogether, ' +
+          '_conflicts, _cherryPicks, _sameTopic)',
+    ],
+
+    reload: function() {
+      if (!this.change || !this.patchNum) {
+        return Promise.resolve();
+      }
+      this._loading = true;
+      var promises = [
+        this.$.relatedXHR.generateRequest().completes,
+        this.$.submittedTogetherXHR.generateRequest().completes,
+        this.$.conflictsXHR.generateRequest().completes,
+        this.$.cherryPicksXHR.generateRequest().completes,
+      ];
+
+      return this._serverConfigReady.then(function() {
+        if (this.change.topic &&
+            !this.serverConfig.change.submit_whole_topic) {
+          return this.$.sameTopicXHR.generateRequest().completes;
+        } else {
+          this._sameTopic = [];
+        }
+        return Promise.resolve();
+      }.bind(this)).then(Promise.all(promises)).then(function() {
+        this._loading = false;
+      }.bind(this));
+    },
+
+    _computeRelatedURL: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/related';
+    },
+
+    _computeSubmittedTogetherURL: function(changeNum) {
+      return this.changeBaseURL(changeNum) + '/submitted_together';
+    },
+
+    _computeConflictsQueryParams: function(changeNum) {
+      var options = this.listChangesOptionsToHex(
+          this.ListChangesOption.CURRENT_REVISION,
+          this.ListChangesOption.CURRENT_COMMIT
+      );
+      return {
+        O: options,
+        q: 'status:open is:mergeable conflicts:' + changeNum,
+      };
+    },
+
+    _computeCherryPicksQueryParams: function(project, changeID, changeNum) {
+      var options = this.listChangesOptionsToHex(
+          this.ListChangesOption.CURRENT_REVISION,
+          this.ListChangesOption.CURRENT_COMMIT
+      );
+      var query = [
+        'project:' + project,
+        'change:' + changeID,
+        '-change:' + changeNum,
+        '-is:abandoned',
+      ].join(' ');
+      return {
+        O: options,
+        q: query
+      }
+    },
+
+    _computeSameTopicQueryParams: function(topic) {
+      var options = this.listChangesOptionsToHex(
+          this.ListChangesOption.LABELS,
+          this.ListChangesOption.CURRENT_REVISION,
+          this.ListChangesOption.CURRENT_COMMIT,
+          this.ListChangesOption.DETAILED_LABELS
+      );
+      return {
+        O: options,
+        q: 'status:open topic:' + topic,
+      };
+    },
+
+    _computeChangeURL: function(changeNum, patchNum) {
+      var urlStr = '/c/' + changeNum;
+      if (patchNum != null) {
+        urlStr += '/' + patchNum;
+      }
+      return urlStr;
+    },
+
+    _computeLinkClass: function(change) {
+      if (change.status == this.ChangeStatus.ABANDONED) {
+        return 'strikethrough';
+      }
+    },
+
+    _computeChangeStatusClass: function(change) {
+      var classes = ['status'];
+      if (change._revision_number != change._current_revision_number) {
+        classes.push('notCurrent');
+      } else if (this._isIndirectAncestor(change)) {
+        classes.push('indirectAncestor');
+      } else if (change.submittable) {
+        classes.push('submittable');
+      } else if (change.status == this.ChangeStatus.NEW) {
+        classes.push('hidden');
+      }
+      return classes.join(' ');
+    },
+
+    _computeChangeStatus: function(change) {
+      switch (change.status) {
+        case this.ChangeStatus.MERGED:
+          return 'Merged';
+        case this.ChangeStatus.ABANDONED:
+          return 'Abandoned';
+        case this.ChangeStatus.DRAFT:
+          return 'Draft';
+      }
+      if (change._revision_number != change._current_revision_number) {
+        return 'Not current';
+      } else if (this._isIndirectAncestor(change)) {
+        return 'Indirect ancestor';
+      } else if (change.submittable) {
+        return 'Submittable';
+      }
+      return ''
+    },
+
+    _serverConfigChanged: function(config) {
+      this._resolveServerConfigReady(config);
+    },
+
+    _resultsChanged: function(related, submittedTogether, conflicts,
+        cherryPicks, sameTopic) {
+      var results = [
+        related,
+        submittedTogether,
+        conflicts,
+        cherryPicks,
+        sameTopic
+      ];
+      for (var i = 0; i < results.length; i++) {
+        if (results[i].length > 0) {
+          this.hidden = false;
+          return;
+        }
+      }
+      this.hidden = true;
+    },
+
+    _isIndirectAncestor: function(change) {
+      return this._connectedRevisions.indexOf(change.commit.commit) == -1;
+    },
+
+    _computeConnectedRevisions: function(change, patchNum, relatedChanges) {
+      var connected = [];
+      var changeRevision;
+      for (var rev in change.revisions) {
+        if (change.revisions[rev]._number == patchNum) {
+          changeRevision = rev;
+        }
+      }
+      var commits = relatedChanges.map(function(c) { return c.commit; });
+      var pos = commits.length - 1;
+
+      while (pos >= 0) {
+        var commit = commits[pos].commit;
+        connected.push(commit);
+        if (commit == changeRevision) {
+          break;
+        }
+        pos--;
+      }
+      while (pos >= 0) {
+        for (var i = 0; i < commits[pos].parents.length; i++) {
+          if (connected.indexOf(commits[pos].parents[i].commit) != -1) {
+            connected.push(commits[pos].commit);
+            break;
+          }
+        }
+        --pos;
+      }
+      return connected;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
new file mode 100644
index 0000000..7e0c236
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -0,0 +1,217 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-related-changes-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-related-changes-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-related-changes-list></gr-related-changes-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-related-changes-list tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('connected revisions', function() {
+      var change = {
+        revisions: {
+          'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
+            _number: 1,
+          },
+          '26e5e4c9c7ae31cbd876271cca281ce22b413997': {
+            _number: 2,
+          },
+          'bf7884d695296ca0c91702ba3e2bc8df0f69a907': {
+            _number: 7,
+          },
+          'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': {
+            _number: 5,
+          },
+          'd6bcee67570859ccb684873a85cf50b1f0e96fda': {
+            _number: 6,
+          },
+          'cc960918a7f90388f4a9e05753d0f7b90ad44546': {
+            _number: 3,
+          },
+          '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
+            _number: 4,
+          }
+        }
+      };
+      var patchNum = 7;
+      var relatedChanges = [
+        {
+          commit: {
+            commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+            parents: [
+              {
+                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+            parents: [
+              {
+                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+            parents: [
+              {
+                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+            parents: [
+              {
+                commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+            parents: [
+              {
+                commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+            parents: [
+              {
+                commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75'
+              }
+            ],
+          },
+        }
+      ];
+
+      var connectedChanges =
+          element._computeConnectedRevisions(change, patchNum, relatedChanges);
+      assert.deepEqual(connectedChanges, [
+        '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+        'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+        'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+        'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+        '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+        '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+        '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+      ]);
+
+      patchNum = 4;
+      relatedChanges = [
+        {
+          commit: {
+            commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+            parents: [
+              {
+                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+            parents: [
+              {
+                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+            parents: [
+              {
+                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+            parents: [
+              {
+                commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+            parents: [
+              {
+                commit: 'af815dac54318826b7f1fa468acc76349ffc588e'
+              }
+            ],
+          },
+        },
+        {
+          commit: {
+            commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+            parents: [
+              {
+                commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c'
+              }
+            ],
+          },
+        }
+      ];
+
+      connectedChanges =
+          element._computeConnectedRevisions(change, patchNum, relatedChanges);
+      assert.deepEqual(connectedChanges, [
+        'af815dac54318826b7f1fa468acc76349ffc588e',
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+        'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+      ]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
new file mode 100644
index 0000000..ab21e6c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -0,0 +1,148 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-ajax/gr-ajax.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-request/gr-request.html">
+
+<dom-module id="gr-reply-dialog">
+  <style>
+    :host {
+      display: block;
+      max-height: 90vh;
+    }
+    :host([disabled]) {
+      pointer-events: none;
+    }
+    :host([disabled]) .container {
+      opacity: .5;
+    }
+    .container {
+      display: flex;
+      flex-direction: column;
+      max-height: 90vh;
+    }
+    section {
+      border-top: 1px solid #ddd;
+      padding: .5em .75em;
+    }
+    .textareaContainer,
+    .labelsContainer,
+    .actionsContainer {
+      flex-shrink: 0;
+    }
+    .textareaContainer {
+      position: relative;
+    }
+    iron-autogrow-textarea {
+      padding: 0;
+      font-family: var(--monospace-font-family);
+    }
+    .message {
+      border: none;
+      width: 100%;
+    }
+    .labelContainer:not(:first-of-type) {
+      margin-top: .5em;
+    }
+    .labelName {
+      display: inline-block;
+      width: 7em;
+      margin-right: .5em;
+      white-space: nowrap;
+    }
+    iron-selector {
+      display: inline-flex;
+    }
+    iron-selector > gr-button {
+      margin-right: .25em;
+    }
+    iron-selector > gr-button:first-of-type {
+      border-top-left-radius: 2px;
+      border-bottom-left-radius: 2px;
+    }
+    iron-selector > gr-button:last-of-type {
+      border-top-right-radius: 2px;
+      border-bottom-right-radius: 2px;
+    }
+    iron-selector > gr-button.iron-selected {
+      background-color: #ddd;
+    }
+    .draftsContainer {
+      overflow-y: auto;
+    }
+    .draftsContainer h3 {
+      margin-top: .25em;
+    }
+    .actionsContainer {
+      display: flex;
+      justify-content: space-between;
+    }
+    .action:link,
+    .action:visited {
+      color: #00e;
+    }
+  </style>
+  <template>
+    <gr-ajax id="draftsXHR"
+        url="[[_computeDraftsURL(changeNum)]]"
+        last-response="{{_drafts}}"></gr-ajax>
+    <div class="container">
+      <section class="textareaContainer">
+        <iron-autogrow-textarea
+            id="textarea"
+            class="message"
+            placeholder="Say something..."
+            disabled="{{disabled}}"
+            rows="4"
+            max-rows="15"
+            bind-value="{{draft}}"></iron-autogrow-textarea>
+      </section>
+      <section class="labelsContainer">
+        <template is="dom-repeat"
+            items="[[_computeLabelArray(permittedLabels)]]" as="label">
+          <div class="labelContainer">
+            <span class="labelName">[[label]]</span>
+            <iron-selector data-label$="[[label]]"
+                selected="[[_computeIndexOfLabelValue(labels, permittedLabels, label, _account)]]">
+              <template is="dom-repeat"
+                  items="[[_computePermittedLabelValues(permittedLabels, label)]]"
+                  as="value">
+                <gr-button data-value$="[[value]]">[[value]]</gr-button>
+              </template>
+            </iron-selector>
+          </div>
+        </template>
+      </section>
+      <section class="draftsContainer" hidden$="[[_computeHideDraftList(_drafts)]]">
+        <h3>[[_computeDraftsTitle(_drafts)]]</h3>
+        <gr-comment-list
+            comments="[[_drafts]]"
+            change-num="[[changeNum]]"
+            patch-num="[[patchNum]]"></gr-comment-list>
+      </section>
+      <section class="actionsContainer">
+        <gr-button primary class="action send" on-tap="_sendTapHandler">Send</gr-button>
+        <gr-button class="action cancel" on-tap="_cancelTapHandler">Cancel</gr-button>
+      </section>
+    </div>
+  </template>
+  <script src="gr-reply-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
new file mode 100644
index 0000000..3cd6e12
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -0,0 +1,171 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-reply-dialog',
+
+    /**
+     * Fired when a reply is successfully sent.
+     *
+     * @event send
+     */
+
+    /**
+     * Fired when the user presses the cancel button.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      changeNum: String,
+      patchNum: String,
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      draft: {
+        type: String,
+        value: '',
+      },
+      labels: Object,
+      permittedLabels: Object,
+
+      _account: Object,
+      _drafts: Object,
+      _xhrPromise: Object,  // Used for testing.
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    ready: function() {
+      app.accountReady.then(function() {
+        this._account = app.account;
+      }.bind(this));
+    },
+
+    reload: function() {
+      return this.$.draftsXHR.generateRequest().completes;
+    },
+
+    focus: function() {
+      this.async(function() {
+        this.$.textarea.textarea.focus();
+      }.bind(this));
+    },
+
+    _computeDraftsURL: function(changeNum) {
+      return '/changes/' + changeNum + '/drafts';
+    },
+
+    _computeHideDraftList: function(drafts) {
+      return Object.keys(drafts || {}).length == 0;
+    },
+
+    _computeDraftsTitle: function(drafts) {
+      var total = 0;
+      for (var file in drafts) {
+        total += drafts[file].length;
+      }
+      if (total == 0) { return ''; }
+      if (total == 1) { return '1 Draft'; }
+      if (total > 1) { return total + ' Drafts'; }
+    },
+
+    _computeLabelArray: function(labelsObj) {
+      return Object.keys(labelsObj).sort();
+    },
+
+    _computeIndexOfLabelValue: function(
+        labels, permittedLabels, labelName, account) {
+      var t = labels[labelName];
+      if (!t) { return null; }
+      var labelValue = t.default_value;
+
+      // Is there an existing vote for the current user? If so, use that.
+      var votes = labels[labelName];
+      if (votes.all && votes.all.length > 0) {
+        for (var i = 0; i < votes.all.length; i++) {
+          if (votes.all[i]._account_id == account._account_id) {
+            labelValue = votes.all[i].value;
+            break;
+          }
+        }
+      }
+
+      var len = permittedLabels[labelName] != null ?
+          permittedLabels[labelName].length : 0;
+      for (var i = 0; i < len; i++) {
+        var val = parseInt(permittedLabels[labelName][i], 10);
+        if (val == labelValue) {
+          return i;
+        }
+      }
+      return null;
+    },
+
+    _computePermittedLabelValues: function(permittedLabels, label) {
+      return permittedLabels[label];
+    },
+
+    _cancelTapHandler: function(e) {
+      e.preventDefault();
+      this._drafts = null;
+      this.fire('cancel', null, {bubbles: false});
+    },
+
+    _sendTapHandler: function(e) {
+      e.preventDefault();
+      var obj = {
+        drafts: 'PUBLISH_ALL_REVISIONS',
+        labels: {},
+      };
+      for (var label in this.permittedLabels) {
+        var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
+        var selectedVal = selectorEl.selectedItem.getAttribute('data-value');
+        selectedVal = parseInt(selectedVal, 10);
+        obj.labels[label] = selectedVal;
+      }
+      if (this.draft != null) {
+        obj.message = this.draft;
+      }
+      this.disabled = true;
+      this._send(obj).then(function(req) {
+        this.fire('send', null, {bubbles: false});
+        this.draft = '';
+        this.disabled = false;
+        this._drafts = null;
+      }.bind(this)).catch(function(err) {
+        alert('Oops. Something went wrong. Check the console and bug the ' +
+            'PolyGerrit team for assistance.');
+        throw err;
+      }.bind(this));
+    },
+
+    _send: function(payload) {
+      var xhr = document.createElement('gr-request');
+      this._xhrPromise = xhr.send({
+        method: 'POST',
+        url: this.changeBaseURL(this.changeNum, this.patchNum) + '/review',
+        body: payload,
+      });
+
+      return this._xhrPromise;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
new file mode 100644
index 0000000..a6f4671
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -0,0 +1,153 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reply-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/fake-app.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-reply-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-reply-dialog></gr-reply-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-reply-dialog tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      element.changeNum = 42;
+      element.patchNum = 1;
+      element.labels = {
+        Verified: {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified'
+          },
+          default_value: 0
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved'
+          },
+          default_value: 0
+        }
+      };
+      element.permittedLabels = {
+        'Code-Review': [
+          '-1',
+          ' 0',
+          '+1'
+        ],
+        Verified: [
+          '-1',
+          ' 0',
+          '+1'
+        ]
+      };
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'POST',
+        '/changes/42/revisions/1/review',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          '{' +
+            '"labels": {' +
+              '"Code-Review": -1,' +
+              '"Verified": -1' +
+            '}' +
+          '}'
+        ]
+      );
+
+      // Allow the elements created by dom-repeat to be stamped.
+      flushAsynchronousOperations();
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    test('cancel event', function(done) {
+      element.addEventListener('cancel', function() { done(); });
+      MockInteractions.tap(element.$$('.cancel'));
+    });
+
+    test('label picker', function(done) {
+      // Async tick is needed because iron-selector content is distributed and
+      // distributed content requires an observer to be set up.
+      element.async(function() {
+        for (var label in element.permittedLabels) {
+          assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
+              label);
+        }
+        element.draft = 'I wholeheartedly disapprove';
+        MockInteractions.tap(element.$$(
+            'iron-selector[data-label="Code-Review"] > ' +
+            'gr-button[data-value="-1"]'));
+        MockInteractions.tap(element.$$(
+            'iron-selector[data-label="Verified"] > ' +
+            'gr-button[data-value="-1"]'));
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        element.async(function() {
+          MockInteractions.tap(element.$$('.send'));
+          assert.isTrue(element.disabled);
+
+          server.respond();
+
+          element._xhrPromise.then(function(req) {
+            assert.isFalse(element.disabled,
+                'Element should be enabled when done sending reply.');
+            assert.equal(req.status, 200);
+            assert.equal(req.url, '/changes/42/revisions/1/review');
+            var reqObj = JSON.parse(req.xhr.requestBody);
+            assert.deepEqual(reqObj, {
+              drafts: 'PUBLISH_ALL_REVISIONS',
+              labels: {
+                'Code-Review': -1,
+                'Verified': -1
+              },
+              message: 'I wholeheartedly disapprove'
+            });
+            assert.equal(req.response.labels['Code-Review'], -1);
+            assert.equal(req.response.labels.Verified, -1);
+            done();
+          });
+        }, 1);
+      }, 1);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
new file mode 100644
index 0000000..d20fd01
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -0,0 +1,118 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../shared/gr-ajax/gr-ajax.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-request/gr-request.html">
+
+<dom-module id="gr-reviewer-list">
+  <style>
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: .8;
+      pointer-events: none;
+    }
+    .autocompleteContainer {
+      position: relative;
+    }
+    .inputContainer {
+      display: flex;
+      margin-top: .25em;
+    }
+    .inputContainer input {
+      flex: 1;
+      font: inherit;
+    }
+    .dropdown {
+      background-color: #fff;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+      position: absolute;
+      left: 0;
+      top: 100%;
+    }
+    .dropdown .reviewer {
+      cursor: pointer;
+      padding: .5em .75em;
+    }
+    .dropdown .reviewer[selected] {
+      background-color: #ccc;
+    }
+    .remove,
+    .cancel {
+      color: #999;
+    }
+    .remove {
+      font-size: .9em;
+    }
+    .cancel {
+      font-size: 2em;
+      line-height: 1;
+      padding: 0 .15em;
+      text-decoration: none;
+    }
+  </style>
+  <template>
+    <gr-ajax id="autocompleteXHR"
+        url="[[_computeAutocompleteURL(change)]]"
+        params="[[_computeAutocompleteParams(_inputVal)]]"
+        on-response="_handleResponse"></gr-ajax>
+
+    <template is="dom-repeat" items="[[_reviewers]]" as="reviewer">
+      <div class="reviewer">
+        <gr-account-link account="[[reviewer]]" show-email></gr-account-link>
+        <gr-button link
+            class="remove"
+            data-account-id$="[[reviewer._account_id]]"
+            on-tap="_handleRemoveTap"
+            hidden$="[[!_computeCanRemoveReviewer(reviewer, mutable)]]">remove</gr-buttom>
+      </div>
+    </template>
+    <div class="controlsContainer" hidden$="[[!mutable]]">
+      <div class="autocompleteContainer" hidden$="[[!_showInput]]">
+        <div class="inputContainer">
+          <input is="iron-input" id="input"
+              bind-value="{{_inputVal}}" disabled$="[[disabled]]">
+          <gr-button link class="cancel" on-tap="_handleCancelTap">×</gr-button>
+        </div>
+        <div class="dropdown" hidden$="[[_hideAutocomplete]]">
+          <template is="dom-repeat" items="[[_autocompleteData]]" as="reviewer">
+            <div class="reviewer"
+                data-index$="[[index]]"
+                on-mouseenter="_handleMouseEnterItem"
+                on-tap="_handleItemTap"
+                selected$="[[_computeSelected(index, _selectedIndex)]]">
+              <template is="dom-if" if="[[reviewer.account]]">
+                <gr-account-label
+                    account="[[reviewer.account]]" show-email></gr-account-label>
+              </template>
+              <template is="dom-if" if="[[reviewer.group]]">
+                <span>[[reviewer.group.name]] (group)</span>
+              </template>
+            </div>
+          </template>
+        </div>
+      </div>
+      <gr-button link id="addReviewer" class="addReviewer" on-tap="_handleAddTap"
+          hidden$="[[_showInput]]">Add reviewer</gr-button>
+    </div>
+  </template>
+  <script src="gr-reviewer-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
new file mode 100644
index 0000000..00fc12e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -0,0 +1,344 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-reviewer-list',
+
+    properties: {
+      change: Object,
+      mutable: {
+        type: Boolean,
+        value: false,
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      suggestFrom: {
+        type: Number,
+        value: 3,
+      },
+
+      _reviewers: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _autocompleteData: {
+        type: Array,
+        value: function() { return []; },
+        observer: '_autocompleteDataChanged',
+      },
+      _inputVal: {
+        type: String,
+        value: '',
+        observer: '_inputValChanged',
+      },
+      _inputRequestHandle: Number,
+      _inputRequestTimeout: {
+        type: Number,
+        value: 250,
+      },
+      _showInput: {
+        type: Boolean,
+        value: false,
+      },
+      _hideAutocomplete: {
+        type: Boolean,
+        value: true,
+        observer: '_hideAutocompleteChanged',
+      },
+      _selectedIndex: {
+        type: Number,
+        value: 0,
+      },
+      _boundBodyClickHandler: {
+        type: Function,
+        value: function() {
+          return this._handleBodyClick.bind(this);
+        },
+      },
+
+      // Used for testing.
+      _lastAutocompleteRequest: Object,
+      _xhrPromise: Object,
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    observers: [
+      '_reviewersChanged(change.reviewers.*, change.owner)',
+    ],
+
+    detached: function() {
+      this._clearInputRequestHandle();
+    },
+
+    _clearInputRequestHandle: function() {
+      if (this._inputRequestHandle != null) {
+        this.cancelAsync(this._inputRequestHandle);
+        this._inputRequestHandle = null;
+      }
+    },
+
+    _reviewersChanged: function(changeRecord, owner) {
+      var result = [];
+      var reviewers = changeRecord.base;
+      for (var key in reviewers) {
+        if (key == 'REVIEWER' || key == 'CC') {
+          result = result.concat(reviewers[key]);
+        }
+      }
+      this._reviewers = result.filter(function(reviewer) {
+        return reviewer._account_id != owner._account_id;
+      });
+    },
+
+    _computeCanRemoveReviewer: function(reviewer, mutable) {
+      if (!mutable) { return false; }
+
+      for (var i = 0; i < this.change.removable_reviewers.length; i++) {
+        if (this.change.removable_reviewers[i]._account_id ==
+            reviewer._account_id) {
+          return true;
+        }
+      }
+      return false;
+    },
+
+    _computeAutocompleteURL: function(change) {
+      return '/changes/' + change._number + '/suggest_reviewers';
+    },
+
+    _computeAutocompleteParams: function(inputVal) {
+      return {
+        n: 10,  // Return max 10 results
+        q: inputVal,
+      };
+    },
+
+    _computeSelected: function(index, selectedIndex) {
+      return index == selectedIndex;
+    },
+
+    _handleResponse: function(e) {
+      this._autocompleteData = e.detail.response.filter(function(reviewer) {
+        var account = reviewer.account;
+        if (!account) { return true; }
+        for (var i = 0; i < this._reviewers.length; i++) {
+          if (account._account_id == this.change.owner._account_id ||
+              account._account_id == this._reviewers[i]._account_id) {
+            return false;
+          }
+        }
+        return true;
+      }, this);
+    },
+
+    _handleBodyClick: function(e) {
+      var eventPath = Polymer.dom(e).path;
+      for (var i = 0; i < eventPath.length; i++) {
+        if (eventPath[i] == this) {
+          return;
+        }
+      }
+      this._selectedIndex = -1;
+      this._autocompleteData = [];
+    },
+
+    _handleRemoveTap: function(e) {
+      e.preventDefault();
+      var target = Polymer.dom(e).rootTarget;
+      var accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      this._send('DELETE', this._restEndpoint(accountID)).then(function(req) {
+        var reviewers = this.change.reviewers;
+        ['REVIEWER', 'CC'].forEach(function(type) {
+          reviewers[type] = reviewers[type] || [];
+          for (var i = 0; i < reviewers[type].length; i++) {
+            if (reviewers[type][i]._account_id == accountID) {
+              this.splice('change.reviewers.' + type, i, 1);
+              break;
+            }
+          }
+        }, this);
+      }.bind(this)).catch(function(err) {
+        alert('Oops. Something went wrong. Check the console and bug the ' +
+            'PolyGerrit team for assistance.');
+        throw err;
+      }.bind(this));
+    },
+
+    _handleAddTap: function(e) {
+      e.preventDefault();
+      this._showInput = true;
+      this.$.input.focus();
+    },
+
+    _handleCancelTap: function(e) {
+      e.preventDefault();
+      this._cancel();
+    },
+
+    _handleMouseEnterItem: function(e) {
+      this._selectedIndex =
+          parseInt(Polymer.dom(e).rootTarget.getAttribute('data-index'), 10);
+    },
+
+    _handleItemTap: function(e) {
+      var reviewerEl;
+      var eventPath = Polymer.dom(e).path;
+      for (var i = 0; i < eventPath.length; i++) {
+        var el = eventPath[i];
+        if (el.classList && el.classList.contains('reviewer')) {
+          reviewerEl = el;
+          break;
+        }
+      }
+      this._selectedIndex =
+          parseInt(reviewerEl.getAttribute('data-index'), 10);
+      this._sendAddRequest();
+    },
+
+    _autocompleteDataChanged: function(data) {
+      this._hideAutocomplete = data.length == 0;
+    },
+
+    _hideAutocompleteChanged: function(hidden) {
+      if (hidden) {
+        document.body.removeEventListener('click',
+            this._boundBodyClickHandler);
+        this._selectedIndex = -1;
+      } else {
+        document.body.addEventListener('click', this._boundBodyClickHandler);
+        this._selectedIndex = 0;
+      }
+    },
+
+    _inputValChanged: function(val) {
+      var sendRequest = function() {
+        if (this.disabled || val == null || val.trim().length == 0) {
+          return;
+        }
+        if (val.length < this.suggestFrom) {
+          this._clearInputRequestHandle();
+          this._hideAutocomplete = true;
+          this._selectedIndex = -1;
+          return;
+        }
+        this._lastAutocompleteRequest =
+            this.$.autocompleteXHR.generateRequest();
+      }.bind(this);
+
+      this._clearInputRequestHandle();
+      if (this._inputRequestTimeout == 0) {
+        sendRequest();
+      } else {
+        this._inputRequestHandle =
+            this.async(sendRequest, this._inputRequestTimeout);
+      }
+    },
+
+    _handleKey: function(e) {
+      if (this._hideAutocomplete) {
+        if (e.keyCode == 27) {  // 'esc'
+          e.preventDefault();
+          this._cancel();
+        }
+        return;
+      }
+
+      switch (e.keyCode) {
+        case 38:  // 'up':
+          e.preventDefault();
+          this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
+          break;
+        case 40:  // 'down'
+          e.preventDefault();
+          this._selectedIndex = Math.min(this._selectedIndex + 1,
+                                         this._autocompleteData.length - 1);
+          break;
+        case 27:  // 'esc'
+          e.preventDefault();
+          this._hideAutocomplete = true;
+          break;
+        case 13:  // 'enter'
+          e.preventDefault();
+          this._sendAddRequest();
+          break;
+      }
+    },
+
+    _cancel: function() {
+      this._showInput = false;
+      this._selectedIndex = 0;
+      this._inputVal = '';
+      this._autocompleteData = [];
+      this.$.addReviewer.focus();
+    },
+
+    _sendAddRequest: function() {
+      this._clearInputRequestHandle();
+
+      var reviewerID;
+      var reviewer = this._autocompleteData[this._selectedIndex];
+      if (reviewer.account) {
+        reviewerID = reviewer.account._account_id;
+      } else if (reviewer.group) {
+        reviewerID = reviewer.group.id;
+      }
+      this._autocompleteData = [];
+      this._send('POST', this._restEndpoint(), reviewerID).then(function(req) {
+        this.change.reviewers.CC = this.change.reviewers.CC || [];
+        req.response.reviewers.forEach(function(r) {
+          this.push('change.removable_reviewers', r);
+          this.push('change.reviewers.CC', r);
+        }, this);
+        this._inputVal = '';
+        this.$.input.focus();
+      }.bind(this)).catch(function(err) {
+        // TODO(andybons): Use the message returned by the server.
+        alert('Unable to add ' + reviewerID + ' as a reviewer.');
+        throw err;
+      }.bind(this));
+    },
+
+    _send: function(method, url, reviewerID) {
+      this.disabled = true;
+      var request = document.createElement('gr-request');
+      var opts = {
+        method: method,
+        url: url,
+      };
+      if (reviewerID) {
+        opts.body = {reviewer: reviewerID};
+      }
+      this._xhrPromise = request.send(opts);
+      var enableEl = function() { this.disabled = false; }.bind(this);
+      this._xhrPromise.then(enableEl).catch(enableEl);
+      return this._xhrPromise;
+    },
+
+    _restEndpoint: function(id) {
+      var path = '/changes/' + this.change._number + '/reviewers';
+      if (id) {
+        path += '/' + id;
+      }
+      return path;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
new file mode 100644
index 0000000..898d328
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -0,0 +1,278 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reviewer-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-reviewer-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-reviewer-list></gr-reviewer-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-reviewer-list tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'GET',
+        /\/changes\/42\/suggest_reviewers\?n=10&q=andy(.*)/,
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify([
+            {
+              account: {
+                _account_id: 1021482,
+                name: 'Andrew Bonventre',
+                email: 'andybons@chromium.org',
+              }
+            },
+            {
+              account: {
+                _account_id: 1021863,
+                name: 'Andrew Bonventre',
+                email: 'andybons@google.com',
+              }
+            },
+            {
+              group: {
+                id: 'c7af6dd375c092ff3b23c0937aa910693dc0c41b',
+                name: 'andy',
+              }
+            }
+          ]),
+        ]
+      );
+      server.respondWith(
+        'POST',
+        '/changes/42/reviewers',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({
+            reviewers: [{
+              _account_id: 1021482,
+              approvals: {
+                'Code-Review': ' 0'
+              },
+              email: 'andybons@chromium.org',
+              name: 'Andrew Bonventre',
+            }]
+          }),
+        ]
+      );
+      server.respondWith(
+        'DELETE',
+        '/changes/42/reviewers/1021482',
+        [
+          204,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n{}',
+        ]
+      );
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    test('controls hidden on immutable element', function() {
+      element.mutable = false;
+      assert.isTrue(element.$$('.controlsContainer').hasAttribute('hidden'));
+      element.mutable = true;
+      assert.isFalse(element.$$('.controlsContainer').hasAttribute('hidden'));
+    });
+
+    function getActiveElement() {
+      return document.activeElement.shadowRoot ?
+          document.activeElement.shadowRoot.activeElement :
+          document.activeElement;
+    }
+
+    test('show/hide input', function() {
+      element.mutable = true;
+      assert.isFalse(element.$$('.addReviewer').hasAttribute('hidden'));
+      assert.isTrue(
+          element.$$('.autocompleteContainer').hasAttribute('hidden'));
+      assert.notEqual(getActiveElement().id, 'input');
+      MockInteractions.tap(element.$$('.addReviewer'));
+      assert.isTrue(element.$$('.addReviewer').hasAttribute('hidden'));
+      assert.isFalse(
+          element.$$('.autocompleteContainer').hasAttribute('hidden'));
+      assert.equal(getActiveElement().id, 'input');
+      MockInteractions.pressAndReleaseKeyOn(element, 27); // 'esc'
+      assert.isFalse(element.$$('.addReviewer').hasAttribute('hidden'));
+      assert.isTrue(
+          element.$$('.autocompleteContainer').hasAttribute('hidden'));
+      assert.equal(getActiveElement().id, 'addReviewer');
+    });
+
+    test('only show remove for removable reviewers', function() {
+      element.mutable = true;
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          'REVIEWER': [
+            {
+              _account_id: 2,
+              name: 'Bojack Horseman',
+              email: 'SecretariatRulez96@hotmail.com',
+            },
+            {
+              _account_id: 3,
+              name: 'Pinky Penguin',
+            },
+          ],
+          'CC': [
+            {
+              _account_id: 4,
+              name: 'Diane Nguyen',
+              email: 'macarthurfellow2B@juno.com',
+            },
+          ]
+        },
+        removable_reviewers: [
+          {
+            _account_id: 3,
+            name: 'Pinky Penguin',
+          },
+          {
+            _account_id: 4,
+            name: 'Diane Nguyen',
+            email: 'macarthurfellow2B@juno.com',
+          },
+        ]
+      };
+      flushAsynchronousOperations();
+      var removeEls =
+          Polymer.dom(element.root).querySelectorAll('.reviewer > .remove');
+      assert.equal(removeEls.length, 3);
+      Array.from(removeEls).forEach(function(el) {
+        var accountID = parseInt(el.getAttribute('data-account-id'), 10);
+        assert.ok(accountID);
+        if (accountID == 2) {
+          assert.isTrue(el.hasAttribute('hidden'));
+        } else {
+          assert.isFalse(el.hasAttribute('hidden'));
+        }
+      });
+    });
+
+    test('autocomplete starts at >= 3 chars', function() {
+      element._inputRequestTimeout = 0;
+      element._mutable = true;
+      var genRequestStub = sinon.stub(
+        element.$.autocompleteXHR,
+        'generateRequest',
+        function() {
+          assert(false, 'generateRequest should not be called for input ' +
+              'lengths of less than 3 chars');
+        }
+      );
+      element._inputVal = 'fo';
+      flushAsynchronousOperations();
+      genRequestStub.restore();
+    });
+
+    test('add/remove reviewer flow', function(done) {
+      element.change = {
+        _number: 42,
+        reviewers: {},
+        removable_reviewers: [],
+        owner: {_account_id: 0},
+      };
+      element._inputRequestTimeout = 0;
+      element._mutable = true;
+      MockInteractions.tap(element.$$('.addReviewer'));
+      flushAsynchronousOperations();
+      element._inputVal = 'andy';
+      server.respond();
+
+      element._lastAutocompleteRequest.completes.then(function() {
+        flushAsynchronousOperations();
+        assert.isFalse(element.$$('.dropdown').hasAttribute('hidden'));
+        var itemEls = Polymer.dom(element.root).querySelectorAll('.reviewer');
+        assert.equal(itemEls.length, 3);
+        assert.isTrue(itemEls[0].hasAttribute('selected'));
+        assert.isFalse(itemEls[1].hasAttribute('selected'));
+
+        MockInteractions.pressAndReleaseKeyOn(element, 40); // 'down'
+        assert.isFalse(itemEls[0].hasAttribute('selected'));
+        assert.isTrue(itemEls[1].hasAttribute('selected'));
+
+        MockInteractions.pressAndReleaseKeyOn(element, 38); // 'up'
+        assert.isTrue(itemEls[0].hasAttribute('selected'));
+        assert.isFalse(itemEls[1].hasAttribute('selected'));
+
+        MockInteractions.pressAndReleaseKeyOn(element, 27); // 'esc'
+        assert.isTrue(element.$$('.dropdown').hasAttribute('hidden'));
+
+        element._inputVal = 'andyb';
+        server.respond();
+
+        element._lastAutocompleteRequest.completes.then(function() {
+          assert.isFalse(element.$$('.dropdown').hasAttribute('hidden'));
+          var itemEls = Polymer.dom(element.root).querySelectorAll('.reviewer');
+          assert.equal(itemEls.length, 3);
+          assert.isTrue(itemEls[0].hasAttribute('selected'));
+          assert.isFalse(itemEls[1].hasAttribute('selected'));
+          MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+          assert.isTrue(element.disabled);
+          server.respond();
+
+          element._xhrPromise.then(function() {
+            assert.isFalse(element.disabled);
+            flushAsynchronousOperations();
+            var reviewerEls =
+                Polymer.dom(element.root).querySelectorAll('.reviewer');
+            assert.equal(reviewerEls.length, 1);
+            MockInteractions.tap(element.$$('.reviewer > .remove'));
+            flushAsynchronousOperations();
+            assert.isTrue(element.disabled);
+            server.respond();
+
+            element._xhrPromise.then(function() {
+              flushAsynchronousOperations();
+              assert.isFalse(element.disabled);
+              var reviewerEls =
+                Polymer.dom(element.root).querySelectorAll('.reviewer');
+              assert.equal(reviewerEls.length, 0);
+              done();
+            });
+          });
+        });
+      });
+    });
+  });
+</script>