forked from farhoodlabs/paperclip
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
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<boolean>`(
|
||||
${issueComments.createdAt} > ${anchor.createdAt}
|
||||
OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} > ${anchor.id})
|
||||
)`
|
||||
: sql<boolean>`(
|
||||
${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)),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<IssueTreeControlMode, string> = {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user