From 75fca7d77cd4a5f72167606f248131edb633bb50 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sun, 11 Oct 2026 05:31:17 +0200 Subject: [PATCH] fix(web): scope PR title collapse to tab scrollers (#14644) --- .../pullRequest/PullRequestCodeTab.tsx | 7 +- .../PullRequestDetailPanel.test.tsx | 145 ++++++++++++++---- .../pullRequest/PullRequestDetailPanel.tsx | 49 ++++-- .../PullRequestSummaryTab.test.tsx | 1 + .../pullRequest/PullRequestSummaryTab.tsx | 4 +- .../pullRequest/PullRequestTimelineTab.tsx | 4 +- 6 files changed, 167 insertions(+), 43 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d5f054dadab0136d157de8403082dbe17f594be0..4b2a878a5ca556596c37ba63810ed20818f8d341 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -196,6 +196,7 @@ function getReviewPositionAnchor(position: PullRequestReviewPosition): { * drafted rather than being posted as it is typed. */ function PullRequestCodeTab({ + scrollerRef, environmentId, reference, detail, @@ -208,6 +209,7 @@ function PullRequestCodeTab({ onRefresh, refreshToken = 0, }: { + scrollerRef: (node: HTMLDivElement | null) => void; environmentId: EnvironmentId; reference: PullRequestRef; detail: PullRequestDetailView; @@ -1394,7 +1396,9 @@ function PullRequestCodeTab({ const withToolbar = (body: ReactNode) => (
{toolbar} -
{body}
+
+ {body} +
); @@ -1574,6 +1578,7 @@ function PullRequestCodeTab({ // interaction, but its native host outline clips and competes with the focus // indicators on its actual controls. className="@container h-full overflow-auto [scrollbar-gutter:stable]" + containerRef={scrollerRef} viewerRef={setViewer} items={items} selectedLines={selectedLines} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx index 3f192f745fa8a273ae263de3cda854a6506d6185..176009e1a98a5e13acb31c68369acf270fe0270c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx @@ -108,43 +108,49 @@ vi.mock("./PullRequestThreadLinks", () => ({ PullRequestThreadLinks: () => null vi.mock("./PullRequestSummaryTab", () => ({ PullRequestSummaryTab: ({ onFixFinding, + scrollerRef, }: ComponentProps) => ( - +
+ +
), })); vi.mock("./PullRequestCodeTab", () => ({ default: ({ onAddToAgentSelection, + scrollerRef, }: ComponentProps) => ( - +
+ +
), })); @@ -343,3 +349,82 @@ describe.each([ } }); }); + +it("changes the title only for the active tab's main scroller", async () => { + const scrollers = new Map(); + await act(async () => { + renderer = create( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + />, + { + createNodeMock: (element) => { + const props = element.props as Record; + const name = props["data-test-scroll"] as string | undefined; + if (name) { + const scroller = { scrollTop: 0, scrollHeight: 300 }; + scrollers.set(name, scroller); + return scroller; + } + return { scrollHeight: props.inert === false ? 100 : 20 }; + }, + }, + ); + }); + + const mainScroll = renderer.root.findByProps({ + className: "relative flex min-h-0 flex-1 flex-col overflow-hidden", + }); + const titleFold = () => { + let node = renderer.root.findByType("h1"); + while (typeof node.props.inert !== "boolean") node = node.parent!; + return node; + }; + const summary = scrollers.get("summary")!; + const nested = { scrollTop: 200 }; + await act(async () => mainScroll.props.onScrollCapture({ target: nested })); + expect(titleFold().props.inert).toBe(false); + summary.scrollTop = 180; + nested.scrollTop = 0; + await act(async () => { + mainScroll.props.onScrollCapture({ target: summary }); + mainScroll.props.onScrollCapture({ target: nested }); + }); + expect(titleFold().props.inert).toBe(true); + expect(summary.scrollTop).toBe(100); + expect(nested.scrollTop).toBe(0); + + await act(async () => mainScroll.props.onScrollCapture({ target: { scrollTop: 0 } })); + expect(titleFold().props.inert).toBe(true); + summary.scrollTop = 0; + await act(async () => mainScroll.props.onScrollCapture({ target: summary })); + expect(titleFold().props.inert).toBe(false); + + await click("Code"); + const code = scrollers.get("code")!; + summary.scrollTop = 180; + await act(async () => mainScroll.props.onScrollCapture({ target: summary })); + expect(titleFold().props.inert).toBe(false); + code.scrollTop = 180; + await act(async () => mainScroll.props.onScrollCapture({ target: code })); + expect(titleFold().props.inert).toBe(true); + expect(code.scrollTop).toBe(100); + await click("Summary"); + expect(titleFold().props.inert).toBe(false); + await click("Code"); + expect(titleFold().props.inert).toBe(true); + code.scrollTop = 0; + await act(async () => mainScroll.props.onScrollCapture({ target: code })); + expect(titleFold().props.inert).toBe(false); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index e977c302be347c0bf2f9aade75e8e80af183e07d..526a1ae9bfbca3ea63fbc050a7d59969b22bf9fd 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -526,18 +526,44 @@ export function PullRequestDetailPanel({ setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); const condensed = chromeCondensed; - const scrollerRef = useRef(null); + const scrollerByTab = useRef>>({}); + const scrollerRefs = useMemo( + () => ({ + summary: (node: HTMLDivElement | null) => { + scrollerByTab.current.summary = node; + }, + timeline: (node: HTMLDivElement | null) => { + scrollerByTab.current.timeline = node; + }, + code: (node: HTMLDivElement | null) => { + scrollerByTab.current.code = node; + }, + }), + [], + ); const foldRef = useRef(null); const condensedRowRef = useRef(null); // Refund after the fold commits so the content under the reader does not jump with its height. - const compensationRef = useRef(null); + const compensationRef = useRef<{ + tab: DetailTab; + scroller: HTMLDivElement; + delta: number; + } | null>(null); useLayoutEffect(() => { - if (compensationRef.current === null) return; - const scroller = scrollerRef.current; - const delta = compensationRef.current; + const compensation = compensationRef.current; + if (compensation === null) return; compensationRef.current = null; - if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); - }, [condensed]); + if ( + !condensed || + compensation.tab !== tab || + scrollerByTab.current[tab] !== compensation.scroller + ) + return; + compensation.scroller.scrollTop = Math.max( + 0, + compensation.scroller.scrollTop + compensation.delta, + ); + }, [condensed, tab]); const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver( @@ -2737,8 +2763,8 @@ export function PullRequestDetailPanel({
{ - const scroller = event.target as HTMLElement; - scrollerRef.current = scroller; + const scroller = scrollerByTab.current[tab]; + if (event.target !== scroller || scroller === null || scroller === undefined) return; const top = scroller.scrollTop; setChromeCondensed((previous) => { let next = previous; @@ -2753,7 +2779,7 @@ export function PullRequestDetailPanel({ next = false; } } else if (foldHeight > 0 && top > foldHeight + 32) { - compensationRef.current = -chromeDelta; + compensationRef.current = { tab, scroller, delta: -chromeDelta }; next = true; } chromeStateByTab.current[tab] = next; @@ -2779,6 +2805,7 @@ export function PullRequestDetailPanel({ {mountedTabs.has("summary") ? (
) : ( }> { function render(value = detail) { return ( {}} environmentId={EnvironmentId.make("environment")} threadRef={null} reference={value} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index bfda1209f9cbefbf08f9b3832a364cbe913db577..2b3de089310839e035faf32f606252483369cf4c 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -376,6 +376,7 @@ const CommentFilterSchema = Schema.Struct({ bots: Schema.Boolean, resolved: Sche const DEFAULT_COMMENT_FILTER: typeof CommentFilterSchema.Type = { bots: false, resolved: false }; export function PullRequestSummaryTab({ + scrollerRef, environmentId, threadRef, reference, @@ -390,6 +391,7 @@ export function PullRequestSummaryTab({ onRefresh, onRefreshChecks = onRefresh, }: { + scrollerRef: (node: HTMLDivElement | null) => void; environmentId: EnvironmentId; threadRef: ScopedThreadRef | null; reference: PullRequestRef; @@ -657,7 +659,7 @@ export function PullRequestSummaryTab({ }; return ( -
+
} label="Reviewers"> diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 69421fefbbcecb13a7828f7ac8f8bbbc1d724fae..b45bd73a7ea9f7bdd3043a5b75d57597f17e0f6d 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -538,6 +538,7 @@ function ReviewVerdictEvent({ } export function PullRequestTimelineTab({ + scrollerRef, detail, environmentId, threadRef = null, @@ -546,6 +547,7 @@ export function PullRequestTimelineTab({ onOpenCommit, onRefresh, }: { + scrollerRef: (node: HTMLDivElement | null) => void; detail: PullRequestDetailView; environmentId: EnvironmentId; threadRef?: ScopedThreadRef | null; @@ -577,7 +579,7 @@ export function PullRequestTimelineTab({ }; return ( -
+
-- 2.54.0