Index: htdocs/css/trac.css
===================================================================
--- htdocs/css/trac.css	(revision 3361)
+++ htdocs/css/trac.css	(working copy)
@@ -42,6 +42,21 @@
  color: inherit;
 }
 
+/* Heading anchors */
+.anchor:link, .anchor:visited {
+ border: none;
+ color: #d7d7d7;
+ font-size: .8em;
+ vertical-align: text-top;
+}
+* > .anchor:link, * > .anchor:visited {
+ visibility: hidden;
+}
+h1:hover .anchor, h2:hover .anchor, h3:hover .anchor,
+h4:hover .anchor, h5:hover .anchor, h6:hover .anchor {
+ visibility: visible;
+}
+
 @media screen {
  a.ext-link .icon {
   background: url(../extlink.gif) left center no-repeat;
@@ -277,12 +292,15 @@
 }
 
 blockquote.citation { 
- margin: 0;
+ margin: -0.6em 0;
  border-style: solid; 
  border-width: 0 0 0 2px; 
+ padding-left: .5em;
  border-color: #b44; 
- padding-left: .5em;
 }
+.citation blockquote.citation { border-color: #4b4; }
+.citation .citation blockquote.citation { border-color: #44b; }
+.citation .citation .citation blockquote.citation { border-color: #c55; }
 
 table.wiki {
  border: 2px solid #ccc;
Index: htdocs/css/ticket.css
===================================================================
--- htdocs/css/ticket.css	(revision 3361)
+++ htdocs/css/ticket.css	(working copy)
@@ -46,6 +46,21 @@
  font-size: 100%;
  font-weight: normal;
 }
+.inlinebuttons input { 
+ float: right;
+ font-size: 70%;
+ border-width: 1px;
+ margin: 0 .5em .1em 1.5em;
+ padding: 0.1em;
+ background-color: #f6f6f6;
+}
+.inlinebuttons > input { /* rule ignored by IE */
+ visibility: hidden;
+}
+div.change:hover .inlinebuttons input {
+ visibility: visible;
+}
+
 #changelog .changes { list-style: square; margin-left: 2em; padding: 0 }
 #changelog .comment { margin-left: 2em }
 
Index: htdocs/css/wiki.css
===================================================================
--- htdocs/css/wiki.css	(revision 3361)
+++ htdocs/css/wiki.css	(working copy)
@@ -22,19 +22,6 @@
 #overview .ipnr { color: #999; font-size: 80% }
 #overview .comment { padding: 1em 0 0 }
 
-/* Heading anchors */
-.anchor:link, .anchor:visited {
- border: none;
- color: #d7d7d7;
- font-size: .8em;
- vertical-align: text-top;
- visibility: hidden;
-}
-h1:hover .anchor, h2:hover .anchor, h3:hover .anchor,
-h4:hover .anchor, h5:hover .anchor, h6:hover .anchor {
- visibility: visible;
-}
-
 /* Styles for the page history table
    (extends the styles for "table.listing") */
 #wikihist td { padding: 0 .5em }
Index: trac/ticket/api.py
===================================================================
--- trac/ticket/api.py	(revision 3361)
+++ trac/ticket/api.py	(working copy)
@@ -186,7 +186,8 @@
 
     def get_link_resolvers(self):
         return [('bug', self._format_link),
-                ('ticket', self._format_link)]
+                ('ticket', self._format_link),
+                ('comment', self._format_comment_link)]
 
     def get_wiki_syntax(self):
         yield (
@@ -215,6 +216,29 @@
         return html.A(class_='missing ticket', rel='nofollow',
                       href=formatter.href.ticket(target))[label]
 
+    def _format_comment_link(self, formatter, ns, target, label):
+        type, id, cnum = 'ticket', '1', 0
+        href = None
+        if ':' in target:
+            elts = target.split(':')
+            if len(elts) == 3:
+                type, id, cnum = elts
+                href = formatter.href(type, id)
+        else:
+            # FIXME: the formatter should know which object the text being
+            #        formatted belongs to
+            if formatter.req:
+                path_info = formatter.req.path_info.strip('/').split('/', 2)
+                if len(path_info) == 2:
+                    type, id = path_info[:2]
+                    href = formatter.href(type, id)
+                    cnum = target
+        if href:
+            return html.A(label, href="%s#comment:%s" % (href, cnum),
+                          title="Comment %s for %s:%s" % (cnum, type, id))
+        else:
+            return label
+ 
     # ISearchSource methods
 
     def get_search_filters(self, req):
Index: trac/ticket/web_ui.py
===================================================================
--- trac/ticket/web_ui.py	(revision 3361)
+++ trac/ticket/web_ui.py	(working copy)
@@ -269,9 +269,8 @@
                 if comment:
                     req.hdf['ticket.comment'] = comment
                     # Wiki format a preview of comment
-                    req.hdf['ticket.comment_preview'] = wiki_to_html(comment,
-                                                                     self.env,
-                                                                     req, db)
+                    req.hdf['ticket.comment_preview'] = wiki_to_html(
+                        comment, self.env, req, db)
         else:
             req.hdf['ticket.reassign_owner'] = req.authname
             # Store a timestamp in order to detect "mid air collisions"
@@ -512,8 +511,14 @@
             ticket['resolution'] = ''
 
         now = int(time.time())
+        cnum = req.args.get('cnum')        
+        replyto = req.args.get('replyto')
+        internal_cnum = cnum
+        if cnum and replyto: # record parent.child relationship
+            internal_cnum = '%s.%s' % (replyto, cnum)
         ticket.save_changes(req.args.get('author', req.authname),
-                            req.args.get('comment'), when=now, db=db)
+                            req.args.get('comment'), when=now, db=db,
+                            cnum=internal_cnum)
         db.commit()
 
         try:
@@ -523,14 +528,22 @@
             self.log.exception("Failure sending notification on change to "
                                "ticket #%s: %s" % (ticket.id, e))
 
-        req.redirect(req.href.ticket(ticket.id))
+        fragment = cnum and '#comment:'+cnum or ''
+        req.redirect(req.href.ticket(ticket.id) + fragment)
 
     def _insert_ticket_data(self, req, db, ticket, reporter_id):
         """Insert ticket data into the hdf"""
+        replyto = req.args.get('replyto')
+        req.hdf['title'] = '#%d (%s)' % (ticket.id, ticket['summary'])
         req.hdf['ticket'] = ticket.values
-        req.hdf['ticket.id'] = ticket.id
-        req.hdf['ticket.href'] = req.href.ticket(ticket.id)
+        req.hdf['ticket'] = {
+            'id': ticket.id,
+            'href': req.href.ticket(ticket.id),
+            'replyto': replyto
+            }
 
+        # -- Ticket fields
+        
         for field in TicketSystem(self.env).get_ticket_fields():
             if field['type'] in ('radio', 'select'):
                 value = ticket.values.get(field['name'])
@@ -548,39 +561,67 @@
             req.hdf['ticket.fields.' + name] = field
 
         req.hdf['ticket.reporter_id'] = reporter_id
-        req.hdf['title'] = '#%d (%s)' % (ticket.id, ticket['summary'])
-        req.hdf['ticket.description.formatted'] = wiki_to_html(ticket['description'],
-                                                               self.env, req, db)
+        req.hdf['ticket.description.formatted'] = wiki_to_html(
+            ticket['description'], self.env, req, db)
 
         req.hdf['ticket.opened'] = format_datetime(ticket.time_created)
         req.hdf['ticket.opened_delta'] = pretty_timedelta(ticket.time_created)
         if ticket.time_changed != ticket.time_created:
-            req.hdf['ticket.lastmod'] = format_datetime(ticket.time_changed)
-            req.hdf['ticket.lastmod_delta'] = pretty_timedelta(ticket.time_changed)
+            req.hdf['ticket'] = {
+                'lastmod': format_datetime(ticket.time_changed),
+                'lastmod_delta': pretty_timedelta(ticket.time_changed)
+                }
 
+        # -- Ticket Change History
+
         changelog = ticket.get_changelog(db=db)
-        curr_author = None
-        curr_date   = 0
+        autonum = 0 # used for "root" numbers
+        replies = {}
         changes = []
-        for date, author, field, old, new in changelog:
-            if date != curr_date or author != curr_author:
-                changes.append({
+        last_uid = current = None
+        for date, author, field, old, new, permanent in changelog:
+            uid = date, author, permanent
+            if uid != last_uid:
+                last_uid = uid
+                current = {
                     'date': format_datetime(date),
                     'author': author,
                     'fields': {}
-                })
-                curr_date = date
-                curr_author = author
+                }
+                changes.append(current)
+                if permanent:
+                    autonum += 1
+                    current['cnum'] = autonum
             if field == 'comment':
-                changes[-1]['comment'] = wiki_to_html(new, self.env, req, db)
+                current['comment'] = wiki_to_html(new, self.env, req, db)
+                if permanent:
+                    this_num = str(autonum)
+                    if old:
+                        if '.' in old: # retrieve parent.child relationship
+                            parent_num, this_num = old.split('.', 1)
+                            current['replyto'] = parent_num
+                            replies.setdefault(parent_num, []).append(this_num)
+                        else:
+                            this_num = old
+                    assert this_num == str(autonum)
+                    # if we replied to this comment, quote it (with '>' prefix)
+                    if replyto == this_num and not 'comment' in req.args:
+                        req.hdf['ticket.comment'] = '\n'.join(
+                            ['Replying to [comment:%s %s]:' % \
+                             (replyto, author)] +
+                            ['> %s' % line for line in new.splitlines()] + [''])
             elif field == 'description':
-                changes[-1]['fields'][field] = ''
+                current['fields'][field] = ''
             else:
-                changes[-1]['fields'][field] = {'old': old,
-                                                'new': new}
-        req.hdf['ticket.changes'] = changes
+                current['fields'][field] = {'old': old, 'new': new}
+        req.hdf['ticket'] = {
+            'changes': changes,
+            'replies': replies,
+            'cnum': autonum + 1
+           }
 
-        # List attached files
+        # -- Ticket Attachments
+
         req.hdf['ticket.attachments'] = attachments_to_hdf(self.env, req, db,
                                                            'ticket', ticket.id)
         if req.perm.has_permission('TICKET_APPEND'):
Index: trac/ticket/tests/model.py
===================================================================
--- trac/ticket/tests/model.py	(revision 3361)
+++ trac/ticket/tests/model.py	(working copy)
@@ -196,8 +196,8 @@
         ticket['component'] = 'bar'
         ticket['milestone'] = 'foo'
         ticket.save_changes('jane', 'Testing', when=42)
-        for t, author, field, old, new in ticket.get_changelog():
-            self.assertEqual((42, 'jane'), (t, author))
+        for t, author, field, old, new, permanent in ticket.get_changelog():
+            self.assertEqual((42, 'jane', True), (t, author, permanent))
             if field == 'component':
                 self.assertEqual(('foo', 'bar'), (old, new))
             elif field == 'milestone':
@@ -214,8 +214,8 @@
         ticket['component'] = 'bar'
         ticket['component'] = 'foo'
         ticket.save_changes('jane', 'Testing', when=42)
-        for t, author, field, old, new in ticket.get_changelog():
-            self.assertEqual((42, 'jane'), (t, author))
+        for t, author, field, old, new, permanent in ticket.get_changelog():
+            self.assertEqual((42, 'jane', True), (t, author, permanent))
             if field == 'comment':
                 self.assertEqual(('', 'Testing'), (old, new))
             else:
Index: trac/ticket/model.py
===================================================================
--- trac/ticket/model.py	(revision 3361)
+++ trac/ticket/model.py	(working copy)
@@ -180,7 +180,7 @@
         self._old = {}
         return self.id
 
-    def save_changes(self, author, comment, when=0, db=None):
+    def save_changes(self, author, comment, when=0, db=None, cnum=''):
         """
         Store ticket changes in the database. The ticket must already exist in
         the database.
@@ -244,8 +244,8 @@
         if comment:
             cursor.execute("INSERT INTO ticket_change "
                            "(ticket,time,author,field,oldvalue,newvalue) "
-                           "VALUES (%s,%s,%s,'comment','',%s)",
-                           (self.id, when, author, comment))
+                           "VALUES (%s,%s,%s,'comment',%s,%s)",
+                           (self.id, when, author, cnum, comment))
 
         cursor.execute("UPDATE ticket SET changetime=%s WHERE id=%s",
                        (when, self.id))
@@ -265,29 +265,30 @@
         db = self._get_db(db)
         cursor = db.cursor()
         if when:
-            cursor.execute("SELECT time,author,field,oldvalue,newvalue "
+            cursor.execute("SELECT time,author,field,oldvalue,newvalue,1 "
                            "FROM ticket_change WHERE ticket=%s AND time=%s "
                            "UNION "
-                           "SELECT time,author,'attachment',null,filename "
+                           "SELECT time,author,'attachment',null,filename,0 "
                            "FROM attachment WHERE id=%s AND time=%s "
                            "UNION "
-                           "SELECT time,author,'comment',null,description "
+                           "SELECT time,author,'comment',null,description,0 "
                            "FROM attachment WHERE id=%s AND time=%s "
                            "ORDER BY time",
                            (self.id, when, str(self.id), when, self.id, when))
         else:
-            cursor.execute("SELECT time,author,field,oldvalue,newvalue "
+            cursor.execute("SELECT time,author,field,oldvalue,newvalue,1 "
                            "FROM ticket_change WHERE ticket=%s "
                            "UNION "
-                           "SELECT time,author,'attachment',null,filename "
+                           "SELECT time,author,'attachment',null,filename,0 "
                            "FROM attachment WHERE id=%s "
                            "UNION "
-                           "SELECT time,author,'comment',null,description "
+                           "SELECT time,author,'comment',null,description,0 "
                            "FROM attachment WHERE id=%s "
                            "ORDER BY time", (self.id,  str(self.id), self.id))
         log = []
-        for t, author, field, oldvalue, newvalue in cursor:
-            log.append((int(t), author, field, oldvalue or '', newvalue or ''))
+        for t, author, field, oldvalue, newvalue, permanent in cursor:
+            log.append((int(t), author, field, oldvalue or '', newvalue or '',
+                        permanent))
         return log
 
     def delete(self, db=None):
Index: trac/ticket/notification.py
===================================================================
--- trac/ticket/notification.py	(revision 3361)
+++ trac/ticket/notification.py	(working copy)
@@ -75,7 +75,7 @@
         changes = ''
         if not self.newticket and modtime:  # Ticket change
             changelog = ticket.get_changelog(modtime)
-            for date, author, field, old, new in changelog:
+            for date, author, field, old, new, permanent in changelog:
                 self.hdf.set_unescaped('ticket.change.author', author)
                 pfx = 'ticket.change.%s' % field
                 newv = ''
Index: templates/ticket.cs
===================================================================
--- templates/ticket.cs	(revision 3361)
+++ templates/ticket.cs	(working copy)
@@ -80,11 +80,39 @@
 <?cs call:list_of_attachments(ticket.attachments, ticket.attach_href) ?>
 <?cs /if ?>
 
+<?cs def:commentref(prefix, cnum) ?>
+<a href="#comment:<?cs var:cnum ?>"><small><?cs var:prefix ?><?cs var:cnum ?></small></a>
+<?cs /def ?>
+
 <?cs if:len(ticket.changes) ?><h2>Change History</h2>
 <div id="changelog"><?cs
  each:change = ticket.changes ?>
-  <h3 id="change_<?cs var:name(change) ?>" class="change"><?cs
-   var:change.date ?>: Modified by <?cs var:change.author ?></h3><?cs
+ <div class="change">
+  <h3 <?cs if:change.cnum ?>id="comment:<?cs var:change.cnum ?>"<?cs /if ?>><?cs
+   var:change.date ?> changed by <?cs var:change.author ?> <?cs
+   if:change.cnum ?><?cs
+    set:nreplies = len(ticket.replies[change.cnum]) ?><?cs
+    if:nreplies || change.replyto ?><span class="threading"> &mdash; <?cs
+     if:change.replyto ?>in reply to: <?cs 
+      call:commentref('&uarr;', change.replyto) ?><?cs if nreplies ?> &ndash; <?cs /if ?><?cs
+     /if ?><?cs
+     if nreplies ?><?cs
+      call:plural('follow-up', nreplies) ?>: <?cs 
+      each:reply = ticket.replies[change.cnum] ?><?cs 
+       call:commentref('&darr;', reply) ?><?cs 
+      /each ?><?cs 
+     /if ?><?cs
+    /if ?></span>&nbsp;
+     <a href="#comment:<?cs var:change.cnum ?>" class="anchor"
+        title="Permalink to comment:<?cs var:change.cnum ?>">&para;</a><?cs
+   /if ?>
+  </h3><?cs
+  if:change.cnum ?>
+   <form method="get" action="<?cs var:ticket.href ?>#comment"><div class="inlinebuttons">
+    <input type="hidden" name="replyto" value="<?cs var:change.cnum ?>" />
+    <input type="submit" value="Reply" title="Reply to comment <?cs var:change.cnum ?>" /></div>
+   </form><?cs 
+  /if ?><?cs
   if:len(change.fields) ?>
    <ul class="changes"><?cs
    each:field = change.fields ?>
@@ -100,8 +128,10 @@
    /each ?>
    </ul><?cs
   /if ?>
-  <div class="comment"><?cs var:change.comment ?></div><?cs
- /each ?></div><?cs
+  <div class="comment"><?cs var:change.comment ?></div>
+ </div><?cs
+ /each ?>
+</div><?cs
 /if ?>
 
 <?cs if:trac.acl.TICKET_CHGPROP || trac.acl.TICKET_APPEND ?>
@@ -277,6 +307,8 @@
 
  <div class="buttons">
   <input type="hidden" name="ts" value="<?cs var:ticket.ts ?>" />
+  <input type="hidden" name="replyto" value="<?cs var:ticket.replyto ?>" />
+  <input type="hidden" name="cnum" value="<?cs var:ticket.cnum ?>" />
   <input type="submit" name="preview" value="Preview" accesskey="r" />&nbsp;
   <input type="submit" value="Submit changes" />
  </div>
Index: templates/macros.cs
===================================================================
--- templates/macros.cs	(revision 3361)
+++ templates/macros.cs	(working copy)
@@ -190,4 +190,8 @@
    <input type="submit" value="Attach File" />
   </div></form><?cs
  /if ?><?cs if:len(attachments) ?></div><?cs /if ?><?cs
+/def ?><?cs
+
+def:plural(base, count) ?><?cs
+ var:base ?><?cs if:count != 1 ?>s<?cs /if ?><?cs
 /def ?>

