diff --git a/packages/views/issues/components/thread-utils.test.ts b/packages/views/issues/components/thread-utils.test.ts new file mode 100644 index 000000000..2c1f9c76c --- /dev/null +++ b/packages/views/issues/components/thread-utils.test.ts @@ -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 { + const map = new Map(); + 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"]); + }); +}); diff --git a/packages/views/issues/components/thread-utils.ts b/packages/views/issues/components/thread-utils.ts index ab821e8b8..a47074fb9 100644 --- a/packages/views/issues/components/thread-utils.ts +++ b/packages/views/issues/components/thread-utils.ts @@ -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); } /**