diff --git a/reviewboard/reviews/builtin_fields.py b/reviewboard/reviews/builtin_fields.py
index dea95888773702a51ed60a566c1c720bf64b6cd3..035cf7ebd78a455ff98f0962d3896b7df413089a 100644
--- a/reviewboard/reviews/builtin_fields.py
+++ b/reviewboard/reviews/builtin_fields.py
@@ -3,10 +3,13 @@ from __future__ import unicode_literals
 import logging
 
 from django.db import models
+from django.template.loader import Context, get_template
 from django.utils import six
-from django.utils.html import escape
+from django.utils.html import escape, format_html, format_html_join
+from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext_lazy as _
 
+from reviewboard.diffviewer.diffutils import get_sorted_filediffs
 from reviewboard.reviews.fields import (BaseCommaEditableField,
                                         BaseEditableField,
                                         BaseReviewRequestField,
@@ -93,20 +96,38 @@ class BuiltinLocalsFieldMixin(BuiltinFieldMixin):
         return None
 
 
-class BaseCaptionsField(BaseReviewRequestField):
+class BaseCaptionsField(BuiltinLocalsFieldMixin, BaseReviewRequestField):
     """Base class for rendering captions for attachments.
 
     This serves as a base for FileAttachmentCaptionsField and
     ScreenshotCaptionsField. It provides the base rendering and
     for caption changes on file attachments or screenshots.
     """
+    obj_map_attr = None
+
+    change_entry_renders_inline = False
+
     def render_change_entry_html(self, info):
         render_item = super(BaseCaptionsField, self).render_change_entry_html
+        obj_map = getattr(self, self.obj_map_attr)
 
-        return '<ul>%s</ul>' % ''.join([
-            '<li>%s</li>' % render_item(caption)
-            for caption in six.itervalues(info)
-        ])
+        s = ['<table class="caption-changed">']
+
+        for id_str, caption in six.iteritems(info):
+            obj = obj_map[int(id_str)]
+
+            s.append(format_html(
+                '<tr>'
+                ' <th><a href="{url}">{filename}</a>:</th>'
+                ' <td>{caption}</td>'
+                '</tr>',
+                url=obj.get_absolute_url(),
+                filename=obj.filename,
+                caption=mark_safe(render_item(caption))))
+
+        s.append('</table>')
+
+        return ''.join(s)
 
 
 class BaseModelListEditableField(BaseCommaEditableField):
@@ -197,6 +218,8 @@ class BugsField(BuiltinFieldMixin, BaseCommaEditableField):
     field_id = 'bugs_closed'
     label = _('Bugs')
 
+    one_line_per_change_entry = False
+
     def load_value(self, review_request_details):
         return review_request_details.get_bug_list()
 
@@ -237,12 +260,25 @@ class DependsOnField(BuiltinFieldMixin, BaseModelListEditableField):
     model_name_attr = 'summary'
 
     def render_change_entry_item_html(self, info, item):
-        return self.render_item(ReviewRequest.objects.get(pk=item[2]))
+        item = ReviewRequest.objects.get(pk=item[2])
+
+        rendered_item = format_html(
+            '<a href="{url}">{id} - {summary}</a>',
+            url=item.get_absolute_url(),
+            id=item.pk,
+            summary=item.summary)
+
+        if item.status == ReviewRequest.SUBMITTED:
+            return '<s>%s</s>' % rendered_item
+        else:
+            return rendered_item
 
     def render_item(self, item):
-        rendered_item = (
-            '<a href="%s">%s</a>'
-            % (escape(item.get_absolute_url()), escape(item.display_id)))
+        rendered_item = format_html(
+            '<a href="{url}" title="{summary}">{id}</a>',
+            url=item.get_absolute_url(),
+            summary=item.summary,
+            id=item.display_id)
 
         if item.status == ReviewRequest.SUBMITTED:
             return '<s>%s</s>' % rendered_item
@@ -262,11 +298,13 @@ class BlocksField(BuiltinFieldMixin, BaseReviewRequestField):
         return len(blocks) > 0
 
     def render_value(self, blocks):
-        return ', '.join([
-            '<a href="%s">%s</a>' % (escape(item.get_absolute_url()),
-                                     escape(item.display_id))
-            for item in blocks
-        ])
+        return format_html_join(
+            ', ',
+            '<a href="{0}">{1}</a>',
+            [
+                (item.get_absolute_url(), item.display_id)
+                for item in blocks
+            ])
 
 
 class ChangeField(BuiltinFieldMixin, BaseReviewRequestField):
@@ -335,34 +373,116 @@ class DiffField(BuiltinLocalsFieldMixin, BaseReviewRequestField):
     """
     field_id = 'diff'
     label = _('Diff')
-    locals_vars = ['diffset_versions']
+    locals_vars = ['diffsets_by_id']
 
     can_record_change_entry = True
 
+    MAX_FILES_PREVIEW = 8
+
     def render_change_entry_html(self, info):
         added_diff_info = info['added'][0]
         review_request = self.review_request_details.get_review_request()
 
-        diff_revision = self.diffset_versions[added_diff_info[2]]
+        diffset = self.diffsets_by_id[added_diff_info[2]]
+        diff_revision = diffset.revision
         past_revision = diff_revision - 1
         diff_url = added_diff_info[1]
 
-        s = '<a href="%s">%s</a>' % (diff_url, added_diff_info[0])
-
-        if past_revision != 0:
+        s = []
+
+        # Fetch the total number of inserts/deletes. These will be shown
+        # alongside the diff revision.
+        counts = diffset.get_total_line_counts()
+        raw_insert_count = counts['raw_insert_count']
+        raw_delete_count = counts['raw_delete_count']
+
+        line_counts = []
+
+        if raw_insert_count > 0:
+            line_counts.append('<span class="insert-count">+%d</span>'
+                               % raw_insert_count)
+
+        if raw_delete_count > 0:
+            line_counts.append('<span class="delete-count">-%d</span>'
+                               % raw_delete_count)
+
+        # Display the label, URL, and line counts for the diff.
+        s.append(format_html(
+            '<p class="diff-changes">'
+            ' <a href="{url}">{label}</a>'
+            ' <span class="line-counts">({line_counts})</span>'
+            '</p>',
+            url=diff_url,
+            label=_('Revision %s') % diff_revision,
+            count=_('%d files') % diffset.file_count,
+            line_counts=mark_safe(' '.join(line_counts))))
+
+        if past_revision > 0:
+            # This is not the first diff revision. Include an interdiff link.
             interdiff_url = local_site_reverse('view-interdiff', args=[
                 review_request.display_id,
                 past_revision,
                 diff_revision,
             ])
 
-            s += ' - <a href="%s">%s</a>' % (interdiff_url, _('Show changes'))
-
-        return '\n'.join([
-            '<ul>',
-            ' <li>%s</li>' % _('added %s' % s),
-            '</ul>',
-        ])
+            s.append(format_html(
+                '<p><a href="{url}">{text}</a>',
+                url=interdiff_url,
+                text=_('Show changes')))
+
+        if diffset.file_count > 0:
+            # Begin displaying the list of files modified in this diff.
+            # It will be capped at a fixed number (MAX_FILES_PREVIEW).
+            s += [
+                '<div class="diff-index">',
+                ' <table>',
+            ]
+
+            # We want a sorted list of filediffs, but tagged with the order in
+            # which they come from the database, so that we can properly link
+            # to the respective files in the diff viewer.
+            files = get_sorted_filediffs(enumerate(diffset.files.all()),
+                                         key=lambda i: i[1])
+
+            for i, filediff in files[:self.MAX_FILES_PREVIEW]:
+                counts = filediff.get_line_counts()
+
+                data_attrs = [
+                    'data-%s="%s"' % (attr.replace('_', '-'), counts[attr])
+                    for attr in ('insert_count', 'delete_count',
+                                 'replace_count', 'total_line_count')
+                    if counts.get(attr) is not None
+                ]
+
+                s.append(format_html(
+                    '<tr {data_attrs}>'
+                    ' <td class="diff-file-icon"></td>'
+                    ' <td class="diff-file-info">'
+                    '  <a href="{url}">{filename}</a>'
+                    ' </td>'
+                    '</tr>',
+                    data_attrs=mark_safe(' '.join(data_attrs)),
+                    url=diff_url + '#%d' % i,
+                    filename=filediff.source_file))
+
+            num_remaining = diffset.file_count - self.MAX_FILES_PREVIEW
+
+            if num_remaining > 0:
+                # There are more files remaining than we've shown, so show
+                # the count.
+                s.append(format_html(
+                    '<tr>'
+                    ' <td></td>'
+                    ' <td class="diff-file-info">{text}</td>'
+                    '</tr>',
+                    text=_('%s more') % num_remaining))
+
+            s += [
+                ' </table>',
+                '</div>',
+            ]
+
+        return ''.join(s)
 
     def has_value_changed(self, old_value, new_value):
         # If there's a new diffset at all (in new_value), then it passes
@@ -406,9 +526,11 @@ class FileAttachmentCaptionsField(BaseCaptionsField):
     """
     field_id = 'file_captions'
     label = _('File Captions')
+    obj_map_attr = 'file_attachment_id_map'
+    locals_vars = [obj_map_attr]
 
 
-class FileAttachmentsField(BaseCommaEditableField):
+class FileAttachmentsField(BuiltinLocalsFieldMixin, BaseCommaEditableField):
     """Renders removed or added file attachments.
 
     This is not shown as an actual displayable field on the review request
@@ -418,6 +540,43 @@ class FileAttachmentsField(BaseCommaEditableField):
     """
     field_id = 'files'
     label = _('Files')
+    locals_vars = ['file_attachment_id_map']
+
+    thumbnail_template = 'reviews/parts/file_attachment_thumbnail.html'
+
+    def get_change_entry_sections_html(self, info):
+        sections = []
+
+        if 'removed' in info:
+            sections.append({
+                'title': _('Removed Files'),
+                'rendered_html': mark_safe(
+                    self.render_change_entry_html(info['removed'])),
+            })
+
+        if 'added' in info:
+            sections.append({
+                'title': _('Added Files'),
+                'rendered_html': mark_safe(
+                    self.render_change_entry_html(info['added'])),
+            })
+
+        return sections
+
+    def render_change_entry_html(self, values):
+        # Fetch the template ourselves only once and render it for each item,
+        # instead of calling render_to_string() in the loop, so we don't
+        # have to locate and parse/fetch from cache for every item.
+        template = get_template(self.thumbnail_template)
+        review_request = self.review_request_details.get_review_request()
+
+        return ''.join([
+            template.render(Context({
+                'file': self.file_attachment_id_map[pk],
+                'review_request': review_request,
+            }))
+            for caption, filename, pk in values
+        ])
 
 
 class ScreenshotCaptionsField(BaseCaptionsField):
@@ -430,6 +589,8 @@ class ScreenshotCaptionsField(BaseCaptionsField):
     """
     field_id = 'screenshot_captions'
     label = _('Screenshot Captions')
+    obj_map_attr = 'screenshot_id_map'
+    locals_vars = [obj_map_attr]
 
 
 class ScreenshotsField(BaseCommaEditableField):
diff --git a/reviewboard/reviews/fields.py b/reviewboard/reviews/fields.py
index 0b62bf3892dd2c9544671d8e09a4c546bb98f617..153286ecc41a82ba9c6380634bde1f1106f5c45f 100644
--- a/reviewboard/reviews/fields.py
+++ b/reviewboard/reviews/fields.py
@@ -4,10 +4,15 @@ import logging
 
 from django.utils import six
 from django.utils.datastructures import SortedDict
-from django.utils.html import escape
-from django.utils.translation import ugettext_lazy as _
+from django.utils.html import escape, strip_tags
+from django.utils.safestring import mark_safe
 
-from reviewboard.reviews.markdown_utils import markdown_escape
+from reviewboard.diffviewer.diffutils import get_line_changed_regions
+from reviewboard.diffviewer.myersdiff import MyersDiffer
+from reviewboard.diffviewer.templatetags.difftags import highlightregion
+from reviewboard.reviews.markdown_utils import (iter_markdown_lines,
+                                                markdown_escape,
+                                                render_markdown)
 
 
 _all_fields = {}
@@ -106,6 +111,7 @@ class BaseReviewRequestField(object):
     is_editable = False
     is_required = False
     default_css_classes = set()
+    change_entry_renders_inline = True
 
     can_record_change_entry = property(lambda self: self.is_editable)
 
@@ -140,6 +146,19 @@ class BaseReviewRequestField(object):
         """
         changedesc.record_field_change(self.field_id, old_value, new_value)
 
+    def get_change_entry_sections_html(self, info):
+        """Returns sections of change entries with titles and rendered HTML.
+
+        By default, this just returns a single section for the field, with
+        the field's title and rendered change HTML.
+
+        Subclasses can override this to provide more information.
+        """
+        return [{
+            'title': self.label,
+            'rendered_html': mark_safe(self.render_change_entry_html(info)),
+        }]
+
     def render_change_entry_html(self, info):
         """Renders a change entry to HTML.
 
@@ -154,24 +173,46 @@ class BaseReviewRequestField(object):
         Subclasses can override ``render_change_entry_value_html`` to
         change how the value itself will be rendered in the string.
         """
-        old_value_html = ''
-        new_value_html = ''
+        old_value = ''
+        new_value = ''
 
         if 'old' in info:
-            old_value_html = \
-                self.render_change_entry_value_html(info, info['old'][0])
+            old_value = info['old'][0]
 
         if 'new' in info:
-            new_value_html = \
-                self.render_change_entry_value_html(info, info['new'][0])
+            new_value = info['new'][0]
+
+        s = ['<table class="changed">']
+
+        if old_value:
+            s.append(self.render_change_entry_removed_value_html(
+                info, old_value))
+
+        if new_value:
+            s.append(self.render_change_entry_added_value_html(
+                info, new_value))
+
+        s.append('</table>')
 
-        return (
-            _('changed from <i>%(old_value)s</i> to <i>%(new_value)s</i>')
-            % {
-                'old_value': old_value_html,
-                'new_value': new_value_html,
-            }
-        )
+        return ''.join(s)
+
+    def render_change_entry_added_value_html(self, info, value):
+        value_html = self.render_change_entry_value_html(info, value)
+
+        if value_html:
+            return ('<tr class="new-value"><th class="marker">+</th>'
+                    '<td class="value">%s</td></tr>' % value_html)
+        else:
+            return ''
+
+    def render_change_entry_removed_value_html(self, info, value):
+        value_html = self.render_change_entry_value_html(info, value)
+
+        if value_html:
+            return ('<tr class="old-value"><th class="marker">-</th>'
+                    '<td class="value">%s</td></tr>' % value_html)
+        else:
+            return ''
 
     def render_change_entry_value_html(self, info, value):
         """Renders the value for a change description string to HTML.
@@ -278,6 +319,8 @@ class BaseCommaEditableField(BaseEditableField):
     default_css_classes = ['editable', 'comma-editable']
     order_matters = False
 
+    one_line_per_change_entry = True
+
     def has_value_changed(self, old_value, new_value):
         """Returns whether two values have changed.
 
@@ -319,31 +362,35 @@ class BaseCommaEditableField(BaseEditableField):
         coming from a field or any other form of user input must be
         properly escaped.
         """
-        s = ['<ul>']
+        s = ['<table class="changed">']
 
         if 'removed' in info:
-            old_value_html = \
-                self.render_change_entry_value_html(info, info['removed'])
+            values = info['removed']
 
-            if old_value_html:
-                s.append('<li>%s</li>' %
-                         _('removed %(values)s') % {
-                             'values': old_value_html,
-                         })
+            if self.one_line_per_change_entry:
+                s += [
+                    self.render_change_entry_removed_value_html(info, [value])
+                    for value in values
+                ]
+            else:
+                s.append(self.render_change_entry_removed_value_html(
+                    info, values))
 
         if 'added' in info:
-            new_value_html = \
-                self.render_change_entry_value_html(info, info['added'])
+            values = info['added']
 
-            if new_value_html:
-                s.append('<li>%s</li>' %
-                         _('added %(values)s') % {
-                             'values': new_value_html,
-                         })
+            if self.one_line_per_change_entry:
+                s += [
+                    self.render_change_entry_added_value_html(info, [value])
+                    for value in values
+                ]
+            else:
+                s.append(self.render_change_entry_added_value_html(
+                    info, values))
 
-        s.append('</ul>')
+        s.append('</table>')
 
-        return '\n'.join(s)
+        return ''.join(s)
 
     def render_change_entry_value_html(self, info, values):
         """Renders a list of items for change description HTML.
@@ -425,29 +472,95 @@ class BaseTextAreaField(BaseEditableField):
         return escape(text)
 
     def render_change_entry_html(self, info):
-        old_value_html = ''
-        new_value_html = ''
+        old_value = ''
+        new_value = ''
 
         if 'old' in info:
-            old_value_html = \
-                self.render_change_entry_value_html(info, info['old'][0])
+            old_value = info['old'][0]
 
         if 'new' in info:
-            new_value_html = \
-                self.render_change_entry_value_html(info, info['new'][0])
-
-        return (
-            '<p><label>%(changed_from_text)s</label></p>\n'
-            '<pre>%(old_value)s</pre>\n'
-            '<p><label>%(changed_to_text)s</label></p>\n'
-            '<pre>%(new_value)s</pre>\n'
-            % {
-                'changed_from_text': escape(_('Changed from:')),
-                'changed_to_text': escape(_('Changed to:')),
-                'old_value': old_value_html,
-                'new_value': new_value_html,
-            }
-        )
+            new_value = info['new'][0]
+
+        old_value = render_markdown(old_value)
+        new_value = render_markdown(new_value)
+        old_lines = list(iter_markdown_lines(old_value))
+        new_lines = list(iter_markdown_lines(new_value))
+
+        differ = MyersDiffer(old_lines, new_lines)
+
+        return ('<table class="diffed-text-area">%s</table>'
+                % ''.join(self._render_all_change_lines(differ, old_lines,
+                                                        new_lines)))
+
+    def _render_all_change_lines(self, differ, old_lines, new_lines):
+        for tag, i1, i2, j1, j2 in differ.get_opcodes():
+            if tag == 'equal':
+                lines = self._render_change_lines(differ, tag, None, None,
+                                                  i1, i2, old_lines)
+            elif tag == 'insert':
+                lines = self._render_change_lines(differ, tag, None, '+',
+                                                  j1, j2, new_lines)
+            elif tag == 'delete':
+                lines = self._render_change_lines(differ, tag, '-', None,
+                                                  i1, i2, old_lines)
+            elif tag == 'replace':
+                lines = self._render_change_replace_lines(differ, i1, i2,
+                                                          j1, j2, old_lines,
+                                                          new_lines)
+            else:
+                raise ValueError('Unexpected tag "%s"' % tag)
+
+            for line in lines:
+                yield line
+
+    def _render_change_lines(self, differ, tag, old_marker, new_marker,
+                             i1, i2, lines):
+        old_marker = old_marker or '&nbsp;'
+        new_marker = new_marker or '&nbsp;'
+
+        for i in range(i1, i2):
+            line = lines[i]
+
+            yield ('<tr class="%s">'
+                   ' <td class="marker">%s</td>'
+                   ' <td class="marker">%s</td>'
+                   ' <td class="line rich-text">%s</td>'
+                   '</tr>'
+                   % (tag, old_marker, new_marker, line))
+
+    def _render_change_replace_lines(self, differ, i1, i2, j1, j2,
+                                     old_lines, new_lines):
+        replace_new_lines = []
+
+        for i, j in zip(range(i1, i2), range(j1, j2)):
+            old_line = old_lines[i]
+            new_line = new_lines[j]
+
+            old_regions, new_regions = \
+                get_line_changed_regions(strip_tags(old_line),
+                                         strip_tags(new_line))
+
+            old_line = highlightregion(old_line, old_regions)
+            new_line = highlightregion(new_line, new_regions)
+
+            yield (
+                '<tr class="replace-old">'
+                ' <td class="marker">~</td>'
+                ' <td class="marker">&nbsp;</td>'
+                ' <td class="line rich-text">%s</td>'
+                '</tr>'
+                % old_line)
+
+            replace_new_lines.append(new_line)
+
+        for line in replace_new_lines:
+            yield (
+                '<tr class="replace-new">'
+                ' <td class="marker">&nbsp;</td>'
+                ' <td class="marker">~</td>'
+                ' <td class="line rich-text">%s</td>'
+                '</tr>'
+                % line)
 
 
 def _populate_defaults():
diff --git a/reviewboard/reviews/models/review_request.py b/reviewboard/reviews/models/review_request.py
index f6ddebc07be006bd02f14119486348ed5cc5b001..5c9abca6053eb60fd888b60c14a0f6c32a682c2d 100644
--- a/reviewboard/reviews/models/review_request.py
+++ b/reviewboard/reviews/models/review_request.py
@@ -3,7 +3,7 @@ from __future__ import unicode_literals
 from django.contrib.auth.models import User
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import models
-from django.db.models import Q
+from django.db.models import Count, Q
 from django.utils import six, timezone
 from django.utils.translation import ugettext_lazy as _
 from djblets.db.fields import CounterField, ModificationTimestampField
@@ -488,13 +488,20 @@ class ReviewRequest(BaseReviewRequestDetails):
             kwargs={'review_request_id': self.display_id})
 
     def get_diffsets(self):
-        """Returns a list of all diffsets on this review request."""
+        """Returns a list of all diffsets on this review request.
+
+        This will also fetch all associated FileDiffs, as well as a count
+        of the number of files (stored in DiffSet.file_count).
+        """
         if not self.repository_id:
             return []
 
         if not hasattr(self, '_diffsets'):
-            self._diffsets = list(DiffSet.objects.filter(
-                history__pk=self.diffset_history_id))
+            self._diffsets = list(
+                DiffSet.objects
+                    .filter(history__pk=self.diffset_history_id)
+                    .annotate(file_count=Count('files'))
+                    .prefetch_related('files'))
 
         return self._diffsets
 
diff --git a/reviewboard/reviews/views.py b/reviewboard/reviews/views.py
index 00c27cceaacd241163a61575fe31f93a11160efd..2fca96afe69cad0e43c3c3918d94b4ec5b7f6cac 100644
--- a/reviewboard/reviews/views.py
+++ b/reviewboard/reviews/views.py
@@ -48,7 +48,7 @@ from reviewboard.reviews.context import (comment_counts,
                                          has_comments_in_diffsets_excluding,
                                          interdiffs_with_comments,
                                          make_review_request_context)
-from reviewboard.reviews.fields import get_review_request_field
+from reviewboard.reviews.fields import get_review_request_fieldsets
 from reviewboard.reviews.models import (BaseComment, Comment,
                                         FileAttachmentComment,
                                         ReviewRequest, Review,
@@ -195,23 +195,6 @@ def build_diff_comment_fragments(
     return had_error, comment_entries
 
 
-fields_changed_name_map = {
-    'summary': _('Summary'),
-    'description': _('Description'),
-    'testing_done': _('Testing Done'),
-    'bugs_closed': _('Bugs Closed'),
-    'depends_on': _('Depends On'),
-    'branch': _('Branch'),
-    'target_groups': _('Reviewers (Groups)'),
-    'target_people': _('Reviewers (People)'),
-    'screenshots': _('Screenshots'),
-    'screenshot_captions': _('Screenshot Captions'),
-    'files': _('Uploaded Files'),
-    'file_captions': _('Uploaded File Captions'),
-    'diff': _('Diff'),
-}
-
-
 #####
 ##### View functions
 #####
@@ -405,12 +388,13 @@ def review_detail(request,
 
     draft = review_request.get_draft(request.user)
     review_request_details = draft or review_request
+
+    # Map diffset IDs to their object.
     diffsets = review_request.get_diffsets()
+    diffsets_by_id = {}
 
-    # Map diffset IDs to their revision ID for changedescs
-    diffset_versions = {}
     for diffset in diffsets:
-        diffset_versions[diffset.pk] = diffset.revision
+        diffsets_by_id[diffset.pk] = diffset
 
     # Find out if we can bail early. Generate an ETag for this.
     last_activity_time, updated_object = \
@@ -490,26 +474,36 @@ def review_detail(request,
 
     # Get all the file attachments and screenshots and build a couple maps,
     # so we can easily associate those objects in comments.
+    #
+    # Note that we're fetching inactive file attachments and screenshots.
+    # is because any file attachments/screenshots created after the initial
+    # creation of the review request that were later removed will still need
+    # to be rendered as an added file in a change box.
     file_attachments = []
+    inactive_file_attachments = []
+    screenshots = []
+    inactive_screenshots = []
 
-    for file_attachment in review_request_details.get_file_attachments():
-        file_attachment._comments = []
-        file_attachments.append(file_attachment)
+    for attachment in review_request_details.get_file_attachments():
+        attachment._comments = []
+        file_attachments.append(attachment)
 
-    screenshots = []
+    for attachment in review_request_details.get_inactive_file_attachments():
+        attachment._comments = []
+        inactive_file_attachments.append(attachment)
 
     for screenshot in review_request_details.get_screenshots():
         screenshot._comments = []
         screenshots.append(screenshot)
 
+    for screenshot in review_request_details.get_inactive_screenshots():
+        screenshot._comments = []
+        inactive_screenshots.append(screenshot)
+
     file_attachment_id_map = _build_id_map(file_attachments)
+    file_attachment_id_map.update(_build_id_map(inactive_file_attachments))
     screenshot_id_map = _build_id_map(screenshots)
-
-    # There will be non-visible (generally deleted) file attachments and
-    # screenshots we'll need to reference. to save on queries, we'll only
-    # get these when we first encounter one not in the above maps.
-    has_inactive_file_attachments = False
-    has_inactive_screenshots = False
+    screenshot_id_map.update(_build_id_map(inactive_screenshots))
 
     issues = {
         'total': 0,
@@ -564,35 +558,11 @@ def review_detail(request,
             # If the comment has an associated object that we've already
             # queried, attach it to prevent a future lookup.
             if isinstance(comment, ScreenshotComment):
-                if (comment.screenshot_id not in screenshot_id_map and
-                    not has_inactive_screenshots):
-                    inactive_screenshots = \
-                        list(review_request_details.get_inactive_screenshots())
-
-                    for screenshot in inactive_screenshots:
-                        screenshot._comments = []
-
-                    screenshot_id_map.update(
-                        _build_id_map(inactive_screenshots))
-                    has_inactive_screenshots = True
-
                 if comment.screenshot_id in screenshot_id_map:
                     screenshot = screenshot_id_map[comment.screenshot_id]
                     comment.screenshot = screenshot
                     screenshot._comments.append(comment)
             elif isinstance(comment, FileAttachmentComment):
-                if (comment.file_attachment_id not in file_attachment_id_map
-                    and not has_inactive_file_attachments):
-                    inactive_file_attachments = list(
-                        review_request_details.get_inactive_file_attachments())
-
-                    for file_attachment in inactive_file_attachments:
-                        file_attachment._comments = []
-
-                    file_attachment_id_map.update(
-                        _build_id_map(inactive_file_attachments))
-                    has_inactive_file_attachments = True
-
                 if comment.file_attachment_id in file_attachment_id_map:
                     file_attachment = \
                         file_attachment_id_map[comment.file_attachment_id]
@@ -641,54 +611,67 @@ def review_detail(request,
     # Sort all the reviews and ChangeDescriptions into a single list, for
     # display.
     for changedesc in changedescs:
-        fields_changed = []
-
-        for name, info in six.iteritems(changedesc.fields_changed):
-            title = fields_changed_name_map.get(name, name)
-            field_cls = get_review_request_field(name)
-            change_info = {}
+        # Process the list of fields, in order by fieldset. These will be
+        # put into groups composed of inline vs. full-width field values,
+        # for render into the box.
+        fields_changed_groups = []
+        cur_field_changed_group = None
+
+        fieldsets = get_review_request_fieldsets(
+            include_main=True,
+            include_change_entries_only=True)
+
+        for fieldset in fieldsets:
+            for field_cls in fieldset.field_classes:
+                field_id = field_cls.field_id
+
+                if field_id not in changedesc.fields_changed:
+                    continue
+
+                inline = field_cls.change_entry_renders_inline
+
+                if (not cur_field_changed_group or
+                    cur_field_changed_group['inline'] != inline):
+                    # Begin a new group of fields.
+                    cur_field_changed_group = {
+                        'inline': inline,
+                        'fields': [],
+                    }
+                    fields_changed_groups.append(cur_field_changed_group)
 
-            if field_cls:
                 if hasattr(field_cls, 'locals_vars'):
                     field = field_cls(review_request, locals_vars=locals())
                 else:
                     field = field_cls(review_request)
 
-                title = field.label
-                change_info['rendered_html'] = \
-                    mark_safe(field.render_change_entry_html(info))
-            elif name == 'status':
-                # Make status human readable.
-                if 'old' in info:
-                    change_info['old_status'] = \
-                        status_to_string(info['old'][0])
-
-                if 'new' in info:
-                    change_info['new_status'] = \
-                        status_to_string(info['new'][0])
-            else:
-                # No clue what this is. Bail.
-                continue
+                cur_field_changed_group['fields'] += \
+                    field.get_change_entry_sections_html(
+                        changedesc.fields_changed[field_id])
 
-            change_info.update({
-                'name': name,
-                'title': title,
-            })
-            fields_changed.append(change_info)
+        # See if the review request has had a status change.
+        status_change = changedesc.fields_changed.get('status')
 
-        # Expand the latest review change
-        state = ''
+        if status_change:
+            assert 'new' in status_change
+            new_status = status_to_string(status_change['new'][0])
+        else:
+            new_status = None
 
         # Mark as collapsed if the change is older than a newer change
         if latest_timestamp and changedesc.timestamp < latest_timestamp:
             state = 'collapsed'
+            collapsed = True
+        else:
+            state = ''
+            collapsed = False
 
         entries.append({
-            'changeinfo': fields_changed,
+            'new_status': new_status,
+            'fields_changed_groups': fields_changed_groups,
             'changedesc': changedesc,
             'timestamp': changedesc.timestamp,
             'class': state,
-            'collapsed': state == 'collapsed',
+            'collapsed': collapsed,
         })
 
     entries.sort(key=lambda item: item['timestamp'])
diff --git a/reviewboard/static/rb/css/dashboard.less b/reviewboard/static/rb/css/dashboard.less
index b21249b7ca4b81eade56484fa233095c29b8b9a1..cb51f2fef6b5f559d9e336f6db0a142ebe7d9709 100644
--- a/reviewboard/static/rb/css/dashboard.less
+++ b/reviewboard/static/rb/css/dashboard.less
@@ -69,11 +69,11 @@
     font-size: 90%;
 
     &.delete {
-      color: darkred;
+      color: @diff-delete-line-count-color;
     }
 
     &.insert {
-      color: darkgreen;
+      color: @diff-insert-line-count-color;
     }
   }
 }
diff --git a/reviewboard/static/rb/css/defs.less b/reviewboard/static/rb/css/defs.less
index 2882c374a8b0770531b6b9b3c3b305693503e90e..7f21b2bcb317eacd26fd6cc1deb11b06a96acdbc 100644
--- a/reviewboard/static/rb/css/defs.less
+++ b/reviewboard/static/rb/css/defs.less
@@ -165,6 +165,10 @@
 @diff-replace-selected-color: darken(@diff-replace-linenum-color, 10%);
 @diff-replace-dot-color: darken(@diff-replace-color, 50%);
 
+// Diff line counts
+@diff-insert-line-count-color: darkgreen;
+@diff-delete-line-count-color: darkred;
+
 // Revisions selector
 @revisions-border-color: #999999;
 @revisions-hover-color: #E7E0A2;
diff --git a/reviewboard/static/rb/css/diffviewer.less b/reviewboard/static/rb/css/diffviewer.less
index 4d1693f3cb330a6fe64b3278d368c1cc05bf0d7a..a46c8e3c7e4eb15f3d3182e609dab1da28ab8fb4 100644
--- a/reviewboard/static/rb/css/diffviewer.less
+++ b/reviewboard/static/rb/css/diffviewer.less
@@ -561,7 +561,8 @@
   }
 }
 
-#diff_index {
+#diff_index,
+.diff-index {
   border: 1px #BBB solid;
   margin: 1em 0;
 
diff --git a/reviewboard/static/rb/css/reviews.less b/reviewboard/static/rb/css/reviews.less
index dbbf45a4993d865d00dd9f3ea6f2b2c9155577d4..5536b97ef3e97badff0e260962db19528298f7cb 100644
--- a/reviewboard/static/rb/css/reviews.less
+++ b/reviewboard/static/rb/css/reviews.less
@@ -770,33 +770,332 @@
   }
 
   .body {
-    background-color: #FAFAFA;
-    border: 1px #AAAAAA solid;
     clear: both;
-    margin: 5px;
-    padding: 10px;
+    padding: @box-padding;
 
-    ul {
-      padding-left: 2em;
-      margin-bottom: 10px;
+    &>ul {
+      list-style: none;
+      margin: 1em 0 1em 0;
+      padding: 0;
 
-      li {
-        label {
-          color: #575012;
-          font-weight: bold;
+      &>li {
+        margin: 3em 0 0 0;
+
+        &:first-child {
+          margin-top: 0;
         }
+      }
+    }
 
-        pre {
-          border: @textarea-border;
-          padding: @textarea-editor-padding;
-          font-size: 9pt;
+    a {
+      color: blue;
+      text-decoration: none;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+
+    /* Field labels */
+    h3 {
+      color: @review-request-label-color;
+      font-size: 110%;
+      margin: 1em 0 0.5em 0;
+      padding: 0;
+
+      &.status {
+        /* Add some separation between this and the other fields. */
+        margin-bottom: 2em;
+
+        &:last-child {
+          /*
+           * If it's the last child, we don't want that separation, or it'll
+           * have an odd amount of extra space.
+           */
+          margin-bottom: 0.5em;
+        }
+
+        .value {
+          color: black;
+          font-weight: normal;
         }
       }
     }
 
+    /* Used in the textarea field diffs and the Change Summary section. */
     pre {
+      background-color: #FFFFFF;
+      border: @textarea-border;
+      padding: @textarea-editor-padding;
+      font-size: 9pt;
       .pre-wrap;
     }
+
+    /*
+     * File attachment/screenshot change lists.
+     *
+     * These differ from the other field change lists in that they have
+     * mini-section labels underneath for each file.
+     */
+    .caption-changed {
+      margin: 2em 0 0 2em;
+      padding: 0;
+
+      a {
+        /* Treat the link like a field. */
+        font-weight: bold;
+      }
+
+      td, th {
+        vertical-align: top;
+      }
+
+      td {
+        padding-left: 1em;
+      }
+
+      th {
+        text-align: right;
+      }
+    }
+
+    /* Displays changes for field values, with "-" and "+" markers. */
+    .changed {
+      border: @textarea-border;
+      border-collapse: collapse;
+      display: inline-block;
+      margin: 0;
+      padding: 0;
+      vertical-align: top;
+
+      a {
+        color: blue;
+        text-decoration: none;
+
+        &:first-child {
+          margin-left: 0;
+        }
+
+        &:hover {
+          text-decoration: underline;
+        }
+      }
+
+      td, th {
+        font-family: @textarea-font-family;
+        padding: 0.3em 0.6em;
+      }
+
+      td {
+        background: #FEFEFE;
+      }
+
+      .new-value .marker {
+        background: @diff-insert-linenum-color;
+      }
+
+      .old-value .marker {
+        background: @diff-delete-linenum-color;
+      }
+    }
+
+    /* The "Change Summary" section. */
+    .changedesc-text {
+      margin-bottom: 2em;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+
+    /* The diff for text areas. */
+    .diffed-text-area {
+      background: white;
+      border: @textarea-border;
+      border-collapse: collapse;
+      padding: 0;
+      width: 100%;
+
+      a {
+        text-decoration: underline;
+      }
+
+      pre {
+        background: none;
+        border: 0;
+        margin: 0;
+        padding: 0;
+        .pre-wrap;
+      }
+
+      td {
+        font-family: @textarea-font-family;
+        padding: 2px 4px;
+        vertical-align: top;
+        .pre-wrap;
+      }
+
+      .delete {
+        .line {
+          background: @diff-delete-color;
+        }
+
+        /* The '-' or '+' marker. */
+        .marker {
+          color: #990000;
+          background: @diff-delete-linenum-color;
+        }
+      }
+
+      .insert {
+        .line {
+          background: @diff-insert-color;
+        }
+
+        /* The '-' or '+' marker. */
+        .marker {
+          background: @diff-insert-linenum-color;
+        }
+      }
+
+      .line {
+        width: 100%;
+
+        /*
+         * Make sure any images in the Markdown-rendered text are kept
+         * small, but with the correct aspect ratio.
+         */
+        img {
+          width: auto;
+          max-height: 100px;
+        }
+      }
+
+      /* The '-' or '+' marker. */
+      .marker {
+        background: #F9F9F9;
+        font-family: @textarea-font-family;
+        font-size: @textarea-font-size;
+        font-weight: bold;
+        text-align: center;
+      }
+
+      .replace-new {
+        .line {
+          background: @diff-insert-color;
+
+          .hl {
+            background: desaturate(darken(@diff-insert-color, 15%), 30%);
+          }
+        }
+
+        /* The '-' or '+' marker. */
+        .marker {
+          background: @diff-insert-linenum-color;
+        }
+      }
+
+      .replace-old {
+        .line {
+          background: @diff-delete-color;
+
+          .hl {
+            background: desaturate(darken(@diff-delete-color, 10%), 20%);
+          }
+        }
+
+        /* The '-' or '+' marker. */
+        .marker {
+          background: @diff-delete-linenum-color;
+        }
+      }
+    }
+
+    /* Styling for the "Diff" field updates. */
+    .diff-changes {
+      .line-counts {
+        margin-left: 0.5em;
+
+        .delete-count {
+          color: @diff-delete-line-count-color;
+        }
+
+        .insert-count {
+          color: @diff-insert-line-count-color;
+        }
+      }
+    }
+
+    /* Styles for the file listing for the "Diff" field. */
+    .diff-index {
+      margin-bottom: 0;
+
+      .diff-file-icon {
+        min-width: 20px;
+        min-height: 20px;
+      }
+
+      .diff-file-info {
+        /* Compensate for the lack of diff-chunks-cell column. */
+        width: 100%;
+      }
+    }
+
+    .primary-fields {
+      h3:first-child {
+        margin-top: 2em;
+      }
+    }
+
+    /*
+     * Secondary fields are in more of a Field: Value display, with the
+     * fields lining up.
+     */
+    .secondary-fields {
+      width: 100%;
+
+      h3 {
+        margin: 2px 1em 0 0;
+        padding: 0;
+        text-align: right;
+      }
+
+      &>tbody>tr {
+        &>td,
+        &>th {
+          padding: 0.5em 0;
+          vertical-align: top;
+          white-space: nowrap;
+        }
+
+        &>td {
+          /*
+           * Between this and the white-space: nowrap above, the field labels
+           * will end up taking the minimum size without wrapping, and the
+           * rest of the width will be given to the value.
+           */
+          width: 100%;
+
+          &>p {
+            /*
+             * If the field value is rendered inside a <p>, make sure it
+             * aligns with the field label.
+             *
+             * Note that the label has a larger font than this, so we can't
+             * reuse the same pixel value. We have to bump it.
+             */
+            &:first-child {
+              margin-top: 3px;
+            }
+
+            /* Don't take up more room than we need in the last <p>. */
+            &:last-child {
+              margin-bottom: 0;
+            }
+          }
+        }
+      }
+    }
   }
 
   .box-inner {
@@ -947,10 +1246,12 @@
       a {
         border-left: 1px #DDD solid;
         border-bottom: 1px #DDD solid;
+        color: black;
         padding: 6px 8px;
 
         &:hover {
           background: #EEEEEE;
+          text-decoration: none;
         }
       }
     }
diff --git a/reviewboard/static/rb/js/pages/views/reviewRequestPageView.js b/reviewboard/static/rb/js/pages/views/reviewRequestPageView.js
index 8f7505a5c173af1c3433e89f4a013fb885fa48bc..a8a39132151583d562ad941efbe0a78f64fec1cf 100644
--- a/reviewboard/static/rb/js/pages/views/reviewRequestPageView.js
+++ b/reviewboard/static/rb/js/pages/views/reviewRequestPageView.js
@@ -14,6 +14,7 @@ RB.ReviewRequestPageView = RB.ReviewablePageView.extend({
         this._reviewBoxListView = new RB.ReviewBoxListView({
             el: $('#content'),
             pageEditState: this.reviewRequestEditor,
+            reviewRequestEditorView: this.reviewRequestEditorView,
             reviewRequest: this.reviewRequest
         });
 
diff --git a/reviewboard/static/rb/js/views/changeBoxView.js b/reviewboard/static/rb/js/views/changeBoxView.js
new file mode 100644
index 0000000000000000000000000000000000000000..1dcf0a3966d265fd80f25505f0ffb2818aee75b5
--- /dev/null
+++ b/reviewboard/static/rb/js/views/changeBoxView.js
@@ -0,0 +1,54 @@
+/*
+ * Displays the "Review request changed" box on the review request page.
+ *
+ * This handles any rendering needed for special contents in the box,
+ * such as the diff complexity icons and the file attachment thumbnails.
+ */
+RB.ChangeBoxView = RB.CollapsableBoxView.extend({
+    initialize: function(options) {
+        this.reviewRequest = options.reviewRequest;
+        this.reviewRequestEditorView = options.reviewRequestEditorView;
+    },
+
+    render: function() {
+        var $text = this.$('.changedesc-text');
+
+        _.super(this).render.call(this);
+
+        RB.formatText($text, $text.text());
+
+        _.each(this.$('.diff-index tr'), function(rowEl) {
+            var $row = $(rowEl),
+                iconView = new RB.DiffComplexityIconView({
+                    numInserts: $row.data('insert-count'),
+                    numDeletes: $row.data('delete-count'),
+                    numReplaces: $row.data('replace-count'),
+                    totalLines: $row.data('total-line-count')
+                })
+
+            iconView.$el.appendTo($row.find('.diff-file-icon'));
+            iconView.render();
+        });
+
+        _.each(this.$('.file-container'), function(thumbnailEl) {
+            var $thumbnail = $(thumbnailEl),
+                $caption = $thumbnail.find('.file-caption .edit'),
+                model = this.reviewRequest.draft.createFileAttachment({
+                    id: $thumbnail.data('file-id')
+                });
+
+            if (!$caption.hasClass('empty-caption')) {
+                model.set('caption', $caption.text());
+            }
+
+            this.reviewRequestEditorView.buildFileAttachmentThumbnail(
+                model, null,
+                {
+                    $el: $thumbnail,
+                    canEdit: false
+                });
+        }, this);
+
+        return this;
+    }
+});
diff --git a/reviewboard/static/rb/js/views/fileAttachmentThumbnailView.js b/reviewboard/static/rb/js/views/fileAttachmentThumbnailView.js
index 594a3054878c289e40d48d5fbb674e2fb48bf13a..b0a89c81e15aef4a469f49d01e19bb8d02717890 100644
--- a/reviewboard/static/rb/js/views/fileAttachmentThumbnailView.js
+++ b/reviewboard/static/rb/js/views/fileAttachmentThumbnailView.js
@@ -84,7 +84,9 @@ RB.FileAttachmentThumbnail = Backbone.View.extend({
         '<% } %>'
     ].join('')),
 
-    initialize: function() {
+    initialize: function(options) {
+        this.options = options;
+
         this._draftComment = null;
         this._comments = [];
         this._commentsProcessed = false;
@@ -150,36 +152,38 @@ RB.FileAttachmentThumbnail = Backbone.View.extend({
             this._renderThumbnail();
         }
 
-        this._$caption
-            .inlineEditor({
-                editIconClass: 'rb-icon rb-icon-edit',
-                showButtons: false
-            })
-            .on({
-                beginEdit: function() {
-                     if ($(this).hasClass('empty-caption')) {
-                         $(this).inlineEditor('field').val('');
-                     }
-
-                    self.trigger('beginEdit');
-                },
-                cancel: function() {
-                    self.trigger('endEdit');
-                },
-                complete: function(e, value) {
-                    /*
-                     * We want to set the caption after ready() finishes,
-                     * it case it loads state and overwrites.
-                     */
-                    self.model.ready({
-                        ready: function() {
-                            self.model.set('caption', value);
-                            self.trigger('endEdit');
-                            self.model.save();
-                        }
-                    });
-                }
-            });
+        if (this.options.canEdit !== false) {
+            this._$caption
+                .inlineEditor({
+                    editIconClass: 'rb-icon rb-icon-edit',
+                    showButtons: false
+                })
+                .on({
+                    beginEdit: function() {
+                         if ($(this).hasClass('empty-caption')) {
+                             $(this).inlineEditor('field').val('');
+                         }
+
+                        self.trigger('beginEdit');
+                    },
+                    cancel: function() {
+                        self.trigger('endEdit');
+                    },
+                    complete: function(e, value) {
+                        /*
+                         * We want to set the caption after ready() finishes,
+                         * it case it loads state and overwrites.
+                         */
+                        self.model.ready({
+                            ready: function() {
+                                self.model.set('caption', value);
+                                self.trigger('endEdit');
+                                self.model.save();
+                            }
+                        });
+                    }
+                });
+        }
 
         return this;
     },
diff --git a/reviewboard/static/rb/js/views/reviewBoxListView.js b/reviewboard/static/rb/js/views/reviewBoxListView.js
index 15a08c9d3396ba77c8e31378222437f57bb89b31..f5691eb661e60c438d332aa0102382801baedcf2 100644
--- a/reviewboard/static/rb/js/views/reviewBoxListView.js
+++ b/reviewboard/static/rb/js/views/reviewBoxListView.js
@@ -16,7 +16,9 @@ RB.ReviewBoxListView = Backbone.View.extend({
     /*
      * Initializes the list.
      */
-    initialize: function() {
+    initialize: function(options) {
+        this.options = options;
+
         this.diffFragmentQueue = new RB.DiffFragmentQueueView({
             reviewRequestPath: this.options.reviewRequest.get('reviewURL'),
             containerPrefix: 'comment_container',
@@ -64,13 +66,11 @@ RB.ReviewBoxListView = Backbone.View.extend({
         }, this);
 
         _.each(this.$el.children('.changedesc'), function(changeBoxEl) {
-            var $changebox = $(changeBoxEl),
-                $text = $changebox.find('.changedesc-text'),
-                box = new RB.CollapsableBoxView({
-                    el: $changebox
-                });
-
-            RB.formatText($text, $text.text());
+            var box = new RB.ChangeBoxView({
+                el: changeBoxEl,
+                reviewRequest: reviewRequest,
+                reviewRequestEditorView: this.options.reviewRequestEditorView
+            });
 
             box.render();
 
diff --git a/reviewboard/static/rb/js/views/reviewRequestEditorView.js b/reviewboard/static/rb/js/views/reviewRequestEditorView.js
index 3a367596be82de2621fc8736106ccf70346004bd..277d3fe7d6d3a9d17ad098f0f0bcd0e4971737df 100644
--- a/reviewboard/static/rb/js/views/reviewRequestEditorView.js
+++ b/reviewboard/static/rb/js/views/reviewRequestEditorView.js
@@ -554,7 +554,7 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
             }
 
             this.model.fileAttachments.on('add',
-                                          this._buildFileAttachmentThumbnail,
+                                          this.buildFileAttachmentThumbnail,
                                           this);
 
             /*
@@ -805,16 +805,18 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
      * attachment (through drag-and-drop or Add File), or after importing
      * from the rendered page.
      */
-    _buildFileAttachmentThumbnail: function(fileAttachment, collection,
-                                            options) {
+    buildFileAttachmentThumbnail: function(fileAttachment, collection,
+                                           options) {
         var fileAttachmentComments = this.model.get('fileAttachmentComments'),
-            $thumbnail = options ? options.$el : undefined,
+            options = options || {},
+            $thumbnail = options.$el,
             view = new RB.FileAttachmentThumbnail({
                 el: $thumbnail,
                 model: fileAttachment,
                 comments: fileAttachmentComments[fileAttachment.id],
                 renderThumbnail: ($thumbnail === undefined),
-                reviewRequest: this.model.get('reviewRequest')
+                reviewRequest: this.model.get('reviewRequest'),
+                canEdit: (options.canEdit !== false)
             });
 
         view.render();
diff --git a/reviewboard/staticbundles.py b/reviewboard/staticbundles.py
index cda8a3e2e477ad587359796242a10b9dc539dc94..5e575648068897a5f6e3497b8b922c72cfc0d4df 100644
--- a/reviewboard/staticbundles.py
+++ b/reviewboard/staticbundles.py
@@ -195,6 +195,7 @@ PIPELINE_JS = dict({
             'rb/js/views/issueSummaryTableView.js',
             'rb/js/views/markdownEditorView.js',
             'rb/js/views/regionCommentBlockView.js',
+            'rb/js/views/changeBoxView.js',
             'rb/js/views/reviewBoxListView.js',
             'rb/js/views/reviewBoxView.js',
             'rb/js/views/reviewDialogView.js',
diff --git a/reviewboard/templates/reviews/boxes/change.html b/reviewboard/templates/reviews/boxes/change.html
index 882bcdd4ea45e10f351d11b179f117e9c433279a..371149c947827ab3743040021541dd29b38180a8 100644
--- a/reviewboard/templates/reviews/boxes/change.html
+++ b/reviewboard/templates/reviews/boxes/change.html
@@ -11,23 +11,54 @@
    <div class="posted_time">{% localtime on %}{% blocktrans with entry.changedesc.timestamp as timestamp and entry.changedesc.timestamp|date:"c" as timestamp_raw %}Updated <time class="timesince" datetime="{{timestamp_raw}}">{{timestamp}}</time> ({{timestamp}}){% endblocktrans %}{% endlocaltime %}</div>
   </div>
   <div class="body">
-   <ul>
-{%  for fieldinfo in entry.changeinfo %}
-    <li><label>{{fieldinfo.title}}</label>
-{%   if fieldinfo.rendered_html %}
-     {{fieldinfo.rendered_html}}
-{%   elif fieldinfo.name == "status" %}
-{%    blocktrans with fieldinfo.old_status as old_status and fieldinfo.new_status as new_status %}
-     changed from <i>{{old_status}}</i> to <i>{{new_status}}</i>
-{%    endblocktrans %}
+{%  if entry.new_status %}
+   <h3 class="status">
+    {% trans "Status:" %}
+    <span class="value">
+{%   if entry.new_status == 'submitted' %}
+    {% trans "Closed (submitted)" %}
+{%   elif entry.new_status == 'discarded' %}
+    {% trans "Discarded" %}
+{%   elif entry.new_status == 'pending' %}
+    {% trans "Re-opened" %}
 {%   endif %}
-    </li>
-{%  endfor %}
-   </ul>
+    </span>
+   </h3>
+{%  endif %}
+
 {%  if entry.changedesc.text %}
-   <label>{% trans "Description:" %}</label>
+   <h3>{% trans "Change Summary:" %}</h3>
    <pre class="changedesc-text" data-rich-text="true">{{entry.changedesc.text|markdown_escape:entry.changedesc.rich_text}}</pre>
 {%  endif %}
+
+{%  for group in entry.fields_changed_groups %}
+{%   if group.inline %}
+   <table class="secondary-fields">
+{%  for fieldinfo in group.fields %}
+{%   if fieldinfo.rendered_html %}
+    <tr>
+     <th><h3>{{fieldinfo.title}}:</h3></th>
+     <td>
+      {{fieldinfo.rendered_html}}
+     </td>
+    </tr>
+{%   endif %}
+{%  endfor %}
+   </table>
+{%   else %}
+   <ul class="primary-fields">
+{%    for fieldinfo in group.fields %}
+{%     if fieldinfo.rendered_html %}
+    <li class="clearfix">
+     <h3>{{fieldinfo.title}}:</h3>
+     {{fieldinfo.rendered_html}}
+    </li>
+{%     endif %}
+{%    endfor %}
+   </ul>
+{%   endif %}
+{%  endfor %}
+
   </div>
  </div>
 {% endbox %}
diff --git a/reviewboard/templates/reviews/parts/file_attachment_thumbnail.html b/reviewboard/templates/reviews/parts/file_attachment_thumbnail.html
new file mode 100644
index 0000000000000000000000000000000000000000..eec5de66689fcdb8770298c093846dcfd3db71e6
--- /dev/null
+++ b/reviewboard/templates/reviews/parts/file_attachment_thumbnail.html
@@ -0,0 +1,45 @@
+{% load djblets_utils i18n %}
+<div class="file-container" data-file-id="{{file.id}}">
+ <div class="file">
+  <ul class="actions">
+{%   if file.review_ui %}
+   <li class="{% if file.review_ui.allow_inline %}file-review-inline %}{% else %}file-review{% endif %}"><a href="{% url 'file-attachment' review_request.display_id file.pk %}">{% trans "Review" %}</a></li>
+{%   else %}
+   <li class="file-add-comment"><a href="#">{% trans "New Comment" %}</a></li>
+{%   endif %}
+{%   if request.user.pk == review_request.submitter_id or perms.reviews.delete_file %}
+   <li class="delete">
+    <a href="#" alt="{% trans 'Delete this file' %}" title="{% trans 'Delete this file' %}">
+     <span class="ui-icon ui-icon-trash"></span>
+    </a>
+   </li>
+{%   endif %}
+  </ul>
+  <div class="file-header">
+   <a href="{{file.get_absolute_url}}">
+    <img class="icon" src="{{file.icon_url}}" />
+    <span class="filename">{{file.filename}}</span>
+   </a>
+  </div>
+  <div class="file-thumbnail-container">
+{%  if file.review_ui %}
+   <a href="{% url 'file-attachment' review_request.display_id file.pk %}" class="file-thumbnail-overlay" alt="{% trans 'Click to review' %}" title="{% trans 'Click to review' %}"> </a>
+   {{file.thumbnail}}
+{%  else %}
+   {{file.thumbnail}}
+{%  endif %}
+   {% if file.review_ui %}</a>{% endif %}
+  </div>
+  <div class="file-caption-container">
+   <div class="file-caption {% if request.user.pk == review_request.submitter_id or perms.reviews.delete_file %}can-edit{% endif %}">
+{%  definevar "caption" %}{% if draft %}{{file.draft_caption}}{% else %}{{file.caption}}{% endif %}{% enddefinevar %}
+{%  definevar "file_attachment_url" %}{% if file.review_ui %}{% url "file-attachment" review_request.display_id file.pk %}{% else %}{{file.get_absolute_url}}{% endif %}{% enddefinevar %}
+{%  if caption %}
+    <a href="{{file_attachment_url}}" class="edit">{{caption}}</a>
+{%  else %}
+    <a href="{{file_attachment_url}}" class="edit empty-caption">{% trans "No caption" %}</a>
+{%  endif %}
+   </div>
+  </div>
+ </div>
+</div>
diff --git a/reviewboard/templates/reviews/review_request_box.html b/reviewboard/templates/reviews/review_request_box.html
index 1c3e78abae81f422d999a146d455c0bf89e1264d..ed89085c683dc527ef494a6b61b4a5feb51cc286 100644
--- a/reviewboard/templates/reviews/review_request_box.html
+++ b/reviewboard/templates/reviews/review_request_box.html
@@ -96,52 +96,7 @@
    <h3>{% trans "Files" %}</h3>
    <div id="file-list">
 {% for file in file_attachments %}
-    <div class="file-container" data-file-id="{{file.id}}">
-     <div class="file">
-      <ul class="actions">
-{%  if request.user.is_authenticated %}
-{%   if file.review_ui %}
-       <li class="{% if file.review_ui.allow_inline %}file-review-inline %}{% else %}file-review{% endif %}"><a href="{% url 'file-attachment' review_request.display_id file.pk %}">{% trans "Review" %}</a></li>
-{%   else %}
-       <li class="file-add-comment"><a href="#">{% trans "New Comment" %}</a></li>
-{%   endif %}
-{%   if request.user.pk == review_request.submitter_id or perms.reviews.delete_file %}
-       <li class="delete">
-        <a href="#" alt="{% trans 'Delete this file' %}" title="{% trans 'Delete this file' %}">
-         <span class="ui-icon ui-icon-trash"></span>
-        </a>
-       </li>
-{%   endif %}
-{%  endif %}
-      </ul>
-      <div class="file-header">
-       <a href="{{file.get_absolute_url}}">
-        <img class="icon" src="{{file.icon_url}}" />
-        <span class="filename">{{file.filename}}</span>
-       </a>
-      </div>
-      <div class="file-thumbnail-container">
-{%  if file.review_ui %}
-       <a href="{% url 'file-attachment' review_request.display_id file.pk %}" class="file-thumbnail-overlay" alt="{% trans 'Click to review' %}" title="{% trans 'Click to review' %}"> </a>
-       {{file.thumbnail}}
-{%  else %}
-       {{file.thumbnail}}
-{%  endif %}
-       {% if file.review_ui %}</a>{% endif %}
-      </div>
-      <div class="file-caption-container">
-       <div class="file-caption {% if request.user.pk == review_request.submitter_id or perms.reviews.delete_file %}can-edit{% endif %}">
-{%  definevar "caption" %}{% if draft %}{{file.draft_caption}}{% else %}{{file.caption}}{% endif %}{% enddefinevar %}
-{%  definevar "file_attachment_url" %}{% if file.review_ui %}{% url "file-attachment" review_request.display_id file.pk %}{% else %}{{file.get_absolute_url}}{% endif %}{% enddefinevar %}
-{%  if caption %}
-        <a href="{{file_attachment_url}}" class="edit">{{caption}}</a>
-{%  else %}
-        <a href="{{file_attachment_url}}" class="edit empty-caption">{% trans "No caption" %}</a>
-{%  endif %}
-       </div>
-      </div>
-     </div>
-    </div>
+{%  include "reviews/parts/file_attachment_thumbnail.html" %}
 {% endfor %}
    <br clear="both" />
   </div>
