Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
a456e4966d fix(issues): render thread replies in chronological order (#3691)
collectThreadReplies walked the parent_id tree depth-first, so an agent
reply forced to nest under its trigger comment rendered before earlier
sibling replies (A-D-B-C instead of A-B-C-D) whenever the agent returned
late. Sort the collected subtree by created_at (id tie-break) so the
thread reads in arrival order — the same order the server already feeds
agents via `comment list --thread` (ListThreadCommentsForIssue).

All other consumers of the array (resolution derivation, fold bars,
counts, deep-link) are order-independent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:38:02 +08:00
2 changed files with 76 additions and 5 deletions

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { TimelineEntry } from "@multica/core/types";
import { collectThreadReplies } from "./thread-utils";
function comment(id: string, createdAt: string, parentId: string | null): TimelineEntry {
return {
type: "comment",
id,
actor_type: "member",
actor_id: "user-1",
content: id,
parent_id: parentId,
created_at: createdAt,
updated_at: createdAt,
comment_type: "comment",
} as TimelineEntry;
}
function bucketByParent(entries: TimelineEntry[]): Map<string, TimelineEntry[]> {
const map = new Map<string, TimelineEntry[]>();
for (const e of entries) {
if (!e.parent_id) continue;
const list = map.get(e.parent_id) ?? [];
list.push(e);
map.set(e.parent_id, list);
}
return map;
}
describe("collectThreadReplies", () => {
it("orders a late nested reply after earlier sibling replies (#3691)", () => {
// R1 (50m ago) triggered a slow agent; R2 (30m) and R3 (10m) arrived while
// it ran; D (3m ago) is the agent's reply, forced to nest under R1. A
// depth-first walk yields R1-D-R2-R3; the thread must read R1-R2-R3-D.
const r1 = comment("r1", "2026-06-11T10:00:00Z", "root");
const r2 = comment("r2", "2026-06-11T10:20:00Z", "root");
const r3 = comment("r3", "2026-06-11T10:40:00Z", "root");
const d = comment("d", "2026-06-11T10:47:00Z", "r1");
const out = collectThreadReplies("root", bucketByParent([r1, r2, r3, d]));
expect(out.map((e) => e.id)).toEqual(["r1", "r2", "r3", "d"]);
});
it("still returns every descendant across nesting levels", () => {
const r1 = comment("r1", "2026-06-11T10:00:00Z", "root");
const d1 = comment("d1", "2026-06-11T10:05:00Z", "r1");
const d2 = comment("d2", "2026-06-11T10:10:00Z", "d1");
const out = collectThreadReplies("root", bucketByParent([r1, d1, d2]));
expect(out.map((e) => e.id)).toEqual(["r1", "d1", "d2"]);
});
it("breaks created_at ties by id so the order is deterministic", () => {
const b = comment("b", "2026-06-11T10:00:00Z", "root");
const a = comment("a", "2026-06-11T10:00:00Z", "b");
const out = collectThreadReplies("root", bucketByParent([b, a]));
expect(out.map((e) => e.id)).toEqual(["a", "b"]);
});
});

View File

@@ -1,11 +1,19 @@
import type { TimelineEntry } from "@multica/core/types";
import { sortTimelineEntriesAsc } from "@multica/core/issues/timeline-sort";
/**
* Walks the parent_id graph rooted at `rootId` and returns every descendant in
* traversal order. Shared between CommentCard (which renders the expanded
* thread) and ResolvedThreadBar (which displays the collapsed count + author
* list) so the two views stay in sync — direct-children-only counts diverge
* once nested replies exist (see Emacs review on PR #2300).
* CHRONOLOGICAL order (created_at ASC, id tie-break). Shared between
* CommentCard (which renders the expanded thread) and ResolvedThreadBar
* (which displays the collapsed count + author list) so the two views stay in
* sync — direct-children-only counts diverge once nested replies exist (see
* Emacs review on PR #2300).
*
* Chronological, not depth-first: agent replies are forced to nest under the
* comment that triggered them, so a depth-first walk lets a slow agent's late
* reply render BEFORE earlier sibling replies (#3691). The server's --thread
* output the agent reads is already chronological (ListThreadCommentsForIssue
* in comment.sql); this keeps the UI on the same order.
*/
export function collectThreadReplies(
rootId: string,
@@ -20,7 +28,7 @@ export function collectThreadReplies(
}
};
walk(rootId);
return out;
return sortTimelineEntriesAsc(out);
}
/**