diff --git a/include/class.thread.php b/include/class.thread.php
index 753b6d803744fdb7ef9d103d672c1bf73b073846..6018045eac2164f81b448ebf9e848c302636c7db 100644
--- a/include/class.thread.php
+++ b/include/class.thread.php
@@ -212,7 +212,12 @@ class Thread extends VerySimpleModel {
         return true;
     }
     // Render thread
-    function render($type=false, $mode=self::MODE_STAFF) {
+    function render($type=false, $options=array()) {
+
+        $mode = $options['mode'] ?: self::MODE_STAFF;
+
+        // Register thread actions prior to rendering the thread.
+        include_once INCLUDE_DIR . 'class.thread_actions.php';
 
         $entries = $this->getEntries();
         if ($type && is_array($type))
diff --git a/include/client/templates/thread-entries.tmpl.php b/include/client/templates/thread-entries.tmpl.php
index 9b1fc704db6ed350c06b932abd3fcb93bcf00bf6..b91b91d74ccb7a32d7949e3ce64f3a1eb2ea7db1 100644
--- a/include/client/templates/thread-entries.tmpl.php
+++ b/include/client/templates/thread-entries.tmpl.php
@@ -6,6 +6,10 @@ $events = $events->getIterator();
 $events->rewind();
 $event = $events->current();
 
+$htmlId = $options['html-id'] ?: ('thread-'.$this->getId());
+?>
+<div id="<?php echo $htmlId; ?>" data-thread-id="<?php echo $this->getId(); ?>">
+<?php
 if (count($entries)) {
     // Go through all the entries and bucket them by time frame
     $buckets = array();
@@ -47,3 +51,5 @@ while ($event) {
 if (count($entries) + count($events) == 0) {
     echo '<p><em>'.__('No entries have been posted to this thread.').'</em></p>';
 }
+?>
+</div>
diff --git a/include/client/view.inc.php b/include/client/view.inc.php
index 2be53bc477943706361921063d776169e1398dce..97c3bb8ef5eb52e5d279b44aeee57da46a7604b5 100644
--- a/include/client/view.inc.php
+++ b/include/client/view.inc.php
@@ -127,11 +127,12 @@ foreach (DynamicFormEntry::forTicket($ticket->getId()) as $form) {
 </table>
 <br>
 
-<div id="ticketThread">
 <?php
-    $ticket->getThread()->render(array('M', 'R'), Thread::MODE_CLIENT);
+    $ticket->getThread()->render(array('M', 'R'), array(
+                'mode' => Thread::MODE_CLIENT,
+                'html-id' => 'ticketThread')
+            );
 ?>
-</div>
 
 <div class="clear" style="padding-bottom:10px;"></div>
 <?php if($errors['err']) { ?>
diff --git a/include/staff/templates/thread-entries.tmpl.php b/include/staff/templates/thread-entries.tmpl.php
index 7ac199444b8b2607346451a97e5435cb5dda0753..b1e3a83bd155a3c0f58867a112a7c9ad33518b4e 100644
--- a/include/staff/templates/thread-entries.tmpl.php
+++ b/include/staff/templates/thread-entries.tmpl.php
@@ -3,47 +3,75 @@ $events = $events->order_by('id');
 $events = $events->getIterator();
 $events->rewind();
 $event = $events->current();
+$htmlId = $options['html-id'] ?: ('thread-'.$this->getId());
+?>
+<div id="<?php echo $htmlId; ?>" data-thread-id="<?php echo $this->getId(); ?>">
+    <div id="thread-items">
+    <?php
+    if (count($entries)) {
+        // Go through all the entries and bucket them by time frame
+        $buckets = array();
+        $rel = 0;
+        foreach ($entries as $i=>$E) {
+            // First item _always_ shows up
+            if ($i != 0)
+                // Set relative time resolution to 12 hours
+                $rel = Format::relativeTime(Misc::db2gmtime($E->created, false, 43200));
+            $buckets[$rel][] = $E;
+        }
 
-if (count($entries)) {
-    // Go through all the entries and bucket them by time frame
-    $buckets = array();
-    $rel = 0;
-    foreach ($entries as $i=>$E) {
-        // First item _always_ shows up
-        if ($i != 0)
-            // Set relative time resolution to 12 hours
-            $rel = Format::relativeTime(Misc::db2gmtime($E->created, false, 43200));
-        $buckets[$rel][] = $E;
-    }
-
-    // Go back through the entries and render them on the page
-    $i = 0;
-    foreach ($buckets as $rel=>$entries) {
-        // TODO: Consider adding a date boundary to indicate significant
-        //       changes in dates between thread items.
-        foreach ($entries as $entry) {
-            // Emit all events prior to this entry
-            while ($event && $event->timestamp < $entry->created) {
-                $event->render(ThreadEvent::MODE_STAFF);
-                $events->next();
-                $event = $events->current();
+        // Go back through the entries and render them on the page
+        foreach ($buckets as $rel=>$entries) {
+            // TODO: Consider adding a date boundary to indicate significant
+            //       changes in dates between thread items.
+            foreach ($entries as $entry) {
+                // Emit all events prior to this entry
+                while ($event && $event->timestamp < $entry->created) {
+                    $event->render(ThreadEvent::MODE_STAFF);
+                    $events->next();
+                    $event = $events->current();
+                }
+                ?><div id="thread-entry-<?php echo $entry->getId(); ?>"><?php
+                include STAFFINC_DIR . 'templates/thread-entry.tmpl.php';
+                ?></div><?php
             }
-            ?><div id="thread-entry-<?php echo $entry->getId(); ?>"><?php
-            include STAFFINC_DIR . 'templates/thread-entry.tmpl.php';
-            ?></div><?php
         }
-        $i++;
     }
-}
 
-// Emit all other events
-while ($event) {
-    $event->render(ThreadEvent::MODE_STAFF);
-    $events->next();
-    $event = $events->current();
-}
+    // Emit all other events
+    while ($event) {
+        $event->render(ThreadEvent::MODE_STAFF);
+        $events->next();
+        $event = $events->current();
+    }
+    // This should never happen
+    if (count($entries) + count($events) == 0) {
+        echo '<p><em>'.__('No entries have been posted to this thread.').'</em></p>';
+    }
+    ?>
+    </div>
+</div>
+<script type="text/javascript">
+    $(function() {
+        var container = '<?php echo $htmlId; ?>';
+
+        // Set inline image urls.
+        <?php
+        $urls = array();
+        foreach (AttachmentFile::objects()->filter(array(
+            'attachments__thread_entry__thread__id' => $this->getId(),
+            'attachments__inline' => true,
+        )) as $file) {
+            $urls[strtolower($file->getKey())] = array(
+                'download_url' => $file->getDownloadUrl(),
+                'filename' => $file->name,
+                );
 
-// This should never happen
-if (count($entries) + count($events) == 0) {
-    echo '<p><em>'.__('No entries have been posted to this thread.').'</em></p>';
-}
+            }
+        ?>
+        $('#'+container).data('imageUrls', <?php echo JsonDataEncoder::encode($urls); ?>);
+        // Trigger thread processing.
+        if ($.thread)
+            $.thread.onLoad(container);
+    });
+</script>
diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php
index 84bbc59833b18068c6e5995e0e54265369e1f2a1..ecad25ee7d8a79e59575551cef4790ecf459da8a 100644
--- a/include/staff/ticket-view.inc.php
+++ b/include/staff/ticket-view.inc.php
@@ -1,6 +1,4 @@
 <?php
-include_once INCLUDE_DIR . 'class.thread_actions.php';
-
 //Note that ticket obj is initiated in tickets.php.
 if(!defined('OSTSCPINC') || !$thisstaff || !is_object($ticket) || !$ticket->getId()) die('Invalid path');
 
@@ -434,10 +432,16 @@ $tcount = $ticket->getThreadEntries($types)->count();
 </ul>
 
 <div id="ticket_tabs_container">
-    <div id="ticket_thread" data-thread-id="<?php echo $ticket->getThread()->getId(); ?>" class="tab_content">
-    <div id="thread-items">
-    <?php $ticket->getThread()->render(array('M', 'R', 'N')); ?>
-    </div>
+<div id="ticket_thread" class="tab_content">
+<?php
+    // Render ticket thread
+    $ticket->getThread()->render(
+            array('M', 'R', 'N'),
+            array(
+                'html-id' => 'ticketThread',
+                'mode' => Thread::MODE_STAFF)
+            );
+?>
 <div class="clear"></div>
 <?php if($errors['err']) { ?>
     <div id="msg_error"><?php echo $errors['err']; ?></div>
@@ -1035,18 +1039,4 @@ $(function() {
 }();
 <?php } ?>
 });
-
-<?php
-// Hover support for all inline images
-$urls = array();
-foreach (AttachmentFile::objects()->filter(array(
-    'attachments__thread_entry__thread__id' => $ticket->getThreadId(),
-    'attachments__inline' => true,
-)) as $file) {
-    $urls[strtolower($file->getKey())] = array(
-        'download_url' => $file->getDownloadUrl(),
-        'filename' => $file->name,
-    );
-} ?>
-$('#ticket_thread').data('imageUrls', <?php echo JsonDataEncoder::encode($urls); ?>);
 </script>
diff --git a/scp/js/thread.js b/scp/js/thread.js
new file mode 100644
index 0000000000000000000000000000000000000000..62568ce27ada399fbf3fadfed5128ae267176495
--- /dev/null
+++ b/scp/js/thread.js
@@ -0,0 +1,180 @@
+/*********************************************************************
+    thread.js
+
+    Thread JS untils
+    Copyright (c) 2014 osTicket
+    http://www.osticket.com
+
+    Released under the GNU General Public License WITHOUT ANY WARRANTY.
+
+    vim: expandtab sw=4 ts=4 sts=4:
+**********************************************************************/
+
+var thread = {
+
+    options: {
+        autoScroll: true,
+        showimages: false
+    },
+
+    scrollTo: function (entry) {
+
+       if (!entry) return;
+
+       var frame = 0;
+       $('html, body').delay(500).animate({
+            scrollTop: entry.offset().top - 50,
+       }, {
+            duration: 750,
+            step: function(now, fx) {
+                // Recalc end target every few frames
+                if (++frame % 6 == 0)
+                    fx.end = entry.offset().top - 50;
+            }
+        });
+    },
+
+    showExternalImage: function(div) {
+        var $div = $(div),
+            $img = $div.append($('<img>')
+              .attr('src', $div.data('src'))
+              .attr('alt', $div.attr('alt'))
+              .attr('title', $div.attr('title'))
+              .attr('style', $div.data('style'))
+            );
+        if ($div.attr('width'))
+            $img.width($div.attr('width'));
+        if ($div.attr('height'))
+            $img.height($div.attr('height'));
+    },
+
+    externalImages: function()  {
+
+        // Optionally show external images
+        $('.thread-entry', this.options.container).each(function(i, te) {
+            var extra = $(te).find('.textra'),
+                imgs = $(te).find('.non-local-image[data-src]');
+
+            if (!extra || !imgs.length)
+                return;
+
+            // Add Show Images buttons
+            extra.append($('<a>')
+              .addClass("action-button pull-right show-images")
+              .css({'font-weight':'normal'})
+              .text(' ' + __('Show Images'))
+              .click(function(ev) {
+                imgs.each(function(i, img) {
+                  this.showExternalImage(img);
+                  $(img).removeClass('non-local-image')
+                    // Remove placeholder sizing
+                    .css({'display':'inline-block'})
+                    .width('auto')
+                    .height('auto')
+                    .removeAttr('width')
+                    .removeAttr('height');
+                  extra.find('.show-images').hide();
+                });
+              })
+              .prepend($('<i>')
+                .addClass('icon-picture')
+              )
+            );
+
+            // Show placeholders
+            imgs.each(function(i, img) {
+                var $img = $(img);
+                // Save a copy of the original styling
+                $img.data('style', $img.attr('style'));
+                $img.removeAttr('style');
+                // If the image has a 'height' attribute, use it, otherwise, use
+                // 40px
+                $img.height(($img.attr('height') || '40') + 'px');
+                // Ensure the image placeholder is visible width-wise
+                if (!$img.width())
+                    $img.width(($img.attr('width') || '80') + 'px');
+                // TODO: Add a hover-button to show just one image
+            });
+        });
+    },
+
+    inlineImages: function (entry_id) {
+        // TODO: use entry selector or object instead of ID
+        var selector = (entry_id == undefined)
+            ? '.thread-body img[data-cid]'
+            : '.thread-body#thread-id-'+entry_id+' img[data-cid]';
+
+        // Get urls
+        if (!(urls=this.options.container.data('imageUrls')))
+            return;
+
+        $(selector, this.options.container).each(function(i, el) {
+            var e = $(el),
+                cid = e.data('cid').toLowerCase(),
+                info = urls[cid];
+            if (info && !e.data('wrapped')) {
+                // Add a hover effect with the filename
+                var timeout, caption = $('<div class="image-hover">')
+                    .css({'float':e.css('float')});
+                e.wrap(caption).parent()
+                    .hover(
+                        function() {
+                            var self = this;
+                            timeout = setTimeout(
+                                function() { $(self).find('.caption').slideDown(250); },
+                                500);
+                        },
+                        function() {
+                            clearTimeout(timeout);
+                            $(this).find('.caption').slideUp(250);
+                        }
+                    ).append($('<div class="caption">')
+                        .append('<span class="filename">'+info.filename+'</span>')
+                        .append($('<a href="'+info.download_url+'" class="action-button pull-right no-pjax"><i class="icon-download-alt"></i> '+__('Download')+'</a>')
+                          .attr('download', info.filename)
+                        )
+                    );
+                e.data('wrapped', true);
+            }
+        });
+    },
+
+    prepImages: function() {
+
+        // TODO: Check config options
+        this.externalImages();
+        this.inlineImages();
+    },
+
+    onLoad: function (container, options) {
+
+        // See if thread container is valid
+        $container = $('#'+container);
+        if (!$container || !$container.length)
+            return;
+
+        // set options
+        this.options.container = $container;
+        $.extend(this.options, options);
+
+        // Prep images
+        this.prepImages();
+
+        // Auto scroll to the last entry if autoScroll is enabled.
+        if (this.options.autoScroll === true) {
+            // Find the last entry to scroll to.
+            var e = $('.thread-entry', $container).filter(':visible').last();
+            if (e.length)
+                this.scrollTo(e);
+        }
+
+        // Open thread body links in a new tab/window
+        $('div.thread-body a', $container).each(function() {
+            $(this).attr('target', '_blank');
+        });
+    }
+};
+
+// Set thread as JQuery object
+$.thread = thread;
+
diff --git a/scp/js/ticket.js b/scp/js/ticket.js
index de289ca296892b8fb7196599a398d8105480c429..c48209a3419bb2e51ed749d5205f52dc0694c36e 100644
--- a/scp/js/ticket.js
+++ b/scp/js/ticket.js
@@ -293,20 +293,6 @@ $.autoLock = autoLock;
 /*
    UI & form events
 */
-$.showNonLocalImage = function(div) {
-    var $div = $(div),
-        $img = $div.append($('<img>')
-          .attr('src', $div.data('src'))
-          .attr('alt', $div.attr('alt'))
-          .attr('title', $div.attr('title'))
-          .attr('style', $div.data('style'))
-        );
-    if ($div.attr('width'))
-        $img.width($div.attr('width'));
-    if ($div.attr('height'))
-        $img.height($div.attr('height'));
-};
-
 $.showImagesInline = function(urls, thread_id) {
     var selector = (thread_id == undefined)
         ? '.thread-body img[data-cid]'
@@ -358,7 +344,7 @@ $.refreshTicketView = function(interval) {
 }
 
 var ticket_onload = function($) {
-    if (0 === $('#ticket_thread').length)
+    if (0 === $('#ticketThread').length)
         return;
 
     //Start watching the form for activity.
@@ -397,66 +383,6 @@ var ticket_onload = function($) {
             $cc.show();
      });
 
-    // Optionally show external images
-    $('.thread-entry').each(function(i, te) {
-        var extra = $(te).find('.textra'),
-            imgs = $(te).find('.non-local-image[data-src]');
-        if (!extra) return;
-        if (!imgs.length) return;
-        extra.append($('<a>')
-          .addClass("action-button pull-right show-images")
-          .css({'font-weight':'normal'})
-          .text(' ' + __('Show Images'))
-          .click(function(ev) {
-            imgs.each(function(i, img) {
-              $.showNonLocalImage(img);
-              $(img).removeClass('non-local-image')
-                // Remove placeholder sizing
-                .css({'display':'inline-block'})
-                .width('auto')
-                .height('auto')
-                .removeAttr('width')
-                .removeAttr('height');
-              extra.find('.show-images').hide();
-            });
-          })
-          .prepend($('<i>')
-            .addClass('icon-picture')
-          )
-        );
-        imgs.each(function(i, img) {
-            var $img = $(img);
-            // Save a copy of the original styling
-            $img.data('style', $img.attr('style'));
-            $img.removeAttr('style');
-            // If the image has a 'height' attribute, use it, otherwise, use
-            // 40px
-            $img.height(($img.attr('height') || '40') + 'px');
-            // Ensure the image placeholder is visible width-wise
-            if (!$img.width())
-                $img.width(($img.attr('width') || '80') + 'px');
-            // TODO: Add a hover-button to show just one image
-        });
-    });
-
-    $.showImagesInline($('#ticket_thread').data('imageUrls'));
-
-    var last_entry = $('#ticket_thread .thread-entry').last(),
-        frame = 0;
-    $('html, body').delay(500).animate({
-        scrollTop: last_entry.offset().top - 50,
-    }, {
-        duration: 750,
-        step: function(now, fx) {
-            // Recalc end target every few frames
-            if (++frame % 6 == 0)
-                fx.end = last_entry.offset().top - 50;
-        }
-    });
-
-    $('div.thread-body a').each(function() {
-        $(this).attr('target', '_blank');
-    });
 };
 $(ticket_onload);
 $(document).on('pjax:success', function() { ticket_onload(jQuery); });
diff --git a/scp/tasks.php b/scp/tasks.php
index 2f4791356b83b0bc46dcde1b96b4e36624828f8f..c22fa5bdb39671a5e47e835d8a8447711b022f6a 100644
--- a/scp/tasks.php
+++ b/scp/tasks.php
@@ -154,6 +154,7 @@ if ($thisstaff->hasPerm(TaskModel::PERM_CREATE)) {
 
 
 $ost->addExtraHeader('<script type="text/javascript" src="js/ticket.js"></script>');
+$ost->addExtraHeader('<script type="text/javascript" src="js/thread.js"></script>');
 $ost->addExtraHeader('<meta name="tip-namespace" content="tasks.queue" />',
     "$('#content').data('tipNamespace', 'tasks.queue');");
 
diff --git a/scp/tickets.php b/scp/tickets.php
index 932e70c6481959080e24de47d63f67221a1fb05d..8e1d45185b6eace1655b12fe900be0a825cd95ad 100644
--- a/scp/tickets.php
+++ b/scp/tickets.php
@@ -494,6 +494,7 @@ if ($thisstaff->hasPerm(TicketModel::PERM_CREATE)) {
 
 
 $ost->addExtraHeader('<script type="text/javascript" src="js/ticket.js"></script>');
+$ost->addExtraHeader('<script type="text/javascript" src="js/thread.js"></script>');
 $ost->addExtraHeader('<meta name="tip-namespace" content="tickets.queue" />',
     "$('#content').data('tipNamespace', 'tickets.queue');");