diff --git a/assets/default/css/theme.css b/assets/default/css/theme.css
index 8adafc32edbd4b7335de5e75769d25a46a86bfda..26680176598c1b8916aaccacb041713d34074a16 100644
--- a/assets/default/css/theme.css
+++ b/assets/default/css/theme.css
@@ -726,7 +726,7 @@ label.required, span.required {
     border-radius: 4px;
 }
 #reply {
-  margin-top: 20px;
+  margin-top: 5px;
   padding: 10px;
   background: #f9f9f9;
   border: 1px solid #ccc;
@@ -855,44 +855,6 @@ a.refresh {
   text-align: left;
   padding: 3px 8px;
 }
-#ticketThread table.response,
-#ticketThread table.message {
-  margin-top: 10px;
-  border: 1px solid #aaa;
-  border-bottom: 2px solid #aaa;
-}
-#ticketThread table th {
-  text-align: left;
-  border-bottom: 1px solid #aaa;
-  font-size: 12px;
-  padding: 5px;
-}
-#ticketThread table th span {
-  font-weight: normal;
-  color: #888;
-  padding-left: 20px;
-}
-#ticketThread .message th {
-  background: #d8efff;
-}
-#ticketThread .response th {
-  background: #ddd;
-}
-#ticketThread .info {
-  padding: 2px;
-  background: #f9f9f9;
-  border-top: 1px solid #ddd;
-  height: 16px;
-  line-height: 16px;
-}
-#ticketThread .info a {
-  display: inline-block;
-  margin: 5px 10px 5px 0;
-  height: 16px;
-  line-height: 16px;
-  background-position: 0 50%;
-  background-repeat: no-repeat;
-}
 .action-button {
   -webkit-border-radius: 3px;
   -moz-border-radius: 3px;
@@ -1048,6 +1010,7 @@ img.sign-in-image {
     white-space: nowrap;
     overflow: hidden;
     text-overflow: ellipsis;
+    vertical-align: bottom;
 }
 .image-hover a.action-button:hover,
 .image-hover a.action-button {
@@ -1079,3 +1042,220 @@ table.custom-data .headline {
 #ticketInfo h1 small {
     font-weight: normal;
 }
+.thread-entry {
+    margin-bottom: 15px;
+}
+.thread-entry.avatar {
+    margin-left: 60px;
+}
+.thread-entry.response.avatar {
+    margin-right: 60px;
+    margin-left: 0;
+}
+.thread-entry > .avatar {
+    margin-left: -60px;
+    display:inline-block;
+    width:48px;
+    height:auto;
+    border-radius: 5px;
+}
+.thread-entry.response > .avatar {
+    margin-left: initial;
+    margin-right: -60px;
+}
+img.avatar {
+    border-radius: inherit;
+}
+.thread-entry .header {
+    padding: 8px 0.9em;
+    border: 1px solid #ccc;
+    border-color: rgba(0,0,0,0.2);
+    border-radius: 5px 5px 0 0;
+}
+.thread-entry.avatar .header:before {
+  position: absolute;
+  top: 7px;
+  right: -8px;
+  content: '';
+  border-top: 8px solid transparent;
+  border-bottom: 8px solid transparent;
+  border-left: 8px solid #b0b0b0;
+  display: inline-block;
+}
+.thread-entry.avatar .header:after {
+  position: absolute;
+  top: 7px;
+  right: -8px;
+  content: '';
+  border-top: 7px solid transparent;
+  border-bottom: 7px solid transparent;
+  display: inline-block;
+  margin-top: 1px;
+}
+
+.thread-entry.avatar .header {
+    position: relative;
+}
+
+.thread-entry.response .header {
+    background:#dddddd;
+}
+.thread-entry.avatar.response .header:after {
+    border-left: 7px solid #dddddd;
+    margin-right: 1px;
+}
+
+.thread-entry.message .header {
+    background:#C3D9FF;
+}
+.thread-entry.avatar.message .header:before {
+    top: 7px;
+    left: -8px;
+    right: initial;
+    border-left: none;
+    border-right: 8px solid #CCC;
+}
+.thread-entry.avatar.message .header:before {
+    border-right-color: #9cadcc;
+}
+.thread-entry.avatar.message .header:after {
+    top: 7px;
+    left: -8px;
+    right: initial;
+    border-left: none;
+    border-right: 7px solid #c3d9ff;
+    margin-left: 1px;
+}
+
+.thread-entry .header .title {
+    max-width: 500px;
+    vertical-align: bottom;
+    display: inline-block;
+    margin-left: 15px;
+}
+
+.thread-entry .thread-body {
+    border: 1px solid #ddd;
+    border-top: none;
+    border-bottom:2px solid #aaa;
+    border-radius: 0 0 5px 5px;
+}
+.thread-body .attachments {
+  background-color: #f4faff;
+  margin: 0 -0.9em;
+  position: relative;
+  top: 0.9em;
+  padding: 0.3em 0.9em;
+  border-top: 1px dotted #ccc;
+  border-top-color: rgba(0,0,0,0.2);
+  border-radius: 0 0 6px 6px;
+}
+.thread-body .attachments .filesize {
+  margin-left: 0.5em;
+}
+.thread-body .attachment-info {
+    margin-right: 10px;
+    display: inline-block;
+    width: 48%;
+}
+.thread-body .attachment-info .filename {
+  max-width: 80%;
+  max-width: calc(100% - 70px);
+}
+.label {
+  font-size: 11px;
+  padding: 1px 4px;
+  -webkit-border-radius: 3px;
+  -moz-border-radius: 3px;
+  border-radius: 3px;
+  font-weight: bold;
+  line-height: 14px;
+  color: #ffffff;
+  vertical-align: baseline;
+  white-space: nowrap;
+  text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+  background-color: #999999;
+}
+.label-bare {
+  background-color: transparent;
+  background-color: rgba(0,0,0,0);
+  border: 1px solid #999999;
+  color: #999999;
+  text-shadow: none;
+}
+.thread-event {
+    padding: 0px 2px 15px;
+    margin-left: 60px;
+}
+.type-icon {
+    border-radius: 8px;
+    background-color: #f4f4f4;
+    padding: 4px 6px;
+    margin-right: 5px;
+    text-align: center;
+    display: inline-block;
+    font-size: 1.1em;
+    border: 1px solid #eee;
+    vertical-align: top;
+}
+.type-icon.dark {
+    border-color: #666;
+    background-color: #949494;
+}
+.thread-event img.avatar {
+    vertical-align: middle;
+    border-radius: 3px;
+    width: auto;
+    max-height: 24px;
+    margin: -3px 3px 0;
+}
+.thread-event .description {
+    margin-left: -30px;
+    padding-top: 6px;
+    padding-left: 30px;
+    display: inline-block;
+    width: 642px;
+    width: calc(100% - 95px);
+    line-height: 1.4em;
+}
+.thread-event .type-icon {
+  position:relative;
+}
+.thread-event .type-icon::after {
+  content: "";
+  border: 16px solid white;
+  position: absolute;
+  top: -3px;
+  bottom: 0;
+  left: -3px;
+  right: 0;
+  z-index: -1;
+}
+.thread-entry::after {
+  content: "";
+  border-bottom: 2px solid white;
+  display: block;
+}
+.thread-entry::before {
+  content: "";
+  display: block;
+  border-top: 2px solid white;
+}
+#ticketThread::before {
+  border-left: 2px dotted #ddd;
+  border-bottom-color: rgba(0,0,0,0.1);
+  position: absolute;
+  margin-left: 74px;
+  z-index: -1;
+  content: "";
+  top: 0;
+  bottom: 0;
+  right: 0;
+  left: 0;
+}
+#ticketThread {
+  z-index: 0;
+  position: relative;
+  border-bottom: 2px solid #ddd;
+  border-bottom-color: rgba(0,0,0,0.1);
+}
diff --git a/bootstrap.php b/bootstrap.php
index a49d8dcd98530af5842123898776108b9411c606..e056f8a7f1259b8c8be078204e49f58676dcbcc6 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -98,7 +98,7 @@ class Bootstrap {
 
         define('TICKET_TABLE',$prefix.'ticket');
         define('TICKET_CDATA_TABLE', $prefix.'ticket__cdata');
-        define('TICKET_EVENT_TABLE',$prefix.'ticket_event');
+        define('THREAD_EVENT_TABLE',$prefix.'thread_event');
         define('THREAD_COLLABORATOR_TABLE', $prefix.'thread_collaborator');
         define('TICKET_STATUS_TABLE', $prefix.'ticket_status');
         define('TICKET_PRIORITY_TABLE',$prefix.'ticket_priority');
diff --git a/css/thread.css b/css/thread.css
index 233f283158c5e8f98a67e1dad072bfac756e343d..4ac6615703681721278f9d2f8815c499d2a6aff9 100644
--- a/css/thread.css
+++ b/css/thread.css
@@ -30,7 +30,7 @@
   color: #333333;
   background-color: #ffffff;
   margin: 0;
-  padding: 0.5em;
+  padding: 0.9em;
 }
 .thread-body a:focus {
   outline: thin dotted;
diff --git a/include/ajax.reports.php b/include/ajax.reports.php
index 6086e3cb0b65fb214c14dd141d874976c25c0916..7b2e4f450a74e0b780b8bbede6515d0382684152 100644
--- a/include/ajax.reports.php
+++ b/include/ajax.reports.php
@@ -90,7 +90,7 @@ class OverviewReportAjaxAPI extends AjaxController {
                 COUNT(*)-COUNT(NULLIF(A1.state, "closed")) AS Closed,
                 COUNT(*)-COUNT(NULLIF(A1.state, "reopened")) AS Reopened
             FROM '.$info['table'].' T1
-                LEFT JOIN '.TICKET_EVENT_TABLE.' A1
+                LEFT JOIN '.THREAD_EVENT_TABLE.' A1
                     ON (A1.'.$info['pk'].'=T1.'.$info['pk'].'
                          AND NOT annulled
                          AND (A1.timestamp BETWEEN '.$start.' AND '.$stop.'))
@@ -190,7 +190,7 @@ class OverviewReportAjaxAPI extends AjaxController {
         list($start, $stop) = $this->_getDateRange();
 
         # Fetch all types of events over the timeframe
-        $res = db_query('SELECT DISTINCT(state) FROM '.TICKET_EVENT_TABLE
+        $res = db_query('SELECT DISTINCT(state) FROM '.THREAD_EVENT_TABLE
             .' WHERE timestamp BETWEEN '.$start.' AND '.$stop
                 .' ORDER BY 1');
         $events = array();
@@ -200,7 +200,7 @@ class OverviewReportAjaxAPI extends AjaxController {
         # XXX: Implement annulled column from the %ticket_event table
         $res = db_query('SELECT state, DATE_FORMAT(timestamp, \'%Y-%m-%d\'), '
                 .'COUNT(ticket_id)'
-            .' FROM '.TICKET_EVENT_TABLE
+            .' FROM '.THREAD_EVENT_TABLE
             .' WHERE timestamp BETWEEN '.$start.' AND '.$stop
             .' AND NOT annulled'
             .' GROUP BY state, DATE_FORMAT(timestamp, \'%Y-%m-%d\')'
diff --git a/include/ajax.tickets.php b/include/ajax.tickets.php
index 1e95dde3ec1c033f51fb70b13456739aafea7cfb..d7f53279963b4272686c7d50a1cf19e0b67bb4bd 100644
--- a/include/ajax.tickets.php
+++ b/include/ajax.tickets.php
@@ -192,7 +192,6 @@ class TicketsAjaxAPI extends AjaxController {
         include STAFFINC_DIR . 'templates/ticket-preview.tmpl.php';
     }
 
-
     function viewUser($tid) {
         global $thisstaff;
 
diff --git a/include/class.collaborator.php b/include/class.collaborator.php
index 205b5200b151911c757611483fae9560f041bb27..6bc891e5fd9fd53d19789faac4de813fc2ab1dfc 100644
--- a/include/class.collaborator.php
+++ b/include/class.collaborator.php
@@ -35,8 +35,10 @@ implements EmailContact, ITicketUser {
     );
 
     function __toString() {
-        return Format::htmlchars(sprintf('%s <%s>', $this->getName(),
-                $this->getEmail()));
+        return Format::htmlchars($this->toString());
+    }
+    function toString() {
+        return sprintf('%s <%s>', $this->getName(), $this->getEmail());
     }
 
     function getId() {
diff --git a/include/class.dynamic_forms.php b/include/class.dynamic_forms.php
index 43ae645c765a97524d7479e6995c5261319d522d..8bb94d81c792c4c407b6b0801de9a44f7dbae4a4 100644
--- a/include/class.dynamic_forms.php
+++ b/include/class.dynamic_forms.php
@@ -1168,6 +1168,23 @@ class DynamicFormEntry extends VerySimpleModel {
         return $this->getForm()->render($staff, $title, $options);
     }
 
+    function getChanges() {
+        $fields = array();
+        foreach ($this->getAnswers() as $a) {
+            $field = $a->getField();
+            if (!$field->hasData() || $field->isPresentationOnly())
+                continue;
+            $val = $v = $field->to_database($field->getClean());
+            if (is_array($val))
+                $v = $val[0];
+            if ($a->value == $v)
+                continue;
+            $before = $field->to_database($a->getValue());
+            $fields[$field->get('id')] = array($before, $val);
+        }
+        return $fields;
+    }
+
     /**
      * addMissingFields
      *
@@ -1489,6 +1506,38 @@ class SelectionField extends FormField {
         return $value;
     }
 
+    // PHP 5.4 Move this to a trait
+    function whatChanged($before, $after) {
+        $before = (array) $before;
+        $after = (array) $after;
+        $added = array_diff($after, $before);
+        $deleted = array_diff($before, $after);
+        $added = array_map(array($this, 'display'), $added);
+        $deleted = array_map(array($this, 'display'), $deleted);
+
+        if ($added && $deleted) {
+            $desc = sprintf(
+                __('added <strong>%1$s</strong> and removed <strong>%2$s</strong>'),
+                implode(', ', $added), implode(', ', $deleted));
+        }
+        elseif ($added) {
+            $desc = sprintf(
+                __('added <strong>%1$s</strong>'),
+                implode(', ', $added));
+        }
+        elseif ($deleted) {
+            $desc = sprintf(
+                __('removed <strong>%1$s</strong>'),
+                implode(', ', $deleted));
+        }
+        else {
+            $desc = sprintf(
+                __('changed to <strong>%1$s</strong>'),
+                $this->display($after));
+        }
+        return $desc;
+    }
+
     function asVar($value, $id=false) {
         $values = $this->to_php($value, $id);
         if (is_array($values)) {
diff --git a/include/class.export.php b/include/class.export.php
index acdc333582c6d7d54b35c0fd17d4db5ccd5ec473..68b907282853cbf55b5ebde39f367230905d4ebc 100644
--- a/include/class.export.php
+++ b/include/class.export.php
@@ -345,7 +345,7 @@ class DatabaseExporter {
         FAQ_TOPIC_TABLE, FAQ_CATEGORY_TABLE, DRAFT_TABLE,
         CANNED_TABLE, TICKET_TABLE, ATTACHMENT_TABLE,
         THREAD_TABLE, THREAD_ENTRY_TABLE, THREAD_ENTRY_EMAIL_TABLE,
-        LOCK_TABLE, TICKET_EVENT_TABLE, TICKET_PRIORITY_TABLE,
+        LOCK_TABLE, THREAD_EVENT_TABLE, TICKET_PRIORITY_TABLE,
         EMAIL_TABLE, EMAIL_TEMPLATE_TABLE, EMAIL_TEMPLATE_GRP_TABLE,
         FILTER_TABLE, FILTER_RULE_TABLE, SLA_TABLE, API_KEY_TABLE,
         TIMEZONE_TABLE, SESSION_TABLE, PAGE_TABLE,
diff --git a/include/class.format.php b/include/class.format.php
index cd9b27f24657c5c95ff59699d9706912c38ae36b..33f6b4ebb9c055200fa14091e309120a9bc669a2 100644
--- a/include/class.format.php
+++ b/include/class.format.php
@@ -714,7 +714,7 @@ class Format {
         return $text;
     }
 
-    function relativeTime($to, $from=false) {
+    function relativeTime($to, $from=false, $granularity=1) {
         $timestamp = $to ?: Misc::gmtime();
         if (gettype($timestamp) === 'string')
             $timestamp = strtotime($timestamp);
@@ -724,6 +724,9 @@ class Format {
         $timeDiff = $from - $timestamp;
         $absTimeDiff = abs($timeDiff);
 
+        // Roll back to the nearest multiple of $granularity
+        $absTimeDiff -= $absTimeDiff % $granularity;
+
         // within 2 seconds
         if ($absTimeDiff <= 2) {
           return $timeDiff >= 0 ? __('just now') : __('now');
@@ -758,7 +761,7 @@ class Format {
         $days2 = 2 * 86400;
         if ($absTimeDiff < $days2) {
             // XXX: yesterday / tomorrow?
-          return $absTimeDiff >= 0 ? __('1 day ago') : __('in 1 day');
+          return $absTimeDiff >= 0 ? __('yesterday') : __('tomorrow');
         }
 
         // within 29 days
diff --git a/include/class.forms.php b/include/class.forms.php
index 0b6da8f275f0f5a75fc08c3f618279bdd2aff058..73e89287bab0c1529558fdbe59fc3977ace94105 100644
--- a/include/class.forms.php
+++ b/include/class.forms.php
@@ -626,6 +626,19 @@ class FormField {
         return false;
     }
 
+    /**
+     * Describe the difference between the to two values. Note that the
+     * values should be passed through ::parse() or to_php() before
+     * utilizing this method.
+     */
+    function whatChanged($before, $after) {
+        if ($before)
+            $desc = __('changed from <strong>%2$s</strong> to <strong>%1$s</strong>');
+        else
+            $desc = __('set to <strong>%1$s</strong>');
+        return sprintf($desc, $this->display($after), $this->display($before));
+    }
+
     /**
      * Convert the field data to something matchable by filtering. The
      * primary use of this is for ticket filtering.
@@ -1312,6 +1325,37 @@ class ChoiceField extends FormField {
         return (string) $value;
     }
 
+    function whatChanged($before, $after) {
+        $B = (array) $before;
+        $A = (array) $after;
+        $added = array_diff($A, $B);
+        $deleted = array_diff($B, $A);
+        $added = array_map(array($this, 'display'), $added);
+        $deleted = array_map(array($this, 'display'), $deleted);
+
+        if ($added && $deleted) {
+            $desc = sprintf(
+                __('added <strong>%1$s</strong> and removed <strong>%2$s</strong>'),
+                implode(', ', $added), implode(', ', $deleted));
+        }
+        elseif ($added) {
+            $desc = sprintf(
+                __('added <strong>%1$s</strong>'),
+                implode(', ', $added));
+        }
+        elseif ($deleted) {
+            $desc = sprintf(
+                __('removed <strong>%1$s</strong>'),
+                implode(', ', $deleted));
+        }
+        else {
+            $desc = sprintf(
+                __('changed from <strong>%1$s</strong> to <strong>%2$s</strong>'),
+                $this->display($before), $this->display($after));
+        }
+        return $desc;
+    }
+
     /*
      Return criteria to which the choice should be filtered by
      */
@@ -1690,6 +1734,8 @@ class PriorityField extends ChoiceField {
             reset($id);
             $id = key($id);
         }
+        elseif (is_array($value))
+            list($value, $id) = $value;
         elseif ($id === false)
             $id = $value;
         if ($id)
diff --git a/include/class.orm.php b/include/class.orm.php
index 57029871265b38038f32d5726038a724d39d3de0..b7250ee94a2a18a75cb2cb76e9991b6f673585bf 100644
--- a/include/class.orm.php
+++ b/include/class.orm.php
@@ -193,7 +193,8 @@ class VerySimpleModel {
                     $fkey[$F ?: $_klas] = ($local[0] == "'")
                         ? trim($local, "'") : $this->ht[$local];
                 }
-                $v = $this->ht[$field] = new InstrumentedList(
+                $manager = @$j['class'] ?: 'InstrumentedList';
+                $v = $this->ht[$field] = new $manager(
                     // Send Model, [Foriegn-Field => Local-Id]
                     array($class, $fkey)
                 );
diff --git a/include/class.staff.php b/include/class.staff.php
index b573eaa6efcc7da49c0cc3a71b979a80cff9f40b..6389e0e3948eff71b3612b666ef185d2f677922c 100644
--- a/include/class.staff.php
+++ b/include/class.staff.php
@@ -243,6 +243,30 @@ implements AuthenticatedUser, EmailContact, TemplateVariable {
     function getEmail() {
         return $this->email;
     }
+    /**
+     * Get either a Gravatar URL or complete image tag for a specified email address.
+     *
+     * @param string $email The email address
+     * @param string $s Size in pixels, defaults to 80px [ 1 - 2048 ]
+     * @param string $d Default imageset to use [ 404 | mm | identicon | monsterid | wavatar ]
+     * @param string $r Maximum rating (inclusive) [ g | pg | r | x ]
+     * @param boole $img True to return a complete IMG tag False for just the URL
+     * @param array $atts Optional, additional key/value attributes to include in the IMG tag
+     * @return String containing either just a URL or a complete image tag
+     * @source http://gravatar.com/site/implement/images/php/
+     */
+    function get_gravatar($s = 80, $img = false, $atts = array(), $d = 'retro', $r = 'g' ) {
+        $url = '//www.gravatar.com/avatar/';
+        $url .= md5( strtolower( $this->getEmail() ) );
+        $url .= "?s=$s&d=$d&r=$r";
+        if ( $img ) {
+            $url = '<img src="' . $url . '"';
+            foreach ( $atts as $key => $val )
+                $url .= ' ' . $key . '="' . $val . '"';
+            $url .= ' />';
+        }
+        return $url;
+    }
 
     function getUserName() {
         return $this->username;
diff --git a/include/class.team.php b/include/class.team.php
index 04427634a0bcce591e1e460737f34cd445e5e56b..f172f1ede5a97ab980a657d5c3d39b80107bc6ad 100644
--- a/include/class.team.php
+++ b/include/class.team.php
@@ -62,6 +62,9 @@ implements TemplateVariable {
     function getName() {
         return $this->name;
     }
+    function getLocalName() {
+        return $this->getLocal('name');
+    }
 
     function getNumMembers() {
         return $this->members->count();
diff --git a/include/class.thread.php b/include/class.thread.php
index 75d5a14a396bf1830b0708f89f6a6d0f7caeb72d..b228fd63e3d3de9f083b45314d5bebbcd4947dae 100644
--- a/include/class.thread.php
+++ b/include/class.thread.php
@@ -42,9 +42,16 @@ class Thread extends VerySimpleModel {
             'entries' => array(
                 'reverse' => 'ThreadEntry.thread',
             ),
+            'events' => array(
+                'reverse' => 'ThreadEvent.thread',
+                'class' => 'ThreadEvents',
+            ),
         ),
     );
 
+    const MODE_STAFF = 1;
+    const MODE_CLIENT = 2;
+
     var $_object;
     var $_collaborators; // Cache for collabs
 
@@ -192,19 +199,25 @@ class Thread extends VerySimpleModel {
         return true;
     }
     // Render thread
-    function render($type=false) {
+    function render($type=false, $mode=self::MODE_STAFF) {
 
         $entries = $this->getEntries();
         if ($type && is_array($type))
             $entries->filter(array('type__in' => $type));
 
-        include STAFFINC_DIR . 'templates/thread-entries.tmpl.php';
+        $events = $this->getEvents();
+        $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR;
+        include $inc . 'templates/thread-entries.tmpl.php';
     }
 
     function getEntry($id) {
         return ThreadEntry::lookup($id, $this->getId());
     }
 
+    function getEvents() {
+        return $this->events;
+    }
+
     /**
      * postEmail
      *
@@ -1406,6 +1419,311 @@ implements TemplateVariable {
 
 RolePermission::register(/* @trans */ 'Tickets', ThreadEntry::getPermissions());
 
+class ThreadEvent extends VerySimpleModel {
+    static $meta = array(
+        'table' => THREAD_EVENT_TABLE,
+        'pk' => array('id'),
+        'joins' => array(
+            // Originator of activity
+            'agent' => array(
+                'constraint' => array(
+                    'uid' => 'Staff.staff_id',
+                ),
+                'null' => true,
+            ),
+            // Agent assignee
+            'staff' => array(
+                'constraint' => array(
+                    'staff_id' => 'Staff.staff_id',
+                ),
+                'null' => true,
+            ),
+            'team' => array(
+                'constraint' => array(
+                    'team_id' => 'Team.team_id',
+                ),
+                'null' => true,
+            ),
+            'thread' => array(
+                'constraint' => array('thread_id' => 'Thread.id'),
+            ),
+            'user' => array(
+                'constraint' => array(
+                    'uid' => 'User.id',
+                ),
+                'null' => true,
+            ),
+            'dept' => array(
+                'constraint' => array(
+                    'dept_id' => 'Dept.id',
+                ),
+                'null' => true,
+            ),
+        ),
+    );
+
+    // Valid events for database storage
+    const ASSIGNED  = 'assigned';
+    const CLOSED    = 'closed';
+    const CREATED   = 'created';
+    const COLLAB    = 'collab';
+    const EDITED    = 'edited';
+    const ERROR     = 'error';
+    const OVERDUE   = 'overdue';
+    const REOPENED  = 'reopened';
+    const STATUS    = 'status';
+    const TRANFERRED = 'transferred';
+    const VIEWED    = 'viewed';
+
+    const MODE_STAFF = 1;
+    const MODE_CLIENT = 2;
+
+    var $_data;
+
+    function getAvatar($size=16) {
+        if ($this->uid && $this->uid_type == 'S')
+            return $this->agent->get_gravatar($size);
+        if ($this->uid && $this->uid_type == 'U')
+            return $this->user->get_gravatar($size);
+    }
+
+    function getUserName() {
+        if ($this->uid && $this->uid_type == 'S')
+            return $this->agent->getName();
+        if ($this->uid && $this->uid_type == 'U')
+            return $this->user->getName();
+        return $this->username;
+    }
+
+    function getIcon() {
+        $icons = array(
+            'assigned'  => 'hand-right',
+            'collab'    => 'group',
+            'created'   => 'magic',
+            'overdue'   => 'time',
+            'transferred' => 'share-alt',
+            'edited'    => 'pencil',
+        );
+        return @$icons[$this->state] ?: 'chevron-sign-right';
+    }
+
+    function getDescription($mode=self::MODE_STAFF) {
+        static $descs;
+        if (!isset($descs))
+            $descs = array(
+            'assigned' => __('Assignee changed by <b>{username}</b> to <strong>{assignees}</strong> {timestamp}'),
+            'assigned:staff' => __('<b>{username}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}'),
+            'assigned:team' => __('<b>{username}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}'),
+            'assigned:claim' => __('<b>{username}</b> claimed this {timestamp}'),
+            'collab:org' => __('Collaborators for {<Organization>data.org} organization added'),
+            'collab:del' => function($evt) {
+                $data = $evt->getData();
+                $base = __('<b>{username}</b> removed %s from the collaborators.');
+                return $data['del']
+                    ? Format::htmlchars(sprintf($base, implode(', ', $data['del'])))
+                    : 'somebody';
+            },
+            'collab:add' => function($evt) {
+                $data = $evt->getData();
+                $base = __('<b>{username}</b> added <strong>%s</strong> as collaborators {timestamp}');
+                $collabs = array();
+                if ($data['add']) {
+                    foreach ($data['add'] as $c) {
+                        $collabs[] = Format::htmlchars($c);
+                    }
+                }
+                return $collabs
+                    ? sprintf($base, implode(', ', $collabs))
+                    : 'somebody';
+            },
+            'created' => __('Created by <b>{username}</b> {timestamp}'),
+            'closed' => __('Closed by <b>{username}</b> {timestamp}'),
+            'reopened' => __('Reopened by <b>{username}</b> {timestamp}'),
+            'edited:owner' => __('<b>{username}</b> changed ownership to {<User>data.owner} {timestamp}'),
+            'edited:status' => __('<b>{username}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}'),
+            'overdue' => __('Flagged as overdue by the system {timestamp}'),
+            'transferred' => __('<b>{username}</b> transferred this to <strong>{dept}</strong> {timestamp}'),
+            'edited:fields' => function($evt) use ($mode) {
+                $base = __('Updated by <b>{username}</b> {timestamp} — %s');
+                $data = $evt->getData();
+                $fields = $changes = array();
+                foreach (DynamicFormField::objects()->filter(array(
+                    'id__in' => array_keys($data['fields'])
+                )) as $F) {
+                    $fields[$F->id] = $F;
+                }
+                foreach ($data['fields'] as $id=>$f) {
+                    $field = $fields[$id];
+                    if ($mode == self::MODE_CLIENT && !$field->isVisibleToUsers())
+                        continue;
+                    list($old, $new) = $f;
+                    $impl = $field->getImpl($field);
+                    $before = $impl->to_php($old);
+                    $after = $impl->to_php($new);
+                    $changes[] = sprintf('<strong>%s</strong> %s',
+                        $field->getLocal('label'), $impl->whatChanged($before, $after));
+                }
+                if (!$changes)
+                    return '';
+                return sprintf($base, implode(', ', $changes));
+            },
+        );
+        $self = $this;
+        $data = $this->getData();
+        $state = $this->state;
+        if (is_array($data)) {
+            foreach (array_keys($data) as $k)
+                if (isset($descs[$state . ':' . $k]))
+                    $state .= ':' . $k;
+        }
+        $description = $descs[$state];
+        if (is_callable($description))
+            $description = $description($this);
+
+        return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/',
+            function ($m) use ($self) {
+                switch ($m['key']) {
+                case 'assignees':
+                    $assignees = array();
+                    if ($S = $this->staff) {
+                        $url = $S->get_gravatar(16);
+                        $assignees[] =
+                            "<img class=\"avatar\" src=\"{$url}\"> ".$S->getName();
+                    }
+                    if ($T = $this->team) {
+                        $assignees[] = $T->getLocalName();
+                    }
+                    return implode('/', $assignees);
+                case 'username':
+                    $name = $self->getUserName();
+                    if ($url = $self->getAvatar())
+                        $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name;
+                    return $name;
+                case 'timestamp':
+                    return sprintf('<time class="relative" datetime="%s" title="%s">%s</time>',
+                        date(DateTime::W3C, Misc::db2gmtime($self->timestamp)),
+                        Format::daydatetime($self->timestamp),
+                        Format::relativeTime(Misc::db2gmtime($self->timestamp))
+                    );
+                case 'agent':
+                    $st = $this->agent;
+                    if ($url = $self->getAvatar())
+                        $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name;
+                case 'dept':
+                    if ($dept = $this->getDept())
+                        return $dept->getLocalName();
+                case 'data':
+                    $val = $self->getData($m['data']);
+                    if ($m['type'] && class_exists($m['type']))
+                        $val = $m['type']::lookup($val);
+                    return (string) $val;
+                }
+                return $m[0];
+            },
+            $description
+        );
+    }
+
+    function getDept() {
+        return $this->dept;
+    }
+
+    function getData($key=false) {
+        if (!isset($this->_data))
+            $this->_data = JsonDataParser::decode($this->data);
+        return ($key) ? @$this->_data[$key] : $this->_data;
+    }
+
+    function render($mode) {
+        $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR;
+        $event = $this;
+        include $inc . 'templates/thread-event.tmpl.php';
+    }
+
+    static function create($ht=false) {
+        $inst = parent::create($ht);
+        $inst->timestamp = SqlFunction::NOW();
+
+        global $thisstaff, $thisclient;
+        if ($thisstaff) {
+            $inst->uid_type = 'S';
+            $inst->uid = $thisstaff->getId();
+        }
+        else if ($thisclient) {
+            $inst->uid_type = 'U';
+            $inst->uid = $thisclient->getId();
+        }
+
+        return $inst;
+    }
+
+    static function forTicket($ticket, $state) {
+        $inst = static::create(array(
+            'staff_id' => $ticket->getStaffId(),
+            'team_id' => $ticket->getTeamId(),
+            'dept_id' => $ticket->getDeptId(),
+            'topic_id' => $ticket->getTopicId(),
+        ));
+        if (!isset($inst->uid_type) && $state == self::CREATED) {
+            $inst->uid_type = 'U';
+            $inst->uid = $ticket->getOwnerId();
+        }
+        return $inst;
+    }
+}
+
+class ThreadEvents extends InstrumentedList {
+    function annul($event) {
+        $this->queryset
+            ->filter(array('state' => $event))
+            ->update(array('annulled' => 1));
+    }
+
+    function log($object, $state, $data=null, $annul=null, $username=null) {
+        if ($object instanceof Ticket)
+            $event = ThreadEvent::forTicket($object, $state);
+        else
+            $event = ThreadEvent::create();
+
+        # Annul previous entries if requested (for instance, reopening a
+        # ticket will annul an 'closed' entry). This will be useful to
+        # easily prevent repeated statistics.
+        if ($annul) {
+            $this->annul($annul);
+        }
+
+        if ($username === null) {
+            if ($thisstaff) {
+                $username = $thisstaff->getUserName();
+            }
+            else if ($thisclient) {
+                if ($thisclient->hasAccount)
+                    $username = $thisclient->getAccount()->getUserName();
+                if (!$username)
+                    $username = $thisclient->getEmail();
+            }
+            else {
+                # XXX: Security Violation ?
+                $username = 'SYSTEM';
+            }
+        }
+        $event->username = $username;
+        $event->state = $state;
+
+        if ($data) {
+            if (is_array($data))
+                $data = JsonDataEncoder::encode($data);
+            if (!is_string($data))
+                throw new InvalidArgumentException('Data must be string or array');
+            $event->data = $data;
+        }
+
+        $this->add($event);
+
+        // Save event immediately
+        return $event->save();
+    }
+}
 
 class ThreadEntryBody /* extends SplString */ {
 
diff --git a/include/class.ticket.php b/include/class.ticket.php
index 605758114102bcc1dfe959abec689b933a8e4fb9..9f53f0a29dc6b8391dbb37c11dfb55266168a141 100644
--- a/include/class.ticket.php
+++ b/include/class.ticket.php
@@ -943,6 +943,8 @@ implements RestrictedAccess, Threadable, TemplateVariable {
         $this->collaborators = null;
         $this->recipients = null;
 
+        $this->logEvent('collab', array('add' => array($c->toString())));
+
         return $c;
     }
 
@@ -959,11 +961,10 @@ implements RestrictedAccess, Threadable, TemplateVariable {
                 if (($c=Collaborator::lookup($cid))
                         && $c->getTicketId() == $this->getId()
                         && $c->delete())
-                     $collabs[] = $c;
+                     $collabs[] = (string) $c;
             }
 
-            $this->logNote(_S('Collaborators Removed'),
-                    implode("<br>", $collabs), $thisstaff, false);
+            $this->logEvent('collab', array('del' => $collabs));
         }
 
         //statuses
@@ -1164,6 +1165,7 @@ implements RestrictedAccess, Threadable, TemplateVariable {
             }
         }
 
+        $hadStatus = $this->getStatusId();
         if ($this->getStatusId() == $status->getId())
             return true;
 
@@ -1196,7 +1198,7 @@ implements RestrictedAccess, Threadable, TemplateVariable {
                 if ($this->isClosed()) {
                     $sql .= ',closed=NULL, lastupdate=NOW(), reopened=NOW() ';
                     $ecb = function ($t) {
-                        $t->logEvent('reopened', 'closed');
+                        $t->logEvent('reopened', false, 'closed');
                     };
                 }
 
@@ -1228,12 +1230,15 @@ implements RestrictedAccess, Threadable, TemplateVariable {
                 $note .= sprintf('<hr>%s', $comments);
                 // Send out alerts if comments are included
                 $alert = true;
+                $this->logNote(__('Status Changed'), $note, $thisstaff, $alert);
             }
-
-            $this->logNote(__('Status Changed'), $note, $thisstaff, $alert);
         }
         // Log events via callback
-        if ($ecb) $ecb($this);
+        if ($ecb)
+            $ecb($this);
+        elseif ($hadStatus)
+            // Don't log the initial status change
+            $this->logEvent('edited', array('status' => $status->getId()));
 
         return true;
     }
@@ -1662,13 +1667,16 @@ implements RestrictedAccess, Threadable, TemplateVariable {
 
         $this->reload();
 
+        $user_comments = (bool) $comments;
         $comments = $comments ?: _S('Ticket assignment');
         $assigner = $thisstaff ?: _S('SYSTEM (Auto Assignment)');
 
         //Log an internal note - no alerts on the internal note.
-        $note = $this->logNote(
-            sprintf(_S('Ticket Assigned to %s'), $assignee->getName()),
-            $comments, $assigner, false);
+        if ($user_comments) {
+            $note = $this->logNote(
+                sprintf(_S('Ticket Assigned to %s'), $assignee->getName()),
+                $comments, $assigner, false);
+        }
 
         //See if we need to send alerts
         if(!$alert || !$cfg->alertONAssignment()) return true; //No alerts!
@@ -1971,8 +1979,11 @@ implements RestrictedAccess, Threadable, TemplateVariable {
         /*** log the transfer comments as internal note - with alerts disabled - ***/
         $title=sprintf(_S('Ticket transferred from %1$s to %2$s'),
             $currentDept, $this->getDeptName());
-        $comments=$comments?$comments:$title;
-        $note = $this->logNote($title, $comments, $thisstaff, false);
+
+        if ($comments) {
+            $note = $this->logNote($title, $comments, $thisstaff, false);
+        }
+        $comments = $comments ?: $title;
 
         $this->logEvent('transferred');
 
@@ -2004,11 +2015,13 @@ implements RestrictedAccess, Threadable, TemplateVariable {
             if($cfg->alertDeptManagerONTransfer() && $dept && ($manager=$dept->getManager()))
                 $recipients[]= $manager;
 
-            $sentlist=array();
-            $options = array(
-                'inreplyto'=>$note->getEmailMessageId(),
-                'references'=>$note->getEmailReferences(),
-                'thread'=>$note);
+            $sentlist = $options = array();
+            if ($note) {
+                $options += array(
+                    'inreplyto'=>$note->getEmailMessageId(),
+                    'references'=>$note->getEmailReferences(),
+                    'thread'=>$note);
+            }
             foreach( $recipients as $k=>$staff) {
                 if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue;
                 $alert = $this->replaceVars($msg, array('recipient' => $staff));
@@ -2030,9 +2043,7 @@ implements RestrictedAccess, Threadable, TemplateVariable {
         if ($dept->assignMembersOnly() && !$dept->isMember($thisstaff))
             return false;
 
-        $comments = sprintf(_S('Ticket claimed by %s'), $thisstaff->getName());
-
-        return $this->assignToStaff($thisstaff->getId(), $comments, false);
+        return $this->assignToStaff($thisstaff->getId(), null, false);
     }
 
     function assignToStaff($staff, $note, $alert=true) {
@@ -2044,7 +2055,14 @@ implements RestrictedAccess, Threadable, TemplateVariable {
             return false;
 
         $this->onAssign($staff, $note, $alert);
-        $this->logEvent('assigned');
+
+        global $thisstaff;
+        $data = array();
+        if ($staff->getId() == $thisstaff->getId())
+            $data['claim'] = true;
+        else
+            $data['staff'] = $staff->getId();
+        $this->logEvent('assigned', $data);
 
         return true;
     }
@@ -2063,7 +2081,7 @@ implements RestrictedAccess, Threadable, TemplateVariable {
             $this->setStaffId(0);
 
         $this->onAssign($team, $note, $alert);
-        $this->logEvent('assigned');
+        $this->logEvent('assigned', array('team' => $team->getId()));
 
         return true;
     }
@@ -2134,18 +2152,15 @@ implements RestrictedAccess, Threadable, TemplateVariable {
         $this->collaborators = null;
         $this->recipients = null;
 
-        //Log an internal note
-        $note = sprintf(_S('%s changed ticket ownership to %s'),
-                $thisstaff->getName(), $user->getName());
-
-        //Remove the new owner from list of collaborators
+        // Remove the new owner from list of collaborators
         $c = Collaborator::lookup(array(
-                    'user_id' => $user->getId(),
-                    'thread_id' => $this->getThreadId()));
-        if ($c && $c->delete())
-            $note.= ' '._S('(removed as collaborator)');
+            'user_id' => $user->getId(),
+            'thread_id' => $this->getThreadId()
+        ));
+        if ($c)
+            $c->delete();
 
-        $this->logNote('Ticket ownership changed', $note);
+        $this->logEvent('edited', array('owner' => $user->getId()));
 
         return true;
     }
@@ -2184,18 +2199,11 @@ implements RestrictedAccess, Threadable, TemplateVariable {
 
                 if (($user=User::fromVars($recipient)))
                     if ($c=$this->addCollaborator($user, $info, $errors))
-                        $collabs[] = sprintf('%s%s',
-                            (string) $c,
-                            $recipient['source']
-                                ? " ".sprintf(_S('via %s'), $recipient['source'])
-                                : ''
-                            );
+                        $collabs[] = array((string)$c, $recipient['source']);
             }
             //TODO: Can collaborators add others?
             if ($collabs) {
-                //TODO: Change EndUser to name of  user.
-                $this->logNote(_S('Collaborators added by end user'),
-                        implode("<br>", $collabs), _S('End User'), false);
+                $this->logEvent('collab', array('add' => $collabs));
             }
         }
 
@@ -2411,31 +2419,8 @@ implements RestrictedAccess, Threadable, TemplateVariable {
     }
 
     // History log -- used for statistics generation (pretty reports)
-    function logEvent($state, $annul=null, $staff=null) {
-        global $thisstaff;
-
-        if ($staff === null) {
-            if ($thisstaff) $staff=$thisstaff->getUserName();
-            else $staff='SYSTEM';               # XXX: Security Violation ?
-        }
-        # Annul previous entries if requested (for instance, reopening a
-        # ticket will annul an 'closed' entry). This will be useful to
-        # easily prevent repeated statistics.
-        if ($annul) {
-            db_query('UPDATE '.TICKET_EVENT_TABLE.' SET annulled=1'
-                .' WHERE ticket_id='.db_input($this->getId())
-                  .' AND state='.db_input($annul));
-        }
-
-        return db_query('INSERT INTO '.TICKET_EVENT_TABLE
-            .' SET ticket_id='.db_input($this->getId())
-            .', staff_id='.db_input($this->getStaffId())
-            .', team_id='.db_input($this->getTeamId())
-            .', dept_id='.db_input($this->getDeptId())
-            .', topic_id='.db_input($this->getTopicId())
-            .', timestamp=NOW(), state='.db_input($state)
-            .', staff='.db_input($staff))
-            && db_affected_rows() == 1;
+    function logEvent($state, $data=null, $annul=null, $staff=null) {
+        $this->getThread()->getEvents()->log($this, $state, $data, $annul, $staff);
     }
 
     //Insert Internal Notes
@@ -2593,7 +2578,6 @@ implements RestrictedAccess, Threadable, TemplateVariable {
         $fields['slaId']    = array('type'=>'int',      'required'=>0, 'error'=>__('Select a valid SLA'));
         $fields['duedate']  = array('type'=>'date',     'required'=>0, 'error'=>__('Invalid date format - must be MM/DD/YY'));
 
-        $fields['note']     = array('type'=>'text',     'required'=>1, 'error'=>__('A reason for the update is required'));
         $fields['user_id']  = array('type'=>'int',      'required'=>0, 'error'=>__('Invalid user-id'));
 
         if(!Validator::process($fields, $vars, $errors) && !$errors['err'])
@@ -2644,16 +2628,16 @@ implements RestrictedAccess, Threadable, TemplateVariable {
         if(!db_query($sql) || !db_affected_rows())
             return false;
 
-        if(!$vars['note'])
-            $vars['note']=sprintf(_S('Ticket details updated by %s'), $thisstaff->getName());
-
-        $this->logNote(_S('Ticket Updated'), $vars['note'], $thisstaff);
+        if ($vars['note'])
+            $this->logNote(_S('Ticket Updated'), $vars['note'], $thisstaff);
 
         // Decide if we need to keep the just selected SLA
         $keepSLA = ($this->getSLAId() != $vars['slaId']);
 
         // Update dynamic meta-data
+        $changes = array();
         foreach ($forms as $f) {
+            $changes += $f->getChanges();
             // Drop deleted forms
             $idx = array_search($f->getId(), $vars['forms']);
             if ($idx === false) {
@@ -2665,6 +2649,9 @@ implements RestrictedAccess, Threadable, TemplateVariable {
             }
         }
 
+        if ($changes)
+            $this->logEvent('edited', array('fields' => $changes));
+
         // Reload the ticket so we can do further checking
         $this->reload();
 
@@ -3262,10 +3249,7 @@ implements RestrictedAccess, Threadable, TemplateVariable {
             }
             //TODO: Can collaborators add others?
             if ($collabs) {
-                //TODO: Change EndUser to name of  user.
-                $ticket->logNote(sprintf(_S('Collaborators for %s organization added'),
-                        $org->getName()),
-                    implode("<br>", $collabs), $org->getName(), false);
+                $ticket->logEvent('collab', array('org' => $org->getId()));
             }
         }
 
@@ -3418,11 +3402,6 @@ implements RestrictedAccess, Threadable, TemplateVariable {
             }
             $ticket->logNote(_S('New Ticket'), $vars['note'], $thisstaff, false);
         }
-        else {
-            // Not assignment and no internal note - log activity
-            $ticket->logActivity(_S('New Ticket by Agent'),
-                sprintf(_S('Ticket created by agent - %s'), $thisstaff->getName()));
-        }
 
         $ticket->reload();
 
@@ -3493,9 +3472,8 @@ implements RestrictedAccess, Threadable, TemplateVariable {
 
         if(($res=db_query($sql)) && db_num_rows($res)) {
             while(list($id)=db_fetch_row($res)) {
-                if(($ticket=Ticket::lookup($id)) && $ticket->markOverdue())
-                    $ticket->logActivity(_S('Ticket Marked Overdue'),
-                        _S('Ticket flagged as overdue by the system.'));
+                if ($ticket=Ticket::lookup($id))
+                    $ticket->markOverdue();
             }
         } else {
             //TODO: Trigger escalation on already overdue tickets - make sure last overdue event > grace_period.
diff --git a/include/class.user.php b/include/class.user.php
index 1b4e309d8af92aae4aef4e277ae23df2c0ffb894..fee4c7a91566621a8d9bd37e0cfdfb9a701c53da 100644
--- a/include/class.user.php
+++ b/include/class.user.php
@@ -39,7 +39,7 @@ class UserModel extends VerySimpleModel {
     static $meta = array(
         'table' => USER_TABLE,
         'pk' => array('id'),
-        'select_related' => array('default_email'),
+        'select_related' => array('account', 'default_email'),
         'joins' => array(
             'emails' => array(
                 'reverse' => 'UserEmailModel.user',
@@ -123,6 +123,9 @@ class UserModel extends VerySimpleModel {
         return $this->default_email;
     }
 
+    function hasAccount() {
+        return !is_null($this->account);
+    }
     function getAccount() {
         return $this->account;
     }
@@ -264,6 +267,30 @@ implements TemplateVariable {
     function getEmail() {
         return new EmailAddress($this->default_email->address);
     }
+    /**
+     * Get either a Gravatar URL or complete image tag for a specified email address.
+     *
+     * @param string $email The email address
+     * @param string $s Size in pixels, defaults to 80px [ 1 - 2048 ]
+     * @param string $d Default imageset to use [ 404 | mm | identicon | monsterid | wavatar ]
+     * @param string $r Maximum rating (inclusive) [ g | pg | r | x ]
+     * @param boole $img True to return a complete IMG tag False for just the URL
+     * @param array $atts Optional, additional key/value attributes to include in the IMG tag
+     * @return String containing either just a URL or a complete image tag
+     * @source http://gravatar.com/site/implement/images/php/
+     */
+    function get_gravatar($s = 80, $img = false, $atts = array(), $d = 'retro', $r = 'g' ) {
+        $url = '//www.gravatar.com/avatar/';
+        $url .= md5( strtolower( $this->default_email->address ) );
+        $url .= "?s=$s&d=$d&r=$r";
+        if ( $img ) {
+            $url = '<img src="' . $url . '"';
+            foreach ( $atts as $key => $val )
+                $url .= ' ' . $key . '="' . $val . '"';
+            $url .= ' />';
+        }
+        return $url;
+    }
 
     function getFullName() {
         return $this->name;
diff --git a/include/client/templates/thread-entries.tmpl.php b/include/client/templates/thread-entries.tmpl.php
new file mode 100644
index 0000000000000000000000000000000000000000..d031182cad2fed9947e5bf75a953b05fb81b0374
--- /dev/null
+++ b/include/client/templates/thread-entries.tmpl.php
@@ -0,0 +1,49 @@
+<?php
+$events = $events
+    ->filter(array('state__in' => array('created', 'closed', 'reopened', 'edited', 'collab')))
+    ->order_by('id');
+$events = $events->getIterator();
+$events->rewind();
+$event = $events->current();
+
+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_CLIENT);
+                $events->next();
+                $event = $events->current();
+            }
+            include 'thread-entry.tmpl.php';
+        }
+        $i++;
+    }
+}
+
+// Emit all other events
+while ($event) {
+    $event->render(ThreadEvent::MODE_CLIENT);
+    $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>';
+}
diff --git a/include/client/templates/thread-entry.tmpl.php b/include/client/templates/thread-entry.tmpl.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe9f686e8201d0e1ee66551dd0d29e55c6ec7039
--- /dev/null
+++ b/include/client/templates/thread-entry.tmpl.php
@@ -0,0 +1,91 @@
+<?php
+$entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note');
+$user = $entry->getUser() ?: $entry->getStaff();
+$name = $user ? $user->getName() : $entry->poster;
+$avatar = '';
+if ($user && ($url = $user->get_gravatar(48)))
+    $avatar = "<img class=\"avatar\" src=\"{$url}\"> ";
+?>
+
+<div class="thread-entry <?php echo $entryTypes[$entry->type]; ?> <?php if ($avatar) echo 'avatar'; ?>">
+<?php if ($avatar) { ?>
+    <span class="<?php echo ($entry->type == 'M') ? 'pull-left' : 'pull-right'; ?> avatar">
+<?php echo $avatar; ?>
+    </span>
+<?php } ?>
+    <div class="header">
+        <div class="pull-right">
+<?php           if ($entry->hasActions()) {
+            $actions = $entry->getActions(); ?>
+            <span class="muted-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>">
+                <i class="icon-caret-down"></i>
+            </span>
+            <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right">
+        <ul class="title">
+<?php               foreach ($actions as $group => $list) {
+                foreach ($list as $id => $action) { ?>
+            <li>
+            <a class="no-pjax" href="#" onclick="javascript:
+                    <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;">
+                <i class="<?php echo $action->getIcon(); ?>"></i> <?php
+                    echo $action->getName();
+        ?></a></li>
+<?php                   }
+            } ?>
+        </ul>
+        </div>
+<?php           } ?>
+                <span style="vertical-align:middle;" class="textra">
+        <?php if ($entry->flags & ThreadEntry::FLAG_EDITED) { ?>
+                <span class="label label-bare" title="<?php
+        echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated), 'You');
+                ?>"><?php echo __('Edited'); ?></span>
+        <?php } ?>
+                </span>
+        </div>
+<?php
+            echo sprintf(__('<b>%s</b> posted %s'), $name,
+                sprintf('<time class="relative" datetime="%s" title="%s">%s</time>',
+                    date(DateTime::W3C, Misc::db2gmtime($entry->created)),
+                    Format::daydatetime($entry->created),
+                    Format::relativeTime(Misc::db2gmtime($entry->created))
+                )
+            ); ?>
+            <span style="max-width:500px" class="faded title truncate"><?php
+                echo $entry->title; ?></span>
+            </span>
+    </div>
+    <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>">
+        <div><?php echo $entry->getBody()->toHtml(); ?></div>
+<?php
+    if ($entry->has_attachments) { ?>
+    <div class="attachments"><?php
+        foreach ($entry->attachments as $A) {
+            if ($A->inline)
+                continue;
+            $size = '';
+            if ($A->file->size)
+                $size = sprintf('<small class="filesize faded">%s</small>', Format::file_size($A->file->size));
+?>
+        <span class="attachment-info">
+        <i class="icon-paperclip icon-flip-horizontal"></i>
+        <a class="no-pjax truncate filename" href="<?php echo $A->file->getDownloadUrl();
+            ?>" download="<?php echo Format::htmlchars($A->file->name); ?>"
+            target="_blank"><?php echo Format::htmlchars($A->file->name);
+        ?></a><?php echo $size;?>
+        </span>
+<?php   }  ?>
+    </div>
+<?php } ?>
+    </div>
+<?php
+    if ($urls = $entry->getAttachmentUrls()) { ?>
+        <script type="text/javascript">
+            $('#thread-id-<?php echo $entry->getId(); ?>')
+                .data('urls', <?php
+                    echo JsonDataEncoder::encode($urls); ?>)
+                .data('id', <?php echo $entry->getId(); ?>);
+        </script>
+<?php
+    } ?>
+</div>
diff --git a/include/client/templates/thread-event.tmpl.php b/include/client/templates/thread-event.tmpl.php
new file mode 100644
index 0000000000000000000000000000000000000000..42fd8027e0024324e15407e0cd4add7c0b69dc83
--- /dev/null
+++ b/include/client/templates/thread-event.tmpl.php
@@ -0,0 +1,11 @@
+<?php
+$desc = $event->getDescription(ThreadEvent::MODE_CLIENT);
+if (!$desc)
+    return;
+?>
+<div class="thread-event <?php if ($event->uid) echo 'action'; ?>">
+        <span class="type-icon">
+          <i class="faded icon-<?php echo $event->getIcon(); ?>"></i>
+        </span>
+        <span class="faded description"><?php echo $desc; ?></span>
+</div>
diff --git a/include/client/view.inc.php b/include/client/view.inc.php
index 4f96855d7fe0822a3c14c7a27877d9c26bd601b3..f11afbabf28d8c5fb1b6be76386fc3d853905e45 100644
--- a/include/client/view.inc.php
+++ b/include/client/view.inc.php
@@ -126,55 +126,13 @@ foreach (DynamicFormEntry::forTicket($ticket->getId()) as $form) {
 </tr>
 </table>
 <br>
+
 <div id="ticketThread">
 <?php
-if($ticket->getThreadCount() && ($thread=$ticket->getClientThread())) {
-    $threadType=array('M' => 'message', 'R' => 'response');
-    foreach($thread as $entry) {
-
-        //Making sure internal notes are not displayed due to backend MISTAKES!
-        if(!$threadType[$entry->type]) continue;
-        $poster = $entry->poster;
-        if($entry->type=='R' && ($cfg->hideStaffName() || !$entry->staff_id))
-            $poster = ' ';
-        ?>
-        <table class="thread-entry <?php echo $threadType[$entry->type]; ?>" cellspacing="0" cellpadding="1" width="800" border="0">
-            <tr><th><div>
-<?php echo Format::datetime($entry->created); ?>
-                &nbsp;&nbsp;<span class="textra"></span>
-                <span><?php echo $poster; ?></span>
-            </div>
-            </th></tr>
-            <tr><td class="thread-body"><div><?php echo Format::clickableurls($entry->getBody()->toHtml()); ?></div></td></tr>
-            <?php
-            $urls = null;
-            if ($entry->has_attachments
-                && ($urls = $entry->getAttachmentUrls())) { ?>
-            <tr>
-                <td class="info"><?php
-                    foreach ($entry->attachments as $A) {
-                        if ($A->inline) continue;
-                        $size = '';
-                        if ($A->file->size)
-                            $size = sprintf('<em>(%s)</em>',
-                                Format::file_size($A->file->size));
-?>
-                &nbsp; <i class="icon-paperclip"></i>
-                <a class="no-pjax" href="<?php echo $A->file->getDownloadUrl();
-                    ?>" download="<?php echo Format::htmlchars($A->file->name); ?>"
-                        target="_blank">
-                <?php echo Format::htmlchars($A->file->name);
-                ?></a><?php echo $size;?>&nbsp;
-<?php               } ?>
-                </td>
-            </tr>
-<?php       } ?>
-        </table>
-    <?php
-    }
-}
+    $ticket->getThread()->render(array('M', 'R'), Thread::MODE_CLIENT);
 ?>
 </div>
+
 <div class="clear" style="padding-bottom:10px;"></div>
 <?php if($errors['err']) { ?>
     <div id="msg_error"><?php echo $errors['err']; ?></div>
diff --git a/include/staff/faq-category.inc.php b/include/staff/faq-category.inc.php
index afc5194bb7c08ebfe4ea4caeed74072a5b434db4..b6287bcb9eb3779a9aab6aa5a49dc244683e1856 100644
--- a/include/staff/faq-category.inc.php
+++ b/include/staff/faq-category.inc.php
@@ -11,7 +11,7 @@ if(!defined('OSTSTAFFINC') || !$category || !$thisstaff) die('Access Denied');
 <div>
     <strong><?php echo $category->getName() ?></strong>
     <span>(<?php echo $category->isPublic()?__('Public'):__('Internal'); ?>)</span>
-    <time> <?php echo __('Last updated').' '. Format::daydatetime($category->getUpdateDate()); ?></time>
+    <time class="faq"> <?php echo __('Last updated').' '. Format::daydatetime($category->getUpdateDate()); ?></time>
 </div>
 <div class="cat-desc">
 <?php echo Format::display($category->getDescription()); ?>
diff --git a/include/staff/templates/thread-entries.tmpl.php b/include/staff/templates/thread-entries.tmpl.php
index 2e7da2c389cf2b276b139a5573e040fb0e6590a9..f15ea1e061997647ba4ec246a630008f412a0c8e 100644
--- a/include/staff/templates/thread-entries.tmpl.php
+++ b/include/staff/templates/thread-entries.tmpl.php
@@ -1,90 +1,47 @@
 <?php
-$entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note');
-if ($entries) {
-    foreach ($entries as $entry) { ?>
-    <table class="thread-entry <?php echo $entryTypes[$entry->type]; ?>" cellspacing="0" cellpadding="1" width="940" border="0">
-        <tr>
-            <th colspan="4" width="100%">
-            <div>
-                <span class="pull-left">
-                <span style="display:inline-block"><?php
-                    echo Format::datetime($entry->created);?></span>
-                <span style="display:inline-block;padding:0 1em;max-width: 500px" class="faded title truncate"><?php
-                    echo $entry->title; ?></span>
-                </span>
-            <div class="pull-right">
-<?php           if ($entry->hasActions()) {
-                $actions = $entry->getActions(); ?>
-                <span class="action-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>">
-                    <i class="icon-caret-down"></i>
-                    <span ><i class="icon-cog"></i></span>
-                </span>
-                <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right">
-            <ul class="title">
-<?php               foreach ($actions as $group => $list) {
-                    foreach ($list as $id => $action) { ?>
-                <li>
-                <a class="no-pjax" href="#" onclick="javascript:
-                        <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;">
-                    <i class="<?php echo $action->getIcon(); ?>"></i> <?php
-                        echo $action->getName();
-            ?></a></li>
-<?php                   }
-                } ?>
-            </ul>
-            </div>
-<?php           } ?>
-                <span style="vertical-align:middle">
-                    <span style="vertical-align:middle;" class="textra">
-        <?php if ($entry->flags & ThreadEntry::FLAG_EDITED) { ?>
-                <span class="label label-bare" title="<?php
-        echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated), 'You');
-                ?>"><?php echo __('Edited'); ?></span>
-        <?php } ?>
-                    </span>
-                    <span style="vertical-align:middle;"
-                        class="tmeta faded title"><?php
-                        echo Format::htmlchars($entry->getName()); ?></span>
-                </span>
-            </div>
-            </th>
-        </tr>
-        <tr><td colspan="4" class="thread-body" id="thread-id-<?php
-            echo $entry->getId(); ?>"><div><?php
-            echo $entry->getBody()->toHtml(); ?></div></td></tr>
-        <?php
-        $urls = null;
-        if ($entry->has_attachments
-            && ($urls = $entry->getAttachmentUrls())) { ?>
-        <tr>
-            <td class="info" colspan="4"><?php
-                foreach ($entry->attachments as $A) {
-                    if ($A->inline) continue;
-                    $size = '';
-                    if ($A->file->size)
-                        $size = sprintf('<em>(%s)</em>',
-                            Format::file_size($A->file->size));
-?>
-            <a class="Icon file no-pjax" href="<?php echo $A->file->getDownloadUrl();
-                ?>" download="<?php echo Format::htmlchars($A->file->name); ?>"
-                target="_blank"><?php echo Format::htmlchars($A->file->name);
-            ?></a><?php echo $size;?>&nbsp;
-<?php               } ?>
-            </td>
-        </tr> <?php
+$events = $events->order_by('id');
+$events = $events->getIterator();
+$events->rewind();
+$event = $events->current();
+
+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();
+            }
+            include STAFFINC_DIR . 'templates/thread-entry.tmpl.php';
         }
-        if ($urls) { ?>
-            <script type="text/javascript">
-                $('#thread-id-<?php echo $entry->getId(); ?>')
-                    .data('urls', <?php
-                        echo JsonDataEncoder::encode($urls); ?>)
-                    .data('id', <?php echo $entry->getId(); ?>);
-            </script>
-<?php
-        } ?>
-    </table>
-    <?php
+        $i++;
     }
-} else {
+}
+
+// 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>';
-}?>
+}
diff --git a/include/staff/templates/thread-entry.tmpl.php b/include/staff/templates/thread-entry.tmpl.php
new file mode 100644
index 0000000000000000000000000000000000000000..f06b376ef463876cfcbcc5df7737acde11d34cd9
--- /dev/null
+++ b/include/staff/templates/thread-entry.tmpl.php
@@ -0,0 +1,91 @@
+<?php
+$entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note');
+$user = $entry->getUser() ?: $entry->getStaff();
+$name = $user ? $user->getName() : $entry->poster;
+$avatar = '';
+if ($user && ($url = $user->get_gravatar(48)))
+    $avatar = "<img class=\"avatar\" src=\"{$url}\"> ";
+?>
+
+<div class="thread-entry <?php echo $entryTypes[$entry->type]; ?> <?php if ($avatar) echo 'avatar'; ?>">
+<?php if ($avatar) { ?>
+    <span class="<?php echo ($entry->type == 'M') ? 'pull-right' : 'pull-left'; ?> avatar">
+<?php echo $avatar; ?>
+    </span>
+<?php } ?>
+    <div class="header">
+        <div class="pull-right">
+<?php           if ($entry->hasActions()) {
+            $actions = $entry->getActions(); ?>
+            <span class="muted-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>">
+                <i class="icon-caret-down"></i>
+            </span>
+            <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right">
+        <ul class="title">
+<?php               foreach ($actions as $group => $list) {
+                foreach ($list as $id => $action) { ?>
+            <li>
+            <a class="no-pjax" href="#" onclick="javascript:
+                    <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;">
+                <i class="<?php echo $action->getIcon(); ?>"></i> <?php
+                    echo $action->getName();
+        ?></a></li>
+<?php                   }
+            } ?>
+        </ul>
+        </div>
+<?php           } ?>
+                <span style="vertical-align:middle;" class="textra">
+        <?php if ($entry->flags & ThreadEntry::FLAG_EDITED) { ?>
+                <span class="label label-bare" title="<?php
+        echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated), 'You');
+                ?>"><?php echo __('Edited'); ?></span>
+        <?php } ?>
+                </span>
+        </div>
+<?php
+            echo sprintf(__('<b>%s</b> posted %s'), $name,
+                sprintf('<time class="relative" datetime="%s" title="%s">%s</time>',
+                    date(DateTime::W3C, Misc::db2gmtime($entry->created)),
+                    Format::daydatetime($entry->created),
+                    Format::relativeTime(Misc::db2gmtime($entry->created))
+                )
+            ); ?>
+            <span style="max-width:500px" class="faded title truncate"><?php
+                echo $entry->title; ?></span>
+            </span>
+    </div>
+    <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>">
+        <div><?php echo $entry->getBody()->toHtml(); ?></div>
+<?php
+    if ($entry->has_attachments) { ?>
+    <div class="attachments"><?php
+        foreach ($entry->attachments as $A) {
+            if ($A->inline)
+                continue;
+            $size = '';
+            if ($A->file->size)
+                $size = sprintf('<small class="filesize faded">%s</small>', Format::file_size($A->file->size));
+?>
+        <span class="attachment-info">
+        <i class="icon-paperclip icon-flip-horizontal"></i>
+        <a class="no-pjax truncate filename" href="<?php echo $A->file->getDownloadUrl();
+            ?>" download="<?php echo Format::htmlchars($A->file->name); ?>"
+            target="_blank"><?php echo Format::htmlchars($A->file->name);
+        ?></a><?php echo $size;?>
+        </span>
+<?php   }  ?>
+    </div>
+<?php } ?>
+    </div>
+<?php
+    if ($urls = $entry->getAttachmentUrls()) { ?>
+        <script type="text/javascript">
+            $('#thread-id-<?php echo $entry->getId(); ?>')
+                .data('urls', <?php
+                    echo JsonDataEncoder::encode($urls); ?>)
+                .data('id', <?php echo $entry->getId(); ?>);
+        </script>
+<?php
+    } ?>
+</div>
diff --git a/include/staff/templates/thread-event.tmpl.php b/include/staff/templates/thread-event.tmpl.php
new file mode 100644
index 0000000000000000000000000000000000000000..f98a1e3200776ca8727d586ee1dc47ead485540a
--- /dev/null
+++ b/include/staff/templates/thread-event.tmpl.php
@@ -0,0 +1,8 @@
+<div class="thread-event <?php if ($event->uid) echo 'action'; ?>">
+        <span class="type-icon">
+          <i class="faded icon-<?php echo $event->getIcon(); ?>"></i>
+        </span>
+        <span class="faded description">
+            <?php echo $event->getDescription(ThreadEvent::MODE_STAFF); ?>
+        </span>
+</div>
diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php
index 74407ea1952c951da73f9345baa0dc6fe88ddb5a..baf81ca4927c0f88ac121a4ca1763601b2f4c0f5 100644
--- a/include/staff/ticket-view.inc.php
+++ b/include/staff/ticket-view.inc.php
@@ -422,7 +422,7 @@ foreach (DynamicFormEntry::forTicket($ticket->getId()) as $form) {
 <?php
 $tcount = $ticket->getThreadEntries($types)->count();
 ?>
-<ul  class="tabs threads" id="ticket_tabs" >
+<ul  class="tabs clean threads" id="ticket_tabs" >
     <li class="active"><a href="#ticket_thread"><?php echo sprintf(__('Ticket Thread (%d)'), $tcount); ?></a></li>
     <li><a id="ticket_tasks" href="#tasks"
             data-url="<?php
@@ -437,6 +437,7 @@ $tcount = $ticket->getThreadEntries($types)->count();
     <?php
     $ticket->getThread()->render(array('M', 'R', 'N'));
     ?>
+    </div>
 <div class="clear" style="padding-bottom:10px;"></div>
 <?php if($errors['err']) { ?>
     <div id="msg_error"><?php echo $errors['err']; ?></div>
diff --git a/include/upgrader/streams/core/9143a511-00000000.patch.sql b/include/upgrader/streams/core/9143a511-00000000.patch.sql
new file mode 100644
index 0000000000000000000000000000000000000000..10a6a23214346ab8fdc42578ae01fcdf867ef5a8
--- /dev/null
+++ b/include/upgrader/streams/core/9143a511-00000000.patch.sql
@@ -0,0 +1,28 @@
+
+ALTER TABLE `%TABLE_PREFIX%ticket_event`
+  ADD `id` int(10) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST,
+  CHANGE `ticket_id` `thread_id` int(11) unsigned NOT NULL default '0',
+  CHANGE `staff` `username` varchar(128) NOT NULL default 'SYSTEM',
+  CHANGE `state` `state` enum('created','closed','reopened','assigned','transferred','overdue','edited','viewed','error','collab','resent') NOT NULL,
+  ADD `data` varchar(1024) DEFAULT NULL COMMENT 'Encoded differences' AFTER `state`,
+  ADD `uid` int(11) unsigned DEFAULT NULL AFTER `username`,
+  ADD `uid_type` char(1) NOT NULL DEFAULT 'S' AFTER `uid`,
+  RENAME TO `%TABLE_PREFIX%thread_event`;
+
+-- Change the `ticket_id` column to the values in `%thread`.`id`
+CREATE TABLE `%TABLE_PREFIX%_ticket_thread_evt`
+    (PRIMARY KEY (`object_id`))
+    SELECT `object_id`, `id` FROM `%TABLE_PREFIX%thread`
+    WHERE `object_type` = 'T';
+
+UPDATE `%TABLE_PREFIX%thread_event` A1
+    JOIN `%TABLE_PREFIX%_ticket_thread_evt` A2 ON (A1.`thread_id` = A2.`object_id`)
+    SET A1.`thread_id` = A2.`id`;
+
+DROP TABLE `%TABLE_PREFIX%_ticket_thread_evt`;
+
+-- Attempt to connect the `username` to the staff_id
+UPDATE `%TABLE_PREFIX%thread_event` A1
+    LEFT JOIN `%TABLE_PREFIX%staff` A2 ON (A2.`username` = A1.`username`)
+    SET A1.`uid` = A2.`staff_id`
+    WHERE A1.`username` != 'SYSTEM';
diff --git a/scp/css/scp.css b/scp/css/scp.css
index 4dd7cef3afd1c4cf0b48512b451375e6146e447f..6b2d2a33cff3c73f1c6014ed3ae13fdbb6c60634 100644
--- a/scp/css/scp.css
+++ b/scp/css/scp.css
@@ -55,10 +55,21 @@ div#header a {
     color: #666;
     color: rgba(0,0,0,0.5);
 }
+.faded b {
+    color: #333;
+    color: rgba(0,0,0,0.75);
+}
+.faded strong {
+    color: #444;
+    color: rgba(0,0,0,0.6);
+}
 .faded-more {
     color: #aaa;
     color: rgba(0,0,0,0.4);
 }
+time[title]:hover {
+    text-decoration: underline;
+}
 
 .small[class^="icon-"],
 .small[class*=" icon-"] {
@@ -452,7 +463,7 @@ a.Icon:hover {
     background:#fff;
 }
 
-a:not(.re-icon) {
+a {
     color:#184E81;
 }
 
@@ -821,9 +832,6 @@ h2 .reload {
     border:1px solid #f90;
 }
 
-
-
-
 #ticket_actions {
     padding:5px;
     background:#eee;
@@ -831,66 +839,151 @@ h2 .reload {
     border-bottom:none;
     margin:0;
 }
-
-#toggle_ticket_thread {
-    background:url(../images/icons/open.gif) 10px 50% no-repeat;
+.thread-entry {
+    margin-bottom: 15px;
+    z-index: 0;
 }
-
-#toggle_notes {
-    background:url(../images/icons/note.gif) 10px 50% no-repeat;
+.thread-entry::after {
+  content: "";
+  border-bottom: 2px solid white;
+  display: block;
 }
-
-table.thread-entry {
-    margin-top:10px;
-    border:1px solid #aaa;
-    border-bottom:2px solid #aaa;
+.thread-entry::before {
+  content: "";
+  display: block;
+  border-top: 2px solid white;
 }
-
-#ticket_notes table {
-    margin-top:10px;
-    border:1px solid #ddd;
-    border-bottom:2px solid #ddd;
+.thread-entry.avatar {
+    margin-left: 60px;
 }
-
-table.thread-entry th, #ticket_notes table th {
-    text-align:left;
-    border-bottom:1px solid #aaa;
-    font-size:10pt;
-    padding:5px;
+.thread-entry.message.avatar {
+    margin-right: 60px;
+    margin-left: 0;
 }
-
-#ticket_notes table th {
-    text-align:left;
-    border-bottom:1px solid #ddd;
-    font-size:10pt;
-    padding:5px;
-    background:#F4FAFF;
+.thread-entry > .avatar {
+    margin-left: -60px;
+    display:inline-block;
+    width:48px;
+    height:auto;
+    border-radius: 5px;
 }
-
-#ticket_notes table th em {
-    font-weight:normal;
-    font-size:10pt;
-    color:#666;
+.thread-entry.message > .avatar {
+    margin-left: initial;
+    margin-right: -60px;
 }
-
-#ticket_notes .date {
-    font-weight:normal;
-    font-size:10pt;
-    color:#888;
-    text-align:right;
+img.avatar {
+    border-radius: inherit;
+}
+.thread-entry .header {
+    padding: 8px 0.9em;
+    border: 1px solid #ccc;
+    border-color: rgba(0,0,0,0.2);
+    border-radius: 5px 5px 0 0;
+}
+.thread-entry.avatar .header:before {
+  position: absolute;
+  top: 7px;
+  right: -8px;
+  content: '';
+  border-top: 8px solid transparent;
+  border-bottom: 8px solid transparent;
+  border-left: 8px solid #9cadcc;
+  display: inline-block;
+}
+.thread-entry.avatar .header:after {
+  position: absolute;
+  top: 7px;
+  right: -8px;
+  content: '';
+  border-top: 7px solid transparent;
+  border-bottom: 7px solid transparent;
+  display: inline-block;
+  margin-top: 1px;
+}
+
+.thread-entry.avatar .header {
+    position: relative;
 }
 
-.thread-entry.message th {
+.thread-entry.message .header {
     background:#C3D9FF;
 }
+.thread-entry.avatar.message .header:after {
+    border-left: 7px solid #C3D9FF;
+    margin-right: 1px;
+}
 
-.thread-entry.response th {
+.thread-entry.response .header {
     background:#FFE0B3;
 }
+.thread-entry.avatar.response .header:before,
+.thread-entry.avatar.note .header:before {
+    top: 7px;
+    left: -8px;
+    right: initial;
+    border-left: none;
+    border-right: 8px solid #CCC;
+}
+.thread-entry.note:not(.avatar) .header {
+    background-color: #f4f4f4;
+}
+.thread-entry.avatar.response .header:before {
+    border-right-color: #ccb3af;
+}
+.thread-entry.avatar.note .header:before {
+    border-right-color: #ccccb0;
+}
+.thread-entry.avatar.response .header:after,
+.thread-entry.avatar.note .header:after {
+    top: 7px;
+    left: -8px;
+    right: initial;
+    border-left: none;
+    border-right: 7px solid #FFE0B3;
+    margin-left: 1px;
+}
 
-.thread-entry.note th {
+.thread-entry.note .header {
     background:#FFE;
 }
+.thread-entry.avatar.note .header:after {
+    border-right-color: #FFE;
+}
+.thread-entry .header .title {
+    max-width: 500px;
+    vertical-align: bottom;
+    display: inline-block;
+    margin-left: 15px;
+}
+
+.thread-entry .thread-body {
+    border: 1px solid #ddd;
+    border-top: none;
+    border-bottom:2px solid #aaa;
+    border-radius: 0 0 5px 5px;
+}
+.thread-body .attachments {
+  background-color: #f4faff;
+  margin: 0 -0.9em;
+  position: relative;
+  top: 0.9em;
+  padding: 0.3em 0.9em;
+  border-top: 1px dotted #ccc;
+  border-top-color: rgba(0,0,0,0.2);
+  border-radius: 0 0 6px 6px;
+}
+.thread-body .attachments .filesize {
+  margin-left: 0.5em;
+}
+.thread-body .attachment-info {
+    margin-right: 10px;
+    display: inline-block;
+    width: 48%;
+}
+.thread-body .attachment-info .filename {
+  max-width: 80%;
+  max-width: calc(100% - 70px);
+}
 
 #ticket_notes table td {
     padding:5px;
@@ -912,7 +1005,7 @@ table.thread-entry th, #ticket_notes table th {
 }
 
 #response_options {
-    margin-top:30px;
+    margin-top:10px;
 }
 
 #response_options > form {
@@ -1403,7 +1496,7 @@ h2 > i.help-tip {
   background-color:#e9f5ff;
 }
 
-time {
+time.faq {
     display:inline-block;
     float:right;
     color:#777;
@@ -1825,6 +1918,23 @@ div.selected-signature .inner {
     top: 4px;
     right: 5px;
 }
+.muted-button:hover {
+    border: 1px solid #aaa;
+    border: 1px solid rgba(0,0,0,0.3);
+    cursor: pointer;
+    background: rgba(255,255,255,0.1);
+    color: black;
+}
+.muted-button {
+  border-radius: 5px;
+  padding: 1px 5px;
+  margin: -1px 0 -1px 5px;
+  border: 1px solid rgba(0,0,0,0.15);
+  color: #666;
+  color: rgba(0,0,0,0.5);
+  background-color: rgba(0,0,0,0.1);
+  background: linear-gradient(0, rgba(0,0,0,0.1), rgba(255,255,255,0.1));
+}
 
 .sortable-rows tr td:hover {
     cursor: move;
@@ -2003,7 +2113,7 @@ tr.disabled th {
 
 .tab_content {
     position: relative;
-    padding: 5px 0;
+    margin: 5px 0;
 }
 .left-tabs {
     margin-left: 48px;
@@ -2127,6 +2237,7 @@ button a:hover {
     white-space: nowrap;
     overflow: hidden;
     text-overflow: ellipsis;
+    vertical-align: bottom;
 }
 td.indented {
     padding-left: 20px;
@@ -2217,3 +2328,68 @@ td.indented {
 .sticky.bar .content {
   margin: auto;
 }
+
+#ticket_thread::before {
+  border-left: 2px dotted #ddd;
+  border-bottom-color: rgba(0,0,0,0.1);
+  position: absolute;
+  margin-left: 74px;
+  z-index: -1;
+  content: "";
+  top: 0;
+  bottom: 0;
+  right: 0;
+  left: 0;
+}
+#ticket_thread {
+  z-index: 0;
+  position: relative;
+  border-bottom: 2px solid #ddd;
+  border-bottom-color: rgba(0,0,0,0.1);
+}
+.thread-event {
+    padding: 0 2px 15px;
+    margin-left: 60px;
+}
+.type-icon {
+    border-radius: 8px;
+    background-color: #f4f4f4;
+    padding: 4px 6px;
+    margin-right: 5px;
+    text-align: center;
+    display: inline-block;
+    font-size: 1.1em;
+    border: 1px solid #eee;
+    vertical-align: top;
+    position: relative;
+}
+.thread-event .type-icon::after {
+  content: "";
+  border: 16px solid white;
+  position: absolute;
+  top: -3px;
+  bottom: 0;
+  left: -3px;
+  right: 0;
+  z-index: -1;
+}
+.type-icon.dark {
+    border-color: #666;
+    background-color: #949494;
+}
+.thread-event img.avatar {
+    vertical-align: middle;
+    border-radius: 3px;
+    width: auto;
+    max-height: 24px;
+    margin: -3px 3px 0;
+}
+.thread-event .description {
+    margin-left: -30px;
+    padding-top: 6px;
+    padding-left: 30px;
+    display: inline-block;
+    width: 772px;
+    width: calc(100% - 95px);
+    line-height: 1.4em;
+}
diff --git a/scp/js/scp.js b/scp/js/scp.js
index d1c73fc8bf47f33a938b5194803683a234a90534..52ca3e4a4f5196e09c6ff6f77081e0a6cfa7b242 100644
--- a/scp/js/scp.js
+++ b/scp/js/scp.js
@@ -578,6 +578,15 @@ $(document).on('focus', 'form.spellcheck textarea, form.spellcheck input[type=te
     $(this).attr({'spellcheck':'true', 'lang': lang});
 });
 
+$(document).on('click', '.thread-entry-group a', function() {
+    var inner = $(this).parent().find('.thread-entry-group-inner');
+    if (inner.is(':visible'))
+      inner.slideUp();
+    else
+      inner.slideDown();
+    return false;
+});
+
 $.toggleOverlay = function (show) {
   if (typeof(show) === 'undefined') {
     return $.toggleOverlay(!$('#overlay').is(':visible'));
diff --git a/scp/tickets.php b/scp/tickets.php
index 4f0dcec4e3642c8628ed5bc0f743926d746becc2..e78104f0c5f294b8ef3eef02651c034b9918b1df 100644
--- a/scp/tickets.php
+++ b/scp/tickets.php
@@ -186,14 +186,6 @@ if($_POST && !$errors):
                          $errors['assignId']=__('Ticket already assigned to the team.');
                  }
 
-                 //Comments are not required on self-assignment (claim)
-                 if($claim && !$_POST['assign_comments'])
-                     $_POST['assign_comments'] = sprintf(__('Ticket claimed by %s'),$thisstaff->getName());
-                 elseif(!$_POST['assign_comments'])
-                     $errors['assign_comments'] = __('Assignment comments required');
-                 elseif(strlen($_POST['assign_comments'])<5)
-                         $errors['assign_comments'] = __('Comment too short');
-
                  if(!$errors && $ticket->assign($_POST['assignId'], $_POST['assign_comments'], !$claim)) {
                      if($claim) {
                          $msg = __('Ticket is NOW assigned to you!');
diff --git a/setup/inc/streams/core/install-mysql.sql b/setup/inc/streams/core/install-mysql.sql
index 26f129681118b2c67eea32dc5f4b74f78d30a656..3ecb6bff63ea7a9df79958e498b18981fdde17f1 100644
--- a/setup/inc/streams/core/install-mysql.sql
+++ b/setup/inc/streams/core/install-mysql.sql
@@ -712,18 +712,22 @@ CREATE TABLE `%TABLE_PREFIX%lock` (
   KEY `staff_id` (`staff_id`)
 ) DEFAULT CHARSET=utf8;
 
-DROP TABLE IF EXISTS `%TABLE_PREFIX%ticket_event`;
-CREATE TABLE `%TABLE_PREFIX%ticket_event` (
-  `ticket_id` int(11) unsigned NOT NULL default '0',
+DROP TABLE IF EXISTS `%TABLE_PREFIX%thread_event`;
+CREATE TABLE `%TABLE_PREFIX%thread_event` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `thread_id` int(11) unsigned NOT NULL default '0',
   `staff_id` int(11) unsigned NOT NULL,
   `team_id` int(11) unsigned NOT NULL,
   `dept_id` int(11) unsigned NOT NULL,
   `topic_id` int(11) unsigned NOT NULL,
-  `state` enum('created','closed','reopened','assigned','transferred','overdue') NOT NULL,
-  `staff` varchar(255) NOT NULL default 'SYSTEM',
+  `state` enum('created','closed','reopened','assigned','transferred','overdue','edited','viewed','error','collab','resent') NOT NULL,
+  `data` varchar(1024) DEFAULT NULL COMMENT 'Encoded differences',
+  `username` varchar(128) NOT NULL default 'SYSTEM',
+  `uid` int(11) unsigned DEFAULT NULL,
+  `uid_type` char(1) NOT NULL DEFAULT 'S',
   `annulled` tinyint(1) unsigned NOT NULL default '0',
   `timestamp` datetime NOT NULL,
-  KEY `ticket_state` (`ticket_id`, `state`, `timestamp`),
+  KEY `ticket_state` (`thread_id`, `state`, `timestamp`),
   KEY `ticket_stats` (`timestamp`, `state`)
 ) DEFAULT CHARSET=utf8;
 
diff --git a/tickets.php b/tickets.php
index 0c6dd8947c1925f025552e20971b8f8cc0079c82..875fdfec40dc2d4770e4891c8a5cd76832c3a5d7 100644
--- a/tickets.php
+++ b/tickets.php
@@ -49,6 +49,7 @@ if ($_POST && is_object($ticket) && $ticket->getId()) {
             $errors['err']=__('Access Denied. Possibly invalid ticket ID');
         else {
             $forms=DynamicFormEntry::forTicket($ticket->getId());
+            $changes = array();
             foreach ($forms as $form) {
                 $form->setSource($_POST);
                 if (!$form->isValid())
@@ -56,11 +57,13 @@ if ($_POST && is_object($ticket) && $ticket->getId()) {
             }
         }
         if (!$errors) {
-            foreach ($forms as $f) $f->save();
+            foreach ($forms as $f) {
+                $changes += $f->getChanges();
+                $f->save();
+            }
+            if ($changes)
+                $ticket->logEvent('edited', array('fields' => $changes));
             $_REQUEST['a'] = null; //Clear edit action - going back to view.
-            $ticket->logNote(__('Ticket details updated'), sprintf(
-                __('Ticket details were updated by client %s &lt;%s&gt;'),
-                $thisclient->getName(), $thisclient->getEmail()));
         }
         break;
     case 'reply':