diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 251f3cd49f95..726bcdfae780 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -192,6 +192,7 @@ function getReviewPositionAnchor(position: PullRequestReviewPosition): { * drafted rather than being posted as it is typed. */ function PullRequestCodeTab({ + scrollerRef, environmentId, reference, detail, @@ -204,6 +205,7 @@ function PullRequestCodeTab({ onRefresh, refreshToken = 0, }: { + scrollerRef: (node: HTMLDivElement | null) => void; environmentId: EnvironmentId; reference: PullRequestRef; detail: PullRequestDetailView; @@ -1327,7 +1329,9 @@ function PullRequestCodeTab({ const withToolbar = (body: ReactNode) => (
{toolbar} -
{body}
+
+ {body} +
); @@ -1507,6 +1511,7 @@ function PullRequestCodeTab({ // interaction, but its native host outline clips and competes with the focus // indicators on its actual controls. className="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 884bef0bf0b8..a7940caaf7b1 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx @@ -101,43 +101,49 @@ vi.mock("./PullRequestThreadLinks", () => ({ PullRequestThreadLinks: () => null vi.mock("./PullRequestSummaryTab", () => ({ PullRequestSummaryTab: ({ onFixFinding, + scrollerRef, }: ComponentProps) => ( - +
+ +
), })); vi.mock("./PullRequestCodeTab", () => ({ default: ({ onAddToAgentSelection, + scrollerRef, }: ComponentProps) => ( - +
+ +
), })); @@ -336,3 +342,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 1b42fdd4481a..2b34fdeda2a2 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -548,18 +548,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); // Server-side and per project, like every other project setting. The @@ -2686,8 +2712,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; @@ -2702,7 +2728,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; @@ -2722,6 +2748,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 cd2eaf6de501..1b07b4dd574d 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -455,6 +455,7 @@ function CommentGroup({ const COMMENT_PAGE = 10; export function PullRequestSummaryTab({ + scrollerRef, environmentId, threadRef, reference, @@ -469,6 +470,7 @@ export function PullRequestSummaryTab({ onRefresh, onRefreshChecks = onRefresh, }: { + scrollerRef: (node: HTMLDivElement | null) => void; environmentId: EnvironmentId; threadRef: ScopedThreadRef | null; reference: PullRequestRef; @@ -708,7 +710,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 9435304f6d7e..30bddef576e8 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 ( -
+