PAP-2720 Load full thread before jump-to-latest

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 17:23:36 -05:00
1 parent 8d1647b039
commit 2834e356d9
5 files changed
+187 -16

No files matched your search

+73 -1
View File
@@ -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 (
<IssueChatThread
comments={comments}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
agentMap={issueChatLongThreadAgentMap}
currentUserId="user-board"
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
onRefreshLatestComments={async () => {
setComments([olderComment, latestComment]);
await new Promise((resolve) => window.requestAnimationFrame(resolve));
}}
/>
);
}
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryRouter>
<RefreshingThread />
</MemoryRouter>,
);
});
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" } } },
+20 -9
View File
@@ -3087,6 +3087,8 @@ export function IssueChatThread({
stableMessageCacheRef.current = stabilized.cache;
return stabilized.messages;
}, [rawMessages]);
const latestMessagesRef = useRef<readonly ThreadMessage[]>(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<unknown>).then === "function") {
(refreshed as Promise<unknown>).then(
() => scrollToLatestCommentWithSettle(),
() => scrollToLatestCommentWithSettle(),
() => scrollToLatestCommentWithSettle(latestMessagesRef.current),
() => scrollToLatestCommentWithSettle(latestMessagesRef.current),
);
return;
}
}
scrollToLatestCommentWithSettle();
scrollToLatestCommentWithSettle(latestMessagesRef.current);
}
const stableOnVote = useStableEvent(onVote);
@@ -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({
+39
View File
@@ -150,6 +150,45 @@ export function getNextIssueCommentPageParam(
return lastPage[lastPage.length - 1]?.id;
}
function getNextPageCursor<T extends { id: string }>(
lastPage: ReadonlyArray<T> | undefined,
pageSize: number,
): string | undefined {
if (!lastPage || lastPage.length < pageSize) return undefined;
return lastPage[lastPage.length - 1]?.id;
}
export async function loadRemainingIssueCommentPages<T extends { id: string }>(params: {
pages: ReadonlyArray<ReadonlyArray<T>> | undefined;
pageParams: ReadonlyArray<string | null> | undefined;
pageSize: number;
fetchPage: (afterCommentId: string) => Promise<ReadonlyArray<T>>;
}): Promise<{ pages: T[][]; pageParams: Array<string | null> }> {
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<string>();
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;
+29 -6
View File
@@ -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<IssueComment>({
pages: refreshed.data?.pages,
pageParams: refreshed.data?.pageParams as Array<string | null> | undefined,
pageSize: ISSUE_COMMENT_PAGE_SIZE,
fetchPage: (afterCommentId) =>
issuesApi.listComments(issueId!, {
order: "desc",
limit: ISSUE_COMMENT_PAGE_SIZE,
after: afterCommentId,
}),
});
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!),
loaded,
);
await new Promise<void>((resolve) => {
if (typeof window === "undefined") {
resolve();
return;
}
window.requestAnimationFrame(() => resolve());
});
}, [issueId, queryClient, refetchComments]);
useEffect(() => {
if (!shouldPrefetchOlderComments) return;
void fetchOlderComments();