Refresh latest comments before latest jump

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 16:48:59 -05:00
1 parent 64f46ab281
commit 7a77102193
3 files changed
+127 -14

No files matched your search

@@ -714,6 +714,49 @@ describe("IssueChatThread", () => {
scrollHost.remove();
});
// Regression for PAP-2672 follow-up: clicking Jump to latest must refresh
// the comments page so a comment that arrived after the initial load is
// present before we scroll. Otherwise the user lands on the latest *loaded*
// comment but not the absolute newest.
it("invokes onRefreshLatestComments before scrolling on Jump to latest", async () => {
const refreshMock = vi.fn(async () => undefined);
const directComments = issueChatLongThreadComments.slice(0, 8);
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={directComments}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
agentMap={issueChatLongThreadAgentMap}
currentUserId="user-board"
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
onRefreshLatestComments={refreshMock}
/>
</MemoryRouter>,
);
});
const jump = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Jump to latest",
) as HTMLButtonElement | undefined;
expect(jump).toBeDefined();
act(() => {
jump?.click();
});
expect(refreshMock).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
});
});
it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => {
const messages = [
{ metadata: { custom: { anchorId: "comment-a" } } },
+72 -14
View File
@@ -309,6 +309,12 @@ interface IssueChatThreadProps {
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
composerRef?: Ref<IssueChatComposerHandle>;
/**
* Hook for the parent to refetch comments when the user explicitly asks
* to jump to the latest comment. Used to make sure the absolute newest
* comment is in the loaded set before we scroll to it.
*/
onRefreshLatestComments?: () => Promise<unknown> | void;
}
type IssueChatErrorBoundaryProps = {
@@ -3082,6 +3088,7 @@ export function IssueChatThread({
onRejectInteraction,
onSubmitInteractionAnswers,
composerRef,
onRefreshLatestComments,
}: IssueChatThreadProps) {
const location = useLocation();
const lastScrolledHashRef = useRef<string | null>(null);
@@ -3341,20 +3348,7 @@ export function IssueChatThread({
};
}, [location.hash, messageAnchorIndex, messages, useVirtualizedThread]);
function handleJumpToLatest() {
// Prefer the latest comment row over whichever run/timeline row happens to
// sort last in the merged feed. Fall back to the trailing row when no
// comment row exists (run-only/embedded outputs).
const latestCommentIndex = findLatestCommentMessageIndex(messages);
if (latestCommentIndex >= 0) {
const latestCommentAnchor = issueChatMessageAnchorId(messages[latestCommentIndex]);
if (
latestCommentAnchor
&& scrollToThreadAnchor(latestCommentAnchor, { align: "end", behavior: "smooth" })
) {
return;
}
}
function jumpToLatestFallback() {
if (useVirtualizedThread) {
virtualizedThreadRef.current?.scrollToLatest({ behavior: "smooth" });
return;
@@ -3362,6 +3356,70 @@ export function IssueChatThread({
bottomAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}
// Walks the thread by anchor and lands on the latest `comment-*` row, with
// a short series of settle passes. The virtualizer estimates row sizes for
// unmeasured rows, and that estimate undershoots tall markdown comments —
// so the first scroll often lands above the actual bottom and the user
// ends up clicking Jump to latest repeatedly to converge. Re-issuing the
// scroll after measurements catch up lets one click reach the actual
// latest comment (PAP-2672 follow-up).
function scrollToLatestCommentWithSettle() {
const latestCommentIndex = findLatestCommentMessageIndex(messages);
if (latestCommentIndex < 0) {
jumpToLatestFallback();
return;
}
const latestCommentAnchor = issueChatMessageAnchorId(messages[latestCommentIndex]);
if (!latestCommentAnchor) {
jumpToLatestFallback();
return;
}
const initial = scrollToThreadAnchor(latestCommentAnchor, { align: "end", behavior: "smooth" });
if (!initial) {
jumpToLatestFallback();
return;
}
if (typeof window === "undefined") return;
const settleDelays = [380, 760, 1140];
settleDelays.forEach((delay) => {
window.setTimeout(() => {
const el = document.getElementById(latestCommentAnchor);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "end" });
return;
}
// The row may still be outside the virtualizer's render buffer; nudge
// the offset so it gets mounted, then the next pass can align with
// real DOM measurements.
virtualizedThreadRef.current?.scrollToIndex(latestCommentIndex, {
align: "end",
behavior: "auto",
});
}, delay);
});
}
function handleJumpToLatest() {
if (onRefreshLatestComments) {
// Refetching from page 0 (newest first) brings any comments that
// arrived after the initial load into the cache before we scroll —
// otherwise we'd land on the latest *loaded* row rather than the
// absolute newest, which is what PAP-2672 reopened on.
const refreshed = onRefreshLatestComments();
if (refreshed && typeof (refreshed as Promise<unknown>).then === "function") {
(refreshed as Promise<unknown>).then(
() => scrollToLatestCommentWithSettle(),
() => scrollToLatestCommentWithSettle(),
);
return;
}
}
scrollToLatestCommentWithSettle();
}
const stableOnVote = useStableEvent(onVote);
const stableOnStopRun = useStableEvent(onStopRun);
const stableOnInterruptQueued = useStableEvent(onInterruptQueued);
+12
View File
@@ -560,6 +560,7 @@ type IssueDetailChatTabProps = {
hasOlderComments: boolean;
commentsLoadingOlder: boolean;
onLoadOlderComments: () => void;
onRefreshLatestComments: () => Promise<unknown> | void;
composerRef: Ref<IssueChatComposerHandle>;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
@@ -613,6 +614,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
hasOlderComments,
commentsLoadingOlder,
onLoadOlderComments,
onRefreshLatestComments,
composerRef,
feedbackVotes,
feedbackDataSharingPreference,
@@ -837,6 +839,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
}
: undefined}
onImageClick={onImageClick}
onRefreshLatestComments={onRefreshLatestComments}
/>
</div>
);
@@ -1092,6 +1095,7 @@ export function IssueDetail() {
isFetchingNextPage: commentsLoadingOlder,
hasNextPage: hasOlderComments,
fetchNextPage: fetchOlderComments,
refetch: refetchComments,
} = useInfiniteQuery({
queryKey: queryKeys.issues.comments(issueId!),
queryFn: ({ pageParam }) =>
@@ -2556,6 +2560,13 @@ export function IssueDetail() {
const loadOlderComments = useCallback(() => {
void fetchOlderComments();
}, [fetchOlderComments]);
const refetchLatestComments = useCallback(async () => {
// Refetch the entire infinite-query (page 0 first), so any comments that
// arrived after the initial load — including ones live updates may have
// missed during reconnects — are present before we scroll the user to
// the absolute newest.
await refetchComments();
}, [refetchComments]);
useEffect(() => {
if (!shouldPrefetchOlderComments) return;
void fetchOlderComments();
@@ -3418,6 +3429,7 @@ export function IssueDetail() {
hasOlderComments={hasOlderComments}
commentsLoadingOlder={commentsLoadingOlder}
onLoadOlderComments={loadOlderComments}
onRefreshLatestComments={refetchLatestComments}
composerRef={commentComposerRef}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}