mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
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:
parent
b2496c8067
commit
54ab0d24cd
5 changed files with 140 additions and 9 deletions
|
|
@ -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 () => {
|
it("includes blockedBy summaries on list rows in one batched pass", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const blockerId = randomUUID();
|
const blockerId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Buffer } from "node:buffer";
|
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 type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
|
|
@ -3138,14 +3138,14 @@ export function issueService(db: Db) {
|
||||||
if (!anchor) return [];
|
if (!anchor) return [];
|
||||||
conditions.push(
|
conditions.push(
|
||||||
order === "asc"
|
order === "asc"
|
||||||
? sql<boolean>`(
|
? or(
|
||||||
${issueComments.createdAt} > ${anchor.createdAt}
|
gt(issueComments.createdAt, anchor.createdAt),
|
||||||
OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} > ${anchor.id})
|
and(eq(issueComments.createdAt, anchor.createdAt), gt(issueComments.id, anchor.id)),
|
||||||
)`
|
)!
|
||||||
: sql<boolean>`(
|
: or(
|
||||||
${issueComments.createdAt} < ${anchor.createdAt}
|
lt(issueComments.createdAt, anchor.createdAt),
|
||||||
OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} < ${anchor.id})
|
and(eq(issueComments.createdAt, anchor.createdAt), lt(issueComments.id, anchor.id)),
|
||||||
)`,
|
)!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
matchesIssueRef,
|
matchesIssueRef,
|
||||||
mergeIssueComments,
|
mergeIssueComments,
|
||||||
removeIssueCommentFromPages,
|
removeIssueCommentFromPages,
|
||||||
|
shouldAutoloadOlderIssueComments,
|
||||||
takeOptimisticIssueComment,
|
takeOptimisticIssueComment,
|
||||||
upsertIssueComment,
|
upsertIssueComment,
|
||||||
upsertIssueCommentInPages,
|
upsertIssueCommentInPages,
|
||||||
|
|
@ -233,6 +234,45 @@ describe("optimistic issue comments", () => {
|
||||||
).toBe("comment-1");
|
).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", () => {
|
it("upserts paged comments without dropping older pages", () => {
|
||||||
const nextPages = upsertIssueCommentInPages(
|
const nextPages = upsertIssueCommentInPages(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,21 @@ export function getNextIssueCommentPageParam(
|
||||||
return lastPage[lastPage.length - 1]?.id;
|
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(
|
export function upsertIssueComment(
|
||||||
comments: IssueComment[] | undefined,
|
comments: IssueComment[] | undefined,
|
||||||
nextComment: IssueComment,
|
nextComment: IssueComment,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ import {
|
||||||
matchesIssueRef,
|
matchesIssueRef,
|
||||||
mergeIssueComments,
|
mergeIssueComments,
|
||||||
removeIssueCommentFromPages,
|
removeIssueCommentFromPages,
|
||||||
|
shouldAutoloadOlderIssueComments,
|
||||||
takeOptimisticIssueComment,
|
takeOptimisticIssueComment,
|
||||||
upsertIssueCommentInPages,
|
upsertIssueCommentInPages,
|
||||||
type IssueCommentReassignment,
|
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 FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||||
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
||||||
|
const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3;
|
||||||
const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
|
const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
|
||||||
pause: "Pause subtree",
|
pause: "Pause subtree",
|
||||||
resume: "Resume subtree",
|
resume: "Resume subtree",
|
||||||
|
|
@ -1103,6 +1105,18 @@ export function IssueDetail() {
|
||||||
() => flattenIssueCommentPages(commentPages?.pages),
|
() => flattenIssueCommentPages(commentPages?.pages),
|
||||||
[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({
|
const { data: interactions = [] } = useQuery({
|
||||||
queryKey: queryKeys.issues.interactions(issueId!),
|
queryKey: queryKeys.issues.interactions(issueId!),
|
||||||
queryFn: () => issuesApi.listInteractions(issueId!),
|
queryFn: () => issuesApi.listInteractions(issueId!),
|
||||||
|
|
@ -2537,6 +2551,10 @@ export function IssueDetail() {
|
||||||
const loadOlderComments = useCallback(() => {
|
const loadOlderComments = useCallback(() => {
|
||||||
void fetchOlderComments();
|
void fetchOlderComments();
|
||||||
}, [fetchOlderComments]);
|
}, [fetchOlderComments]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldPrefetchOlderComments) return;
|
||||||
|
void fetchOlderComments();
|
||||||
|
}, [fetchOlderComments, shouldPrefetchOlderComments]);
|
||||||
const handleCommentVote = useCallback(async (commentId: string, vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }) => {
|
const handleCommentVote = useCallback(async (commentId: string, vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }) => {
|
||||||
await feedbackVoteMutation.mutateAsync({
|
await feedbackVoteMutation.mutateAsync({
|
||||||
targetType: "issue_comment",
|
targetType: "issue_comment",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue