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}