feat(activity): unified activity timeline with comment reply support

Replace the comment-only list with a Linear-style unified timeline that
interleaves field changes and comments chronologically.

Backend:
- activity_listeners.go: records field changes (status, assignee, description,
  task completed/failed) to activity_log table on domain events
- Timeline API: GET /api/issues/{id}/timeline merges activity_log + comments
  sorted by created_at
- Comment reply: parent_id column + handler support for threading

Frontend:
- Unified timeline replaces comment list: activity entries as compact muted
  lines, comments as Card components with reply threading
- Filter toggle (All / Comments / Activity)
- Reply UI: inline editor under comments with Cancel/Reply buttons
- Real-time sync for activity:created + comment events
- 10 new Go tests, all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-28 21:53:08 +08:00
parent 3bb79564ed
commit e7fe6ea79b
21 changed files with 1307 additions and 132 deletions

View file

@ -0,0 +1,100 @@
package handler
import (
"encoding/json"
"net/http"
"sort"
"github.com/go-chi/chi/v5"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TimelineEntry represents a single entry in the issue timeline, which can be
// either an activity log record or a comment.
type TimelineEntry struct {
Type string `json:"type"` // "activity" or "comment"
ID string `json:"id"`
ActorType string `json:"actor_type"`
ActorID string `json:"actor_id"`
CreatedAt string `json:"created_at"`
// Activity-only fields
Action *string `json:"action,omitempty"`
Details json.RawMessage `json:"details,omitempty"`
// Comment-only fields
Content *string `json:"content,omitempty"`
ParentID *string `json:"parent_id,omitempty"`
UpdatedAt *string `json:"updated_at,omitempty"`
CommentType *string `json:"comment_type,omitempty"`
}
// ListTimeline returns a merged, chronologically-sorted timeline of activities
// and comments for a given issue.
func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
activities, err := h.Queries.ListActivities(r.Context(), db.ListActivitiesParams{
IssueID: issue.ID,
Limit: 200,
Offset: 0,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
comments, err := h.Queries.ListComments(r.Context(), issue.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
timeline := make([]TimelineEntry, 0, len(activities)+len(comments))
for _, a := range activities {
action := a.Action
actorType := ""
if a.ActorType.Valid {
actorType = a.ActorType.String
}
timeline = append(timeline, TimelineEntry{
Type: "activity",
ID: uuidToString(a.ID),
ActorType: actorType,
ActorID: uuidToString(a.ActorID),
Action: &action,
Details: a.Details,
CreatedAt: timestampToString(a.CreatedAt),
})
}
for _, c := range comments {
content := c.Content
commentType := c.Type
updatedAt := timestampToString(c.UpdatedAt)
timeline = append(timeline, TimelineEntry{
Type: "comment",
ID: uuidToString(c.ID),
ActorType: c.AuthorType,
ActorID: uuidToString(c.AuthorID),
Content: &content,
CommentType: &commentType,
ParentID: uuidToPtr(c.ParentID),
CreatedAt: timestampToString(c.CreatedAt),
UpdatedAt: &updatedAt,
})
}
// Sort chronologically (ascending by created_at)
sort.Slice(timeline, func(i, j int) bool {
return timeline[i].CreatedAt < timeline[j].CreatedAt
})
writeJSON(w, http.StatusOK, timeline)
}

View file

@ -0,0 +1,275 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func TestListTimeline_MergedAndSorted(t *testing.T) {
ctx := context.Background()
// Create an issue
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Timeline test issue",
"status": "todo",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// Create an activity record directly in DB
_, err := testHandler.Queries.CreateActivity(ctx, db.CreateActivityParams{
WorkspaceID: parseUUID(testWorkspaceID),
IssueID: parseUUID(issueID),
ActorType: strToText("member"),
ActorID: parseUUID(testUserID),
Action: "created",
Details: []byte("{}"),
})
if err != nil {
t.Fatalf("CreateActivity: %v", err)
}
// Create a comment
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "Timeline test comment",
})
req = withURLParam(req, "id", issueID)
testHandler.CreateComment(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Fetch timeline
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListTimeline(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListTimeline: expected 200, got %d: %s", w.Code, w.Body.String())
}
var timeline []TimelineEntry
json.NewDecoder(w.Body).Decode(&timeline)
if len(timeline) != 2 {
t.Fatalf("expected 2 timeline entries, got %d", len(timeline))
}
// First entry should be the activity (created earlier)
if timeline[0].Type != "activity" {
t.Fatalf("expected first entry type 'activity', got %q", timeline[0].Type)
}
if *timeline[0].Action != "created" {
t.Fatalf("expected action 'created', got %q", *timeline[0].Action)
}
// Second entry should be the comment
if timeline[1].Type != "comment" {
t.Fatalf("expected second entry type 'comment', got %q", timeline[1].Type)
}
if *timeline[1].Content != "Timeline test comment" {
t.Fatalf("expected comment content 'Timeline test comment', got %q", *timeline[1].Content)
}
}
func TestListTimeline_ChronologicalOrder(t *testing.T) {
ctx := context.Background()
// Create an issue
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Timeline order test issue",
"status": "todo",
})
testHandler.CreateIssue(w, req)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// Create comment first
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "First comment",
})
req = withURLParam(req, "id", issueID)
testHandler.CreateComment(w, req)
// Then create an activity after the comment
_, err := testHandler.Queries.CreateActivity(ctx, db.CreateActivityParams{
WorkspaceID: parseUUID(testWorkspaceID),
IssueID: parseUUID(issueID),
ActorType: strToText("member"),
ActorID: parseUUID(testUserID),
Action: "status_changed",
Details: []byte(`{"from":"todo","to":"in_progress"}`),
})
if err != nil {
t.Fatalf("CreateActivity: %v", err)
}
// Fetch timeline
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListTimeline(w, req)
var timeline []TimelineEntry
json.NewDecoder(w.Body).Decode(&timeline)
if len(timeline) != 2 {
t.Fatalf("expected 2 entries, got %d", len(timeline))
}
// Entries should be in chronological order
if timeline[0].CreatedAt > timeline[1].CreatedAt {
t.Fatalf("timeline not in chronological order: %s > %s", timeline[0].CreatedAt, timeline[1].CreatedAt)
}
}
func TestCreateComment_WithParentID(t *testing.T) {
ctx := context.Background()
// Create an issue
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Reply test issue",
})
testHandler.CreateIssue(w, req)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// Create parent comment
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "Parent comment",
})
req = withURLParam(req, "id", issueID)
testHandler.CreateComment(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment (parent): expected 201, got %d: %s", w.Code, w.Body.String())
}
var parentComment CommentResponse
json.NewDecoder(w.Body).Decode(&parentComment)
// Create reply with parent_id
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "Reply to parent",
"parent_id": parentComment.ID,
})
req = withURLParam(req, "id", issueID)
testHandler.CreateComment(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment (reply): expected 201, got %d: %s", w.Code, w.Body.String())
}
var replyComment CommentResponse
json.NewDecoder(w.Body).Decode(&replyComment)
if replyComment.ParentID == nil {
t.Fatal("expected reply to have parent_id set")
}
if *replyComment.ParentID != parentComment.ID {
t.Fatalf("expected parent_id %q, got %q", parentComment.ID, *replyComment.ParentID)
}
// Verify parent comment has no parent_id
if parentComment.ParentID != nil {
t.Fatalf("expected parent comment to have nil parent_id, got %q", *parentComment.ParentID)
}
}
func TestCommentWithParentID_AppearsInTimeline(t *testing.T) {
ctx := context.Background()
// Create an issue
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Timeline reply test",
})
testHandler.CreateIssue(w, req)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// Create parent comment
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "Parent in timeline",
})
req = withURLParam(req, "id", issueID)
testHandler.CreateComment(w, req)
var parent CommentResponse
json.NewDecoder(w.Body).Decode(&parent)
// Create reply
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "Reply in timeline",
"parent_id": parent.ID,
})
req = withURLParam(req, "id", issueID)
testHandler.CreateComment(w, req)
// Fetch timeline
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListTimeline(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListTimeline: expected 200, got %d: %s", w.Code, w.Body.String())
}
var timeline []TimelineEntry
json.NewDecoder(w.Body).Decode(&timeline)
if len(timeline) != 2 {
t.Fatalf("expected 2 timeline entries, got %d", len(timeline))
}
// Find the reply entry
var found bool
for _, entry := range timeline {
if entry.Type == "comment" && entry.ParentID != nil && *entry.ParentID == parent.ID {
found = true
if *entry.Content != "Reply in timeline" {
t.Fatalf("expected reply content 'Reply in timeline', got %q", *entry.Content)
}
}
}
if !found {
t.Fatal("expected to find reply with parent_id in timeline")
}
}

View file

@ -6,20 +6,22 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
type CommentResponse struct {
ID string `json:"id"`
IssueID string `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID string `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
IssueID string `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID string `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
ParentID *string `json:"parent_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func commentToResponse(c db.Comment) CommentResponse {
@ -30,6 +32,7 @@ func commentToResponse(c db.Comment) CommentResponse {
AuthorID: uuidToString(c.AuthorID),
Content: c.Content,
Type: c.Type,
ParentID: uuidToPtr(c.ParentID),
CreatedAt: timestampToString(c.CreatedAt),
UpdatedAt: timestampToString(c.UpdatedAt),
}
@ -57,8 +60,9 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
}
type CreateCommentRequest struct {
Content string `json:"content"`
Type string `json:"type"`
Content string `json:"content"`
Type string `json:"type"`
ParentID *string `json:"parent_id"`
}
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
@ -87,12 +91,18 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
req.Type = "comment"
}
var parentID pgtype.UUID
if req.ParentID != nil {
parentID = parseUUID(*req.ParentID)
}
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
IssueID: issue.ID,
AuthorType: "member",
AuthorID: parseUUID(userID),
Content: req.Content,
Type: req.Type,
ParentID: parentID,
})
if err != nil {
slog.Warn("create comment failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...)