authorgravatar for adam.bogucki2018@gmail.comAdamulek123 <adam.bogucki2018@gmail.com> 2026-10-11 05:31:17+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 03:31:17+00:00
log75fca7d77cd4a5f72167606f248131edb633bb50
tree081576c94b5c9ea3ea65ab83cac8d1b4bc790ab6
parent6c130163941618480da333979bf6fc675d803e4f
signature Signed by PGP key B5690EEEBB952194

fix(web): scope PR title collapse to tab scrollers (#14644)


6 files changed, 167 insertions(+), 43 deletions(-)

apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+6-1
...@@ -196,6 +196,7 @@ function getReviewPositionAnchor(position: PullRequestReviewPosition): {...@@ -196,6 +196,7 @@ function getReviewPositionAnchor(position: PullRequestReviewPosition): {
196 * drafted rather than being posted as it is typed.196 * drafted rather than being posted as it is typed.
197 */197 */
198function PullRequestCodeTab({198function PullRequestCodeTab({
199 scrollerRef,
199 environmentId,200 environmentId,
200 reference,201 reference,
201 detail,202 detail,
...@@ -208,6 +209,7 @@ function PullRequestCodeTab({...@@ -208,6 +209,7 @@ function PullRequestCodeTab({
208 onRefresh,209 onRefresh,
209 refreshToken = 0,210 refreshToken = 0,
210}: {211}: {
212 scrollerRef: (node: HTMLDivElement | null) => void;
211 environmentId: EnvironmentId;213 environmentId: EnvironmentId;
212 reference: PullRequestRef;214 reference: PullRequestRef;
213 detail: PullRequestDetailView;215 detail: PullRequestDetailView;
...@@ -1394,7 +1396,9 @@ function PullRequestCodeTab({...@@ -1394,7 +1396,9 @@ function PullRequestCodeTab({
1394 const withToolbar = (body: ReactNode) => (1396 const withToolbar = (body: ReactNode) => (
1395 <div className="flex h-full min-h-0 flex-col">1397 <div className="flex h-full min-h-0 flex-col">
1396 {toolbar}1398 {toolbar}
1397 <div className="min-h-0 flex-1 overflow-auto">{body}</div>1399 <div ref={scrollerRef} className="min-h-0 flex-1 overflow-auto">
1400 {body}
1401 </div>
1398 </div>1402 </div>
1399 );1403 );
14001404
...@@ -1574,6 +1578,7 @@ function PullRequestCodeTab({...@@ -1574,6 +1578,7 @@ function PullRequestCodeTab({
1574 // interaction, but its native host outline clips and competes with the focus1578 // interaction, but its native host outline clips and competes with the focus
1575 // indicators on its actual controls.1579 // indicators on its actual controls.
1576 className="@container h-full overflow-auto [scrollbar-gutter:stable]"1580 className="@container h-full overflow-auto [scrollbar-gutter:stable]"
1581 containerRef={scrollerRef}
1577 viewerRef={setViewer}1582 viewerRef={setViewer}
1578 items={items}1583 items={items}
1579 selectedLines={selectedLines}1584 selectedLines={selectedLines}
apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx+115-30
...@@ -108,43 +108,49 @@ vi.mock("./PullRequestThreadLinks", () => ({ PullRequestThreadLinks: () => null...@@ -108,43 +108,49 @@ vi.mock("./PullRequestThreadLinks", () => ({ PullRequestThreadLinks: () => null
108vi.mock("./PullRequestSummaryTab", () => ({108vi.mock("./PullRequestSummaryTab", () => ({
109 PullRequestSummaryTab: ({109 PullRequestSummaryTab: ({
110 onFixFinding,110 onFixFinding,
111 scrollerRef,
111 }: ComponentProps<typeof import("./PullRequestSummaryTab").PullRequestSummaryTab>) => (112 }: ComponentProps<typeof import("./PullRequestSummaryTab").PullRequestSummaryTab>) => (
112 <button113 <div ref={scrollerRef} data-test-scroll="summary">
113 onClick={() =>114 <button
114 onFixFinding?.({115 onClick={() =>
115 kind: "check",116 onFixFinding?.({
116 check: { name: "Unit tests", status: "failure", description: "Test failed", url: null },117 kind: "check",
117 })118 check: { name: "Unit tests", status: "failure", description: "Test failed", url: null },
118 }119 })
119 >120 }
120 Fix check121 >
121 </button>122 Fix check
123 </button>
124 </div>
122 ),125 ),
123}));126}));
124vi.mock("./PullRequestCodeTab", () => ({127vi.mock("./PullRequestCodeTab", () => ({
125 default: ({128 default: ({
126 onAddToAgentSelection,129 onAddToAgentSelection,
130 scrollerRef,
127 }: ComponentProps<typeof import("./PullRequestCodeTab").default>) => (131 }: ComponentProps<typeof import("./PullRequestCodeTab").default>) => (
128 <button132 <div ref={scrollerRef} data-test-scroll="code">
129 onClick={() =>133 <button
130 onAddToAgentSelection?.({134 onClick={() =>
131 request: "Fix this line",135 onAddToAgentSelection?.({
132 comment: {136 request: "Fix this line",
133 id: "note-1",137 comment: {
134 sectionId: "file:a.ts",138 id: "note-1",
135 sectionTitle: "a.ts",139 sectionId: "file:a.ts",
136 filePath: "a.ts",140 sectionTitle: "a.ts",
137 startIndex: 0,141 filePath: "a.ts",
138 endIndex: 0,142 startIndex: 0,
139 rangeLabel: "L1",143 endIndex: 0,
140 text: "Please fix",144 rangeLabel: "L1",
141 diff: "+broken()",145 text: "Please fix",
142 },146 diff: "+broken()",
143 })147 },
144 }148 })
145 >149 }
146 Add to agent150 >
147 </button>151 Add to agent
152 </button>
153 </div>
148 ),154 ),
149}));155}));
150156
...@@ -343,3 +349,82 @@ describe.each([...@@ -343,3 +349,82 @@ describe.each([
343 }349 }
344 });350 });
345});351});
352
353it("changes the title only for the active tab's main scroller", async () => {
354 const scrollers = new Map<string, { scrollTop: number; scrollHeight: number }>();
355 await act(async () => {
356 renderer = create(
357 <PullRequestDetailPanel
358 environmentId={threadRef.environmentId}
359 reference={detail}
360 context="page"
361 shortcutsEnabled={false}
362 getShortcutContext={() => ({
363 terminalFocus: false,
364 terminalOpen: false,
365 previewFocus: false,
366 previewOpen: false,
367 isWeb: true,
368 isDesktop: false,
369 })}
370 />,
371 {
372 createNodeMock: (element) => {
373 const props = element.props as Record<string, unknown>;
374 const name = props["data-test-scroll"] as string | undefined;
375 if (name) {
376 const scroller = { scrollTop: 0, scrollHeight: 300 };
377 scrollers.set(name, scroller);
378 return scroller;
379 }
380 return { scrollHeight: props.inert === false ? 100 : 20 };
381 },
382 },
383 );
384 });
385
386 const mainScroll = renderer.root.findByProps({
387 className: "relative flex min-h-0 flex-1 flex-col overflow-hidden",
388 });
389 const titleFold = () => {
390 let node = renderer.root.findByType("h1");
391 while (typeof node.props.inert !== "boolean") node = node.parent!;
392 return node;
393 };
394 const summary = scrollers.get("summary")!;
395 const nested = { scrollTop: 200 };
396 await act(async () => mainScroll.props.onScrollCapture({ target: nested }));
397 expect(titleFold().props.inert).toBe(false);
398 summary.scrollTop = 180;
399 nested.scrollTop = 0;
400 await act(async () => {
401 mainScroll.props.onScrollCapture({ target: summary });
402 mainScroll.props.onScrollCapture({ target: nested });
403 });
404 expect(titleFold().props.inert).toBe(true);
405 expect(summary.scrollTop).toBe(100);
406 expect(nested.scrollTop).toBe(0);
407
408 await act(async () => mainScroll.props.onScrollCapture({ target: { scrollTop: 0 } }));
409 expect(titleFold().props.inert).toBe(true);
410 summary.scrollTop = 0;
411 await act(async () => mainScroll.props.onScrollCapture({ target: summary }));
412 expect(titleFold().props.inert).toBe(false);
413
414 await click("Code");
415 const code = scrollers.get("code")!;
416 summary.scrollTop = 180;
417 await act(async () => mainScroll.props.onScrollCapture({ target: summary }));
418 expect(titleFold().props.inert).toBe(false);
419 code.scrollTop = 180;
420 await act(async () => mainScroll.props.onScrollCapture({ target: code }));
421 expect(titleFold().props.inert).toBe(true);
422 expect(code.scrollTop).toBe(100);
423 await click("Summary");
424 expect(titleFold().props.inert).toBe(false);
425 await click("Code");
426 expect(titleFold().props.inert).toBe(true);
427 code.scrollTop = 0;
428 await act(async () => mainScroll.props.onScrollCapture({ target: code }));
429 expect(titleFold().props.inert).toBe(false);
430});
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+39-10
...@@ -526,18 +526,44 @@ export function PullRequestDetailPanel({...@@ -526,18 +526,44 @@ export function PullRequestDetailPanel({
526 setChromeCondensed(chromeStateByTab.current[tab] ?? false);526 setChromeCondensed(chromeStateByTab.current[tab] ?? false);
527 }, [tab]);527 }, [tab]);
528 const condensed = chromeCondensed;528 const condensed = chromeCondensed;
529 const scrollerRef = useRef<HTMLElement | null>(null);529 const scrollerByTab = useRef<Partial<Record<DetailTab, HTMLDivElement | null>>>({});
530 const scrollerRefs = useMemo(
531 () => ({
532 summary: (node: HTMLDivElement | null) => {
533 scrollerByTab.current.summary = node;
534 },
535 timeline: (node: HTMLDivElement | null) => {
536 scrollerByTab.current.timeline = node;
537 },
538 code: (node: HTMLDivElement | null) => {
539 scrollerByTab.current.code = node;
540 },
541 }),
542 [],
543 );
530 const foldRef = useRef<HTMLDivElement | null>(null);544 const foldRef = useRef<HTMLDivElement | null>(null);
531 const condensedRowRef = useRef<HTMLDivElement | null>(null);545 const condensedRowRef = useRef<HTMLDivElement | null>(null);
532 // Refund after the fold commits so the content under the reader does not jump with its height.546 // Refund after the fold commits so the content under the reader does not jump with its height.
533 const compensationRef = useRef<number | null>(null);547 const compensationRef = useRef<{
548 tab: DetailTab;
549 scroller: HTMLDivElement;
550 delta: number;
551 } | null>(null);
534 useLayoutEffect(() => {552 useLayoutEffect(() => {
535 if (compensationRef.current === null) return;553 const compensation = compensationRef.current;
536 const scroller = scrollerRef.current;554 if (compensation === null) return;
537 const delta = compensationRef.current;
538 compensationRef.current = null;555 compensationRef.current = null;
539 if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);556 if (
540 }, [condensed]);557 !condensed ||
558 compensation.tab !== tab ||
559 scrollerByTab.current[tab] !== compensation.scroller
560 )
561 return;
562 compensation.scroller.scrollTop = Math.max(
563 0,
564 compensation.scroller.scrollTop + compensation.delta,
565 );
566 }, [condensed, tab]);
541 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);567 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
542 const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);568 const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);
543 const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver(569 const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver(
...@@ -2737,8 +2763,8 @@ export function PullRequestDetailPanel({...@@ -2737,8 +2763,8 @@ export function PullRequestDetailPanel({
2737 <div2763 <div
2738 className="relative flex min-h-0 flex-1 flex-col overflow-hidden"2764 className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
2739 onScrollCapture={(event) => {2765 onScrollCapture={(event) => {
2740 const scroller = event.target as HTMLElement;2766 const scroller = scrollerByTab.current[tab];
2741 scrollerRef.current = scroller;2767 if (event.target !== scroller || scroller === null || scroller === undefined) return;
2742 const top = scroller.scrollTop;2768 const top = scroller.scrollTop;
2743 setChromeCondensed((previous) => {2769 setChromeCondensed((previous) => {
2744 let next = previous;2770 let next = previous;
...@@ -2753,7 +2779,7 @@ export function PullRequestDetailPanel({...@@ -2753,7 +2779,7 @@ export function PullRequestDetailPanel({
2753 next = false;2779 next = false;
2754 }2780 }
2755 } else if (foldHeight > 0 && top > foldHeight + 32) {2781 } else if (foldHeight > 0 && top > foldHeight + 32) {
2756 compensationRef.current = -chromeDelta;2782 compensationRef.current = { tab, scroller, delta: -chromeDelta };
2757 next = true;2783 next = true;
2758 }2784 }
2759 chromeStateByTab.current[tab] = next;2785 chromeStateByTab.current[tab] = next;
...@@ -2779,6 +2805,7 @@ export function PullRequestDetailPanel({...@@ -2779,6 +2805,7 @@ export function PullRequestDetailPanel({
2779 {mountedTabs.has("summary") ? (2805 {mountedTabs.has("summary") ? (
2780 <div className={cn("absolute inset-0", tab !== "summary" && "invisible")}>2806 <div className={cn("absolute inset-0", tab !== "summary" && "invisible")}>
2781 <PullRequestSummaryTab2807 <PullRequestSummaryTab
2808 scrollerRef={scrollerRefs.summary}
2782 environmentId={environmentId}2809 environmentId={environmentId}
2783 threadRef={threadRef}2810 threadRef={threadRef}
2784 reference={reference}2811 reference={reference}
...@@ -2806,6 +2833,7 @@ export function PullRequestDetailPanel({...@@ -2806,6 +2833,7 @@ export function PullRequestDetailPanel({
2806 />2833 />
2807 ) : (2834 ) : (
2808 <PullRequestTimelineTab2835 <PullRequestTimelineTab
2836 scrollerRef={scrollerRefs.timeline}
2809 detail={detail}2837 detail={detail}
2810 environmentId={environmentId}2838 environmentId={environmentId}
2811 threadRef={threadRef}2839 threadRef={threadRef}
...@@ -2821,6 +2849,7 @@ export function PullRequestDetailPanel({...@@ -2821,6 +2849,7 @@ export function PullRequestDetailPanel({
2821 <div className={cn("absolute inset-0", tab !== "code" && "invisible")}>2849 <div className={cn("absolute inset-0", tab !== "code" && "invisible")}>
2822 <Suspense fallback={<DiffPanelLoadingState label="Loading pull request diff..." />}>2850 <Suspense fallback={<DiffPanelLoadingState label="Loading pull request diff..." />}>
2823 <PullRequestCodeTab2851 <PullRequestCodeTab
2852 scrollerRef={scrollerRefs.code}
2824 onAddToAgentSelection={addSelectionToAgent}2853 onAddToAgentSelection={addSelectionToAgent}
2825 environmentId={environmentId}2854 environmentId={environmentId}
2826 reference={reference}2855 reference={reference}
apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx+1
...@@ -101,6 +101,7 @@ afterEach(() => {...@@ -101,6 +101,7 @@ afterEach(() => {
101function render(value = detail) {101function render(value = detail) {
102 return (102 return (
103 <PullRequestSummaryTab103 <PullRequestSummaryTab
104 scrollerRef={() => {}}
104 environmentId={EnvironmentId.make("environment")}105 environmentId={EnvironmentId.make("environment")}
105 threadRef={null}106 threadRef={null}
106 reference={value}107 reference={value}
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+3-1
...@@ -376,6 +376,7 @@ const CommentFilterSchema = Schema.Struct({ bots: Schema.Boolean, resolved: Sche...@@ -376,6 +376,7 @@ const CommentFilterSchema = Schema.Struct({ bots: Schema.Boolean, resolved: Sche
376const DEFAULT_COMMENT_FILTER: typeof CommentFilterSchema.Type = { bots: false, resolved: false };376const DEFAULT_COMMENT_FILTER: typeof CommentFilterSchema.Type = { bots: false, resolved: false };
377377
378export function PullRequestSummaryTab({378export function PullRequestSummaryTab({
379 scrollerRef,
379 environmentId,380 environmentId,
380 threadRef,381 threadRef,
381 reference,382 reference,
...@@ -390,6 +391,7 @@ export function PullRequestSummaryTab({...@@ -390,6 +391,7 @@ export function PullRequestSummaryTab({
390 onRefresh,391 onRefresh,
391 onRefreshChecks = onRefresh,392 onRefreshChecks = onRefresh,
392}: {393}: {
394 scrollerRef: (node: HTMLDivElement | null) => void;
393 environmentId: EnvironmentId;395 environmentId: EnvironmentId;
394 threadRef: ScopedThreadRef | null;396 threadRef: ScopedThreadRef | null;
395 reference: PullRequestRef;397 reference: PullRequestRef;
...@@ -657,7 +659,7 @@ export function PullRequestSummaryTab({...@@ -657,7 +659,7 @@ export function PullRequestSummaryTab({
657 };659 };
658660
659 return (661 return (
660 <div className="h-full overflow-y-auto" data-pull-request-summary-scroll>662 <div ref={scrollerRef} className="h-full overflow-y-auto" data-pull-request-summary-scroll>
661 <section className="px-4 pt-2.5 pb-1">663 <section className="px-4 pt-2.5 pb-1">
662 <div className="space-y-2">664 <div className="space-y-2">
663 <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers">665 <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers">
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+3-1
...@@ -538,6 +538,7 @@ function ReviewVerdictEvent({...@@ -538,6 +538,7 @@ function ReviewVerdictEvent({
538}538}
539539
540export function PullRequestTimelineTab({540export function PullRequestTimelineTab({
541 scrollerRef,
541 detail,542 detail,
542 environmentId,543 environmentId,
543 threadRef = null,544 threadRef = null,
...@@ -546,6 +547,7 @@ export function PullRequestTimelineTab({...@@ -546,6 +547,7 @@ export function PullRequestTimelineTab({
546 onOpenCommit,547 onOpenCommit,
547 onRefresh,548 onRefresh,
548}: {549}: {
550 scrollerRef: (node: HTMLDivElement | null) => void;
549 detail: PullRequestDetailView;551 detail: PullRequestDetailView;
550 environmentId: EnvironmentId;552 environmentId: EnvironmentId;
551 threadRef?: ScopedThreadRef | null;553 threadRef?: ScopedThreadRef | null;
...@@ -577,7 +579,7 @@ export function PullRequestTimelineTab({...@@ -577,7 +579,7 @@ export function PullRequestTimelineTab({
577 };579 };
578580
579 return (581 return (
580 <div className="scrollbar-gutter-both h-full overflow-y-auto px-4 py-5">582 <div ref={scrollerRef} className="scrollbar-gutter-both h-full overflow-y-auto px-4 py-5">
581 <div className="mx-auto max-w-3xl">583 <div className="mx-auto max-w-3xl">
582 <div className="relative">584 <div className="relative">
583 <span aria-hidden className="absolute bottom-5 left-[15px] top-1 w-px bg-border/45" />585 <span aria-hidden className="absolute bottom-5 left-[15px] top-1 w-px bg-border/45" />