diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 16afd95a..b6e8e6d0 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -895,6 +895,64 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { ); }); + it("paginates earlier comments in descending order from an anchor comment", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const firstCommentId = randomUUID(); + const anchorCommentId = randomUUID(); + const latestCommentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Paged comments issue", + status: "todo", + priority: "medium", + }); + + await db.insert(issueComments).values([ + { + id: firstCommentId, + companyId, + issueId, + body: "First comment", + createdAt: new Date("2026-03-26T10:00:00.000Z"), + updatedAt: new Date("2026-03-26T10:00:00.000Z"), + }, + { + id: anchorCommentId, + companyId, + issueId, + body: "Anchor comment", + createdAt: new Date("2026-03-26T11:00:00.000Z"), + updatedAt: new Date("2026-03-26T11:00:00.000Z"), + }, + { + id: latestCommentId, + companyId, + issueId, + body: "Latest comment", + createdAt: new Date("2026-03-26T12:00:00.000Z"), + updatedAt: new Date("2026-03-26T12:00:00.000Z"), + }, + ]); + + const comments = await svc.listComments(issueId, { + afterCommentId: anchorCommentId, + order: "desc", + limit: 50, + }); + + expect(comments.map((comment) => comment.id)).toEqual([firstCommentId]); + }); + it("includes blockedBy summaries on list rows in one batched pass", async () => { const companyId = randomUUID(); const blockerId = randomUUID(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 48893d03..d7d466b3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, inArray, isNull, lt, ne, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, @@ -3138,14 +3138,14 @@ export function issueService(db: Db) { if (!anchor) return []; conditions.push( order === "asc" - ? sql`( - ${issueComments.createdAt} > ${anchor.createdAt} - OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} > ${anchor.id}) - )` - : sql`( - ${issueComments.createdAt} < ${anchor.createdAt} - OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} < ${anchor.id}) - )`, + ? or( + gt(issueComments.createdAt, anchor.createdAt), + and(eq(issueComments.createdAt, anchor.createdAt), gt(issueComments.id, anchor.id)), + )! + : or( + lt(issueComments.createdAt, anchor.createdAt), + and(eq(issueComments.createdAt, anchor.createdAt), lt(issueComments.id, anchor.id)), + )!, ); } diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts index 1c53fb7f..40d292f3 100644 --- a/ui/src/lib/optimistic-issue-comments.test.ts +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -12,6 +12,7 @@ import { matchesIssueRef, mergeIssueComments, removeIssueCommentFromPages, + shouldAutoloadOlderIssueComments, takeOptimisticIssueComment, upsertIssueComment, upsertIssueCommentInPages, @@ -233,6 +234,45 @@ describe("optimistic issue comments", () => { ).toBe("comment-1"); }); + it("autoloads older chat comments while the initial thread is still under the threshold", () => { + expect( + shouldAutoloadOlderIssueComments({ + activeDetailTab: "chat", + hasOlderComments: true, + loadedCommentCount: 50, + initialPageLoading: false, + olderPageLoading: false, + autoLoadLimit: 150, + }), + ).toBe(true); + }); + + it("does not autoload older comments outside the chat tab", () => { + expect( + shouldAutoloadOlderIssueComments({ + activeDetailTab: "activity", + hasOlderComments: true, + loadedCommentCount: 50, + initialPageLoading: false, + olderPageLoading: false, + autoLoadLimit: 150, + }), + ).toBe(false); + }); + + it("stops autoloading once the initial comment window reaches the cap", () => { + expect( + shouldAutoloadOlderIssueComments({ + activeDetailTab: "chat", + hasOlderComments: true, + loadedCommentCount: 150, + initialPageLoading: false, + olderPageLoading: false, + autoLoadLimit: 150, + }), + ).toBe(false); + }); + it("upserts paged comments without dropping older pages", () => { const nextPages = upsertIssueCommentInPages( [ diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts index f35a484c..d216ab06 100644 --- a/ui/src/lib/optimistic-issue-comments.ts +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -150,6 +150,21 @@ export function getNextIssueCommentPageParam( return lastPage[lastPage.length - 1]?.id; } +export function shouldAutoloadOlderIssueComments(params: { + activeDetailTab: string; + hasOlderComments: boolean; + loadedCommentCount: number; + initialPageLoading: boolean; + olderPageLoading: boolean; + autoLoadLimit: number; +}) { + if (params.activeDetailTab !== "chat") return false; + if (!params.hasOlderComments) return false; + if (params.initialPageLoading || params.olderPageLoading) return false; + if (params.loadedCommentCount === 0) return false; + return params.loadedCommentCount < params.autoLoadLimit; +} + export function upsertIssueComment( comments: IssueComment[] | undefined, nextComment: IssueComment, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index f5ae8616..6e335c95 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -51,6 +51,7 @@ import { matchesIssueRef, mergeIssueComments, removeIssueCommentFromPages, + shouldAutoloadOlderIssueComments, takeOptimisticIssueComment, upsertIssueCommentInPages, type IssueCommentReassignment, @@ -152,6 +153,7 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; const ISSUE_COMMENT_PAGE_SIZE = 50; +const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3; const TREE_CONTROL_MODE_LABEL: Record = { pause: "Pause subtree", resume: "Resume subtree", @@ -1103,6 +1105,18 @@ export function IssueDetail() { () => flattenIssueCommentPages(commentPages?.pages), [commentPages?.pages], ); + const shouldPrefetchOlderComments = useMemo( + () => + shouldAutoloadOlderIssueComments({ + activeDetailTab: detailTab, + hasOlderComments: hasOlderComments ?? false, + loadedCommentCount: comments.length, + initialPageLoading: commentsLoading, + olderPageLoading: commentsLoadingOlder, + autoLoadLimit: ISSUE_COMMENT_AUTOLOAD_LIMIT, + }), + [comments.length, commentsLoading, commentsLoadingOlder, detailTab, hasOlderComments], + ); const { data: interactions = [] } = useQuery({ queryKey: queryKeys.issues.interactions(issueId!), queryFn: () => issuesApi.listInteractions(issueId!), @@ -2537,6 +2551,10 @@ export function IssueDetail() { const loadOlderComments = useCallback(() => { void fetchOlderComments(); }, [fetchOlderComments]); + useEffect(() => { + if (!shouldPrefetchOlderComments) return; + void fetchOlderComments(); + }, [fetchOlderComments, shouldPrefetchOlderComments]); const handleCommentVote = useCallback(async (commentId: string, vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }) => { await feedbackVoteMutation.mutateAsync({ targetType: "issue_comment",