From 54ab0d24cdf2ed37d55f93a819a59878f55868fb Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 26 Apr 2026 16:23:53 -0700 Subject: [PATCH] Fix disappearing issue comments (#4557) ## Thinking Path > - Paperclip is a control plane for AI-agent companies, so issue detail pages are a primary surface for understanding agent work and human feedback. > - The relevant subsystem here is the issue comments/chat experience across the React issue detail page and the server comment pagination API. > - Long issue threads were only surfacing the newest page of comments at first render, which hid earlier human and agent messages behind extra pagination. > - The first UI fix exposed that the descending cursor path on the server could also fail for older-page fetches, leaving the chat tab stuck on an infinite "Loading earlier comments..." state. > - This needed to be addressed in both layers so the chat tab can surface earlier conversation history without manual recovery and without server errors. > - This pull request auto-loads earlier comment pages in the issue detail chat view and fixes the descending cursor predicate used by issue comment pagination. > - The benefit is that long-running issues like `PAPA-103` now show the missing conversation history near the top of the chat surface instead of hiding it or failing to load it. ## What Changed - Auto-load earlier issue comment pages in the issue detail chat tab until the thread reaches a 150-comment cap or there are no older comments left. - Add UI-side guard logic and regression coverage for optimistic issue comment pagination so the autoload behavior stops cleanly. - Replace the raw SQL descending cursor predicate in `issueService.listComments` with typed Drizzle comparisons for the `(createdAt, id)` anchor tuple. - Add a server regression test that paginates earlier comments in descending order from an anchor comment. - Smoke-test the exact previously failing seeded `PAPA-103` cursor path on the isolated dev instance used for review. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/issues-service.test.ts` - `pnpm --filter @paperclipai/server typecheck` - Manual smoke against seeded `PAPA-103` data on the isolated dev server: - `GET /api/issues/PAPA-103/comments?order=desc&limit=50` returns `200` - `GET /api/issues/PAPA-103/comments?after=765d3609-edc6-4d11-a8fe-d466affbe85d&order=desc&limit=50` now returns `200` with 50 comments instead of `500` ## Risks - Moderate UI/perf risk on very large threads because the chat tab now prefetches multiple earlier pages on mount; the cap is intentionally limited to 150 comments to bound that work. - Low API risk because the server fix only changes the cursor predicate construction for anchor-based comment pagination, but any mistake there would affect older-comment paging order. > I checked `ROADMAP.md` before opening this PR and this bug fix does not duplicate planned core work. ## Model Used - OpenAI Codex coding agent in the Paperclip local adapter environment. The exact backend model ID and context window were not exposed in-session. Tool-assisted workflow included shell execution, git/GitHub CLI, local test execution, and targeted code edits. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- server/src/__tests__/issues-service.test.ts | 58 ++++++++++++++++++++ server/src/services/issues.ts | 18 +++--- ui/src/lib/optimistic-issue-comments.test.ts | 40 ++++++++++++++ ui/src/lib/optimistic-issue-comments.ts | 15 +++++ ui/src/pages/IssueDetail.tsx | 18 ++++++ 5 files changed, 140 insertions(+), 9 deletions(-) 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",