Files
Claude c4b6140334 perf(issues): page the comment timeline by root comment
Before: GET /issues/{id}/timeline read every comment and activity of the
issue (up to the hard cap) in one response, and the paging params only
changed the response wrapper. The issue page fetched, parsed and held the
whole timeline no matter how long the conversation was.

After: `roots` (1..200) pages by root comment, newest first, with a
(created_at, id) cursor in `next_before` / `next_before_id`. Each page carries
every reply of its roots, so a thread is never split, plus the activities of
the page's time window; `has_more_before` is exact. `around=<commentId>` lands
on the page that starts at the comment's thread root. Requests without
`roots` (including the legacy limit/before/after/around) behave exactly as
before, so installed clients still receive the whole timeline. The issue page
loads the newest 50 roots, fetches an older page when the list scrolls to its
top without moving the viewport, and walks older pages for deep links and
in-page find.

How: three new sqlc queries (roots before a cursor, replies of a set of roots
via a recursive walk, activities in a time window) feed the new
listTimelinePage path. The web cache becomes an infinite query whose newest
page is pages[0]; older pages are its next pages. Realtime and optimistic
patches go through updateTimelinePages in core, which applies a flat-list edit
across pages. Replies per page are capped at the comment hard cap: trailing
threads move to the next page, and only a single oversized thread is cut and
reported through X-Timeline-Truncated.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
2026-10-21 03:24:28 +00:00

499 lines
19 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"sort"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
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"`
// Display-only identity is hydrated from the global user row for member
// actors. It remains available after the member leaves this workspace;
// actor_type + actor_id stay the durable attribution keys.
ActorName string `json:"actor_name,omitempty"`
ActorAvatarURL string `json:"actor_avatar_url,omitempty"`
// 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"`
Revision int64 `json:"revision,omitempty"`
CommentType *string `json:"comment_type,omitempty"`
// Set only on comments produced by a quick action run. Unforgeable: there
// is no request field for it on the generic comment endpoint.
QuickActionID *string `json:"quick_action_id,omitempty"`
Reactions []ReactionResponse `json:"reactions,omitempty"`
Attachments []AttachmentResponse `json:"attachments,omitempty"`
ResolvedAt *string `json:"resolved_at,omitempty"`
ResolvedByType *string `json:"resolved_by_type,omitempty"`
ResolvedByID *string `json:"resolved_by_id,omitempty"`
SourceTaskID *string `json:"source_task_id,omitempty"`
// Set only on a tombstone: a comment deleted while it still had replies.
DeletedAt *string `json:"deleted_at,omitempty"`
// Supplements lists every running turn this comment steered; the single
// supplement_* fields mirror the first receipt for older clients.
Supplements []CommentSupplementResponse `json:"supplements,omitempty"`
SupplementTaskID string `json:"supplement_task_id,omitempty"`
SupplementStatus string `json:"supplement_status,omitempty"`
SupplementFailureReason *string `json:"supplement_failure_reason,omitempty"`
SupplementDeliveredAt *string `json:"supplement_delivered_at,omitempty"`
}
// timelineHardCap bounds the per-issue timeline payload. Sized as a defensive
// safety net, not a UX page window: see commentHardCap in comment.go for the
// data-shape rationale (#1929). A variable only so the cap tests can shrink it,
// like commentHardCap; nothing outside tests assigns it.
var timelineHardCap = 2000
// timelineProbeLimit reads one row past the cap so "we hit the cap" can be
// distinguished from "the issue happens to have exactly timelineHardCap rows".
// Without the probe row an issue sitting exactly on the boundary would report a
// complete timeline as truncated and pay a needless ancestor-backfill query.
func timelineProbeLimit() int32 { return int32(timelineHardCap) + 1 }
// Truncation is signalled with a response header rather than a body field
// because the unpaginated response is a bare JSON array (TimelineEntriesSchema =
// z.array(TimelineEntrySchema) in packages/core/api/schemas.ts) with nowhere to
// put a flag. The header is additive: existing clients keep validating.
//
// The value names which kinds were truncated ("activity", "comment", or
// "activity,comment") because the two caps are independent — in practice it is
// almost always activity alone.
//
// There is deliberately no companion "window from" header. An earlier revision
// emitted one, but TimestampToString is second-precision RFC3339 while the real
// ordering key is (created_at, id) at full precision, so it could not be used to
// resume a read without skipping or repeating rows inside a shared second. A
// resumable cursor needs to be opaque and carry both halves; that is worth
// designing when there is a consumer, not shipping as a lossy approximation.
//
// Exported so the CORS layer can reference the same identifier
// (corsExposedHeaders in server/cmd/server/router.go). A custom response header
// is invisible to browser JS unless explicitly exposed, so a rename here that
// did not reach the CORS list would silently switch the signal back off.
const HeaderTimelineTruncated = "X-Timeline-Truncated"
// truncatedKinds renders the X-Timeline-Truncated value, "" when nothing was
// truncated.
func truncatedKinds(comments, activities bool) string {
switch {
case comments && activities:
return "activity,comment"
case comments:
return "comment"
case activities:
return "activity"
default:
return ""
}
}
// takeNewest trims a timelineProbeLimit read down to timelineHardCap and reports
// whether the probe row proved older rows exist. rows must be ascending, so the
// newest timelineHardCap entries are the tail.
func takeNewest[T any](rows []T) ([]T, bool) {
if len(rows) <= timelineHardCap {
return rows, false
}
return rows[len(rows)-timelineHardCap:], true
}
// timelinePaginatedResponse is the wrapped timeline shape. It has two producers:
//
// - Legacy: the wrapper produced by the prior cursor-paginated ListTimeline
// (#2128), kept for installed Desktop builds and stale Web bundles between
// #2128 and #1929 that send `?before=`/`?after=`/`?around=` and parse the
// response with the old TimelinePageSchema. Cursors are always nil and
// `has_more_after` is always false: the whole timeline comes in one shot.
// `has_more_before` reports the hard-cap clamp.
// - Root-paged (`?roots=`, see activity_timeline_page.go): `has_more_before`
// is exact and `next_before` / `next_before_id` carry the cursor to the
// next older page.
type timelinePaginatedResponse struct {
Entries []TimelineEntry `json:"entries"`
NextCursor *string `json:"next_cursor"`
PrevCursor *string `json:"prev_cursor"`
HasMoreBefore bool `json:"has_more_before"`
HasMoreAfter bool `json:"has_more_after"`
TargetIndex *int `json:"target_index,omitempty"`
// NextBefore / NextBeforeID are the composite cursor of the root-paged
// path (`roots` present): the oldest root on the page, RFC3339Nano. Always
// null on the legacy wrapper.
NextBefore *string `json:"next_before"`
NextBeforeID *string `json:"next_before_id"`
}
// ListTimeline returns the issue timeline (comments + activities merged).
// Three response modes coexist for boundary compatibility (#1929):
//
// - No pagination params → flat ASC `TimelineEntry[]`. Matches the legacy
// desktop contract (Multica.app ≤ v0.2.25) and the new client.
// - Any of `limit` / `before` / `after` / `around` WITHOUT `roots` → wrapped object
// with DESC entries + null cursors + has_more_after=false. Matches what a
// stale v0.2.26+ build expects when it parses the response with
// TimelinePageSchema; the client just sees a single full page.
// - `roots` present → real paging by root comment, see
// activity_timeline_page.go. Only this mode returns a partial timeline.
//
// The first two modes carry the same set of entries — shape and ordering
// differ. Time-based pagination was removed because it split reply threads at
// page boundaries; the root-paged mode avoids that by always returning every
// reply of the roots on a page.
//
// When the hard cap fires each list is independently reduced to its newest
// entries and X-Timeline-Truncated names which kinds were affected. Comment
// threads cut by the window are completed afterwards within a bounded context
// budget; a thread that cannot be completed is omitted as one unit (MUL-5492).
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
}
ctx := r.Context()
// `roots` is the only param that opts into real root paging; see
// activity_timeline_page.go. The legacy `limit` / before / after / around
// keep the stale-client wrapper so installed builds still get the whole
// timeline.
q := r.URL.Query()
if rawRoots := q.Get("roots"); rawRoots != "" {
req, ok := parseTimelinePageRequest(w, q, rawRoots)
if !ok {
return
}
h.listTimelinePage(w, r, issue, req)
return
}
comments, err := h.Queries.ListCommentsForIssue(ctx, db.ListCommentsForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Limit: timelineProbeLimit(),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
activities, err := h.Queries.ListActivitiesForIssue(ctx, db.ListActivitiesForIssueParams{
IssueID: issue.ID,
Limit: timelineProbeLimit(),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
wantWrapped := q.Get("limit") != "" || q.Get("before") != "" ||
q.Get("after") != "" || q.Get("around") != ""
// Each list is capped independently and reports its own truncation. The two
// are deliberately NOT clamped to a shared floor.
//
// An earlier revision did clamp them, to guarantee the returned window was a
// contiguous correctly-interleaved slice with no region holding only one of
// the two kinds. That traded the wrong way round. Comments are human-paced
// (p99 ~30, max ever observed ~1.1k) so they essentially never reach the cap,
// while activity is machine-paced and reaches it routinely — so the shared
// floor was almost always the ACTIVITY floor cutting away comments that had
// been fetched successfully and would have rendered fine. It deleted real
// content to buy a cosmetic property, and on a busy issue with thirty
// comments it was pure loss.
//
// Not clamping costs only activity density in the older part of the range,
// which is metadata, not content — and it is reported rather than hidden.
// It also keeps the comment set from being cut at an arbitrary row, which is
// why completeCommentThreads repairs any affected thread below.
comments, commentsTruncated := takeNewest(comments)
activities, activitiesTruncated := takeNewest(activities)
// Timeline clients derive resolution bars, author lists, and folded counts
// from the comments in this response. A parent-only repair would therefore
// make a partial thread look complete. Restore all missing siblings and
// descendants for affected roots within the shared context budget; if the
// walk cannot prove a thread complete, omit that thread as one unit.
if commentsTruncated {
var err error
comments, err = h.completeCommentThreads(ctx, issue.ID, issue.WorkspaceID, comments)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to complete comment threads")
return
}
}
if kinds := truncatedKinds(commentsTruncated, activitiesTruncated); kinds != "" {
w.Header().Set(HeaderTimelineTruncated, kinds)
}
entries := h.mergeTimeline(r, comments, activities, !wantWrapped)
// The current-member directory deliberately excludes departed members, but
// timeline attribution must remain readable after they leave. Hydrate only
// the member ids already present in this authorised issue response; lookup
// failure is display-only and must not make the timeline unavailable.
h.hydrateTimelineMemberActors(ctx, entries)
if entries == nil {
entries = []TimelineEntry{}
}
if wantWrapped {
resp := timelinePaginatedResponse{
Entries: entries,
HasMoreBefore: commentsTruncated || activitiesTruncated,
}
// `around=<id>`: locate the anchor in the DESC slice so the legacy
// client can scroll-to-highlight without a follow-up request.
if anchor := q.Get("around"); anchor != "" {
for i, e := range entries {
if e.ID == anchor {
idx := i
resp.TargetIndex = &idx
break
}
}
}
writeJSON(w, http.StatusOK, resp)
return
}
writeJSON(w, http.StatusOK, entries)
}
// mergeTimeline merges comments and activities and returns them sorted by
// (created_at, id). When ascending=true, oldest first (the new flat-array
// contract); otherwise newest first (the wrapped legacy contract).
func (h *Handler) mergeTimeline(r *http.Request, comments []db.Comment, activities []db.ActivityLog, ascending bool) []TimelineEntry {
out := make([]TimelineEntry, 0, len(comments)+len(activities))
out = append(out, h.commentsToEntries(r, comments)...)
for _, a := range activities {
out = append(out, activityToEntry(a))
}
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt != out[j].CreatedAt {
if ascending {
return out[i].CreatedAt < out[j].CreatedAt
}
return out[i].CreatedAt > out[j].CreatedAt
}
if ascending {
return out[i].ID < out[j].ID
}
return out[i].ID > out[j].ID
})
return out
}
// commentsToEntries fetches reactions + attachments for the given comments in
// one batch each and returns enriched TimelineEntry slices preserving order.
func (h *Handler) commentsToEntries(r *http.Request, comments []db.Comment) []TimelineEntry {
if len(comments) == 0 {
return nil
}
ids := make([]pgtype.UUID, len(comments))
for i, c := range comments {
ids[i] = c.ID
}
reactions := h.groupReactions(r, ids)
attachments := h.groupAttachments(r, ids)
supplements := h.listCommentSupplements(r.Context(), comments[0].WorkspaceID, ids)
out := make([]TimelineEntry, len(comments))
for i, c := range comments {
content := c.Content
commentType := c.Type
updatedAt := timestampToString(c.UpdatedAt)
cid := uuidToString(c.ID)
out[i] = TimelineEntry{
Type: "comment",
ID: cid,
ActorType: c.AuthorType,
ActorID: uuidToString(c.AuthorID),
Content: &content,
CommentType: &commentType,
QuickActionID: uuidToPtr(c.QuickActionID),
ParentID: uuidToPtr(c.ParentID),
CreatedAt: timestampToString(c.CreatedAt),
UpdatedAt: &updatedAt,
Revision: c.Revision,
Reactions: reactions[cid],
Attachments: attachments[cid],
ResolvedAt: timestampToPtr(c.ResolvedAt),
ResolvedByType: textToPtr(c.ResolvedByType),
ResolvedByID: uuidToPtr(c.ResolvedByID),
SourceTaskID: uuidToPtr(c.SourceTaskID),
DeletedAt: timestampToPtr(c.DeletedAt),
}
if receipts := supplements[cid]; len(receipts) > 0 {
out[i].Supplements = receipts
out[i].SupplementTaskID = receipts[0].TaskID
out[i].SupplementStatus = receipts[0].Status
out[i].SupplementFailureReason = receipts[0].FailureReason
out[i].SupplementDeliveredAt = receipts[0].DeliveredAt
}
}
return out
}
func activityToEntry(a db.ActivityLog) TimelineEntry {
action := a.Action
actorType := ""
if a.ActorType.Valid {
actorType = a.ActorType.String
}
return TimelineEntry{
Type: "activity",
ID: uuidToString(a.ID),
ActorType: actorType,
ActorID: uuidToString(a.ActorID),
Action: &action,
Details: a.Details,
CreatedAt: timestampToString(a.CreatedAt),
}
}
// hydrateTimelineMemberActors adds display identity for member-authored rows
// without changing the active-member directory's semantics. The ids came from
// comments/activity rows on an issue loadIssueForUser already authorised, so
// this creates no arbitrary user lookup surface. The query is bounded by the
// timeline hard caps and runs once for the whole response, never once per row.
func (h *Handler) hydrateTimelineMemberActors(ctx context.Context, entries []TimelineEntry) {
seen := make(map[string]struct{})
ids := make([]pgtype.UUID, 0)
for i := range entries {
entry := &entries[i]
if entry.ActorType != "member" || entry.ActorID == "" {
continue
}
if _, ok := seen[entry.ActorID]; ok {
continue
}
id, err := util.ParseUUID(entry.ActorID)
if err != nil {
continue
}
seen[entry.ActorID] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return
}
users, err := h.Queries.GetUsersByIDs(ctx, ids)
if err != nil {
return
}
byID := make(map[string]db.GetUsersByIDsRow, len(users))
for _, user := range users {
byID[uuidToString(user.ID)] = user
}
for i := range entries {
entry := &entries[i]
if entry.ActorType != "member" {
continue
}
user, ok := byID[entry.ActorID]
if !ok {
continue
}
entry.ActorName = user.Name
if user.AvatarUrl.Valid {
entry.ActorAvatarURL = h.resolveAvatarURL(user.AvatarUrl.String)
}
}
}
// AssigneeFrequencyEntry represents how often a user assigns to a specific target.
type AssigneeFrequencyEntry struct {
AssigneeType string `json:"assignee_type"`
AssigneeID string `json:"assignee_id"`
Frequency int64 `json:"frequency"`
}
// GetAssigneeFrequency returns assignee usage frequency for the current user,
// combining data from assignee change activities and initial issue assignments.
func (h *Handler) GetAssigneeFrequency(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := h.resolveWorkspaceID(r)
// Aggregate frequency from both data sources.
freq := map[string]int64{} // key: "type:id"
// Source 1: assignee_changed activities by this user.
activityCounts, err := h.Queries.CountAssigneeChangesByActor(r.Context(), db.CountAssigneeChangesByActorParams{
WorkspaceID: parseUUID(workspaceID),
ActorID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
return
}
for _, row := range activityCounts {
aType, _ := row.AssigneeType.(string)
aID, _ := row.AssigneeID.(string)
if aType != "" && aID != "" {
freq[aType+":"+aID] += row.Frequency
}
}
// Source 2: issues created by this user with an assignee.
issueCounts, err := h.Queries.CountCreatedIssueAssignees(r.Context(), db.CountCreatedIssueAssigneesParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
return
}
for _, row := range issueCounts {
if !row.AssigneeType.Valid || !row.AssigneeID.Valid {
continue
}
key := row.AssigneeType.String + ":" + uuidToString(row.AssigneeID)
freq[key] += row.Frequency
}
// Build sorted response.
result := make([]AssigneeFrequencyEntry, 0, len(freq))
for key, count := range freq {
// Split "type:id" — type is always "member" or "agent" (no colons).
var aType, aID string
for i := 0; i < len(key); i++ {
if key[i] == ':' {
aType = key[:i]
aID = key[i+1:]
break
}
}
result = append(result, AssigneeFrequencyEntry{
AssigneeType: aType,
AssigneeID: aID,
Frequency: count,
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Frequency > result[j].Frequency
})
writeJSON(w, http.StatusOK, result)
}