From 2834e356d9b799376db8cb80a339d55bd6de1146 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 28 Apr 2026 17:23:36 -0500 Subject: [PATCH] PAP-2720 Load full thread before jump-to-latest Co-Authored-By: Paperclip --- ui/src/components/IssueChatThread.test.tsx | 74 +++++++++++++++++++- ui/src/components/IssueChatThread.tsx | 29 +++++--- ui/src/lib/optimistic-issue-comments.test.ts | 26 +++++++ ui/src/lib/optimistic-issue-comments.ts | 39 +++++++++++ ui/src/pages/IssueDetail.tsx | 35 +++++++-- 5 files changed, 187 insertions(+), 16 deletions(-) diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 814469fbaa9..622a202f787 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, createRef, forwardRef, useImperativeHandle } from "react"; +import { act, createRef, forwardRef, useImperativeHandle, useState } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter } from "react-router-dom"; @@ -692,6 +692,78 @@ describe("IssueChatThread", () => { }); }); + it("uses comments rendered by onRefreshLatestComments before resolving latest", async () => { + const scrolledIds: string[] = []; + const originalScrollIntoView = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = vi.fn(function scrollIntoView(this: Element) { + scrolledIds.push(this.id); + }) as unknown as typeof Element.prototype.scrollIntoView; + + const olderComment = { + id: "comment-before-refresh", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: "agent-perf-codex", + authorUserId: null, + body: "Older loaded comment", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }; + const latestComment = { + ...olderComment, + id: "comment-after-refresh", + body: "Latest fetched comment", + createdAt: new Date("2026-04-06T12:01:00.000Z"), + updatedAt: new Date("2026-04-06T12:01:00.000Z"), + }; + + function RefreshingThread() { + const [comments, setComments] = useState([olderComment]); + return ( + {}} + enableLiveTranscriptPolling={false} + onRefreshLatestComments={async () => { + setComments([olderComment, latestComment]); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }} + /> + ); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + const jump = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Jump to latest", + ) as HTMLButtonElement | undefined; + expect(jump).toBeDefined(); + + await act(async () => { + jump?.click(); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }); + + expect(scrolledIds).toContain("comment-comment-after-refresh"); + + Element.prototype.scrollIntoView = originalScrollIntoView; + act(() => { + root.unmount(); + }); + }); + it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => { const messages = [ { metadata: { custom: { anchorId: "comment-a" } } }, diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 26ec6dae6c0..209a05f8d51 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -3087,6 +3087,8 @@ export function IssueChatThread({ stableMessageCacheRef.current = stabilized.cache; return stabilized.messages; }, [rawMessages]); + const latestMessagesRef = useRef(messages); + latestMessagesRef.current = messages; const isRunning = displayLiveRuns.some((run) => run.status === "queued" || run.status === "running"); const unresolvedBlockers = useMemo( @@ -3119,9 +3121,14 @@ export function IssueChatThread({ function scrollToThreadAnchor( anchorId: string, options?: { align?: "start" | "center" | "end" | "auto"; behavior?: ScrollBehavior }, + messageSnapshot: readonly ThreadMessage[] = messages, ) { - const virtualIndex = messageAnchorIndex.get(anchorId); - if (useVirtualizedThread && virtualIndex !== undefined) { + const snapshotUsesVirtualizer = messageSnapshot.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD; + const virtualIndex = + messageSnapshot === messages + ? messageAnchorIndex.get(anchorId) + : findMessageAnchorIndex(messageSnapshot, anchorId); + if (snapshotUsesVirtualizer && virtualIndex !== undefined && virtualIndex >= 0) { if (!virtualizedThreadRef.current) return false; virtualizedThreadRef.current.scrollToIndex(virtualIndex, { align: options?.align ?? "center", @@ -3261,19 +3268,23 @@ export function IssueChatThread({ // on the latest comment element on every tick until the DOM bottom of // that element is at the scroll container's bottom (or scroll position // and content height stop changing). - function scrollToLatestCommentWithSettle() { - const latestCommentIndex = findLatestCommentMessageIndex(messages); + function scrollToLatestCommentWithSettle(messageSnapshot: readonly ThreadMessage[] = latestMessagesRef.current) { + const latestCommentIndex = findLatestCommentMessageIndex(messageSnapshot); if (latestCommentIndex < 0) { jumpToLatestFallback(); return; } - const latestCommentAnchor = issueChatMessageAnchorId(messages[latestCommentIndex]); + const latestCommentAnchor = issueChatMessageAnchorId(messageSnapshot[latestCommentIndex]); if (!latestCommentAnchor) { jumpToLatestFallback(); return; } - const initial = scrollToThreadAnchor(latestCommentAnchor, { align: "end", behavior: "smooth" }); + const initial = scrollToThreadAnchor( + latestCommentAnchor, + { align: "end", behavior: "smooth" }, + messageSnapshot, + ); if (!initial) { jumpToLatestFallback(); return; @@ -3352,13 +3363,13 @@ export function IssueChatThread({ const refreshed = onRefreshLatestComments(); if (refreshed && typeof (refreshed as Promise).then === "function") { (refreshed as Promise).then( - () => scrollToLatestCommentWithSettle(), - () => scrollToLatestCommentWithSettle(), + () => scrollToLatestCommentWithSettle(latestMessagesRef.current), + () => scrollToLatestCommentWithSettle(latestMessagesRef.current), ); return; } } - scrollToLatestCommentWithSettle(); + scrollToLatestCommentWithSettle(latestMessagesRef.current); } const stableOnVote = useStableEvent(onVote); diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts index 40d292f3357..f92c34199f1 100644 --- a/ui/src/lib/optimistic-issue-comments.test.ts +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -9,6 +9,7 @@ import { flattenIssueCommentPages, getNextIssueCommentPageParam, isQueuedIssueComment, + loadRemainingIssueCommentPages, matchesIssueRef, mergeIssueComments, removeIssueCommentFromPages, @@ -234,6 +235,31 @@ describe("optimistic issue comments", () => { ).toBe("comment-1"); }); + it("loads remaining comment pages until the terminal partial page", async () => { + const fetchPage = vi.fn(async (afterCommentId: string) => { + if (afterCommentId === "comment-3") return [{ id: "comment-2" }, { id: "comment-1" }]; + if (afterCommentId === "comment-1") return [{ id: "comment-0" }]; + return []; + }); + + const loaded = await loadRemainingIssueCommentPages({ + pages: [[{ id: "comment-4" }, { id: "comment-3" }]], + pageParams: [null], + pageSize: 2, + fetchPage, + }); + + expect(fetchPage).toHaveBeenCalledTimes(2); + expect(fetchPage).toHaveBeenNthCalledWith(1, "comment-3"); + expect(fetchPage).toHaveBeenNthCalledWith(2, "comment-1"); + expect(loaded.pages.map((page) => page.map((comment) => comment.id))).toEqual([ + ["comment-4", "comment-3"], + ["comment-2", "comment-1"], + ["comment-0"], + ]); + expect(loaded.pageParams).toEqual([null, "comment-3", "comment-1"]); + }); + it("autoloads older chat comments while the initial thread is still under the threshold", () => { expect( shouldAutoloadOlderIssueComments({ diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts index d216ab06678..53cd3c20cd1 100644 --- a/ui/src/lib/optimistic-issue-comments.ts +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -150,6 +150,45 @@ export function getNextIssueCommentPageParam( return lastPage[lastPage.length - 1]?.id; } +function getNextPageCursor( + lastPage: ReadonlyArray | undefined, + pageSize: number, +): string | undefined { + if (!lastPage || lastPage.length < pageSize) return undefined; + return lastPage[lastPage.length - 1]?.id; +} + +export async function loadRemainingIssueCommentPages(params: { + pages: ReadonlyArray> | undefined; + pageParams: ReadonlyArray | undefined; + pageSize: number; + fetchPage: (afterCommentId: string) => Promise>; +}): Promise<{ pages: T[][]; pageParams: Array }> { + const pages = (params.pages ?? []).map((page) => [...page]); + const pageParams = params.pageParams + ? [...params.pageParams].slice(0, pages.length) + : pages.map((_, index) => index === 0 ? null : null); + + while (pageParams.length < pages.length) { + pageParams.push(null); + } + + if (params.pageSize <= 0) return { pages, pageParams }; + + let cursor = getNextPageCursor(pages[pages.length - 1], params.pageSize); + const seenCursors = new Set(); + while (cursor && !seenCursors.has(cursor)) { + seenCursors.add(cursor); + const nextPage = [...await params.fetchPage(cursor)]; + pages.push(nextPage); + pageParams.push(cursor); + + cursor = getNextPageCursor(nextPage, params.pageSize); + } + + return { pages, pageParams }; +} + export function shouldAutoloadOlderIssueComments(params: { activeDetailTab: string; hasOlderComments: boolean; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 94ca5147e51..ad5768450f0 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -48,6 +48,7 @@ import { flattenIssueCommentPages, getNextIssueCommentPageParam, isQueuedIssueComment, + loadRemainingIssueCommentPages, matchesIssueRef, mergeIssueComments, removeIssueCommentFromPages, @@ -2561,12 +2562,34 @@ export function IssueDetail() { void fetchOlderComments(); }, [fetchOlderComments]); const refetchLatestComments = useCallback(async () => { - // Refetches every loaded page (page 0 first), so any comment that - // landed after the initial load — including ones live updates may - // have dropped on reconnect — is in cache before we scroll the user - // to the absolute newest. - await refetchComments(); - }, [refetchComments]); + // Refetch page 0 first so comments that arrived after initial load are + // visible, then load every remaining older page. The chat thread is + // paginated and virtualized, so "latest" must be resolved against the + // complete comment set rather than the current loaded window. + const refreshed = await refetchComments(); + const loaded = await loadRemainingIssueCommentPages({ + pages: refreshed.data?.pages, + pageParams: refreshed.data?.pageParams as Array | undefined, + pageSize: ISSUE_COMMENT_PAGE_SIZE, + fetchPage: (afterCommentId) => + issuesApi.listComments(issueId!, { + order: "desc", + limit: ISSUE_COMMENT_PAGE_SIZE, + after: afterCommentId, + }), + }); + queryClient.setQueryData>( + queryKeys.issues.comments(issueId!), + loaded, + ); + await new Promise((resolve) => { + if (typeof window === "undefined") { + resolve(); + return; + } + window.requestAnimationFrame(() => resolve()); + }); + }, [issueId, queryClient, refetchComments]); useEffect(() => { if (!shouldPrefetchOlderComments) return; void fetchOlderComments();