diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index ff89602a454..6837fe0da9d 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -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( + + {}} + enableLiveTranscriptPolling={false} + onRefreshLatestComments={refreshMock} + /> + , + ); + }); + + 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" } } }, diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 2abd38ff0ac..6a89c073ef5 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -309,6 +309,12 @@ interface IssueChatThreadProps { answers: AskUserQuestionsAnswer[], ) => Promise | void; composerRef?: Ref; + /** + * 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 | void; } type IssueChatErrorBoundaryProps = { @@ -3082,6 +3088,7 @@ export function IssueChatThread({ onRejectInteraction, onSubmitInteractionAnswers, composerRef, + onRefreshLatestComments, }: IssueChatThreadProps) { const location = useLocation(); const lastScrolledHashRef = useRef(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).then === "function") { + (refreshed as Promise).then( + () => scrollToLatestCommentWithSettle(), + () => scrollToLatestCommentWithSettle(), + ); + return; + } + } + scrollToLatestCommentWithSettle(); + } + const stableOnVote = useStableEvent(onVote); const stableOnStopRun = useStableEvent(onStopRun); const stableOnInterruptQueued = useStableEvent(onInterruptQueued); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b197aa7118e..be4707e85b3 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -560,6 +560,7 @@ type IssueDetailChatTabProps = { hasOlderComments: boolean; commentsLoadingOlder: boolean; onLoadOlderComments: () => void; + onRefreshLatestComments: () => Promise | void; composerRef: Ref; 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} /> ); @@ -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}