mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
PAP-2720 Load full thread before jump-to-latest
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8d1647b039
commit
2834e356d9
5 files changed
+187
-16
No files matched your search
@@ -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" } } },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user