mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
Refresh latest comments before latest jump
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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" } } },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user