Skip to content

Commit 2006a97

Browse files
Copilotalexr00
andauthored
Add sticky header to PR/Issue views (#8285)
* Initial plan * Initial plan for sticky PR/Issue headers Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Add sticky header functionality with compact mode Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Add high contrast mode and mobile responsiveness support Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Add test for sticky header functionality Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Address code review: add constant and use requestAnimationFrame Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Fix flickering at threshold with hysteresis Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Fix useEffect dependency issue with ref Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Fix flickering with IntersectionObserver and pure CSS approach Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Fix subtitle visibility and remove side borders/shadows Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Fix subtitle visibility by adding flex: 1 to .details Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Fix IntersectionObserver to prevent stuck state on page load Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Fix sentinel observer to use threshold 0 without negative rootMargin Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Explicitly remove stuck class on mount and use threshold 1 Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Replace IntersectionObserver with scroll-based position detection Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Add requestAnimationFrame throttling and extract threshold constant Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * reduce diff * Hide edit title button when in sticky mode Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Use ID instead of title attribute for edit button selector Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com>
1 parent 9b7cc20 commit 2006a97

5 files changed

Lines changed: 153 additions & 2 deletions

File tree

webviews/common/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,8 @@
77
* ID for the main comment textarea element in the PR description page.
88
*/
99
export const COMMENT_TEXTAREA_ID = 'comment-textarea';
10+
11+
/**
12+
* ID for the edit title button in the PR/Issue header.
13+
*/
14+
export const EDIT_TITLE_BUTTON_ID = 'edit-title-button';

webviews/components/header.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '.
1111
import { CopilotStartedEvent, TimelineEvent } from '../../src/common/timelineEvent';
1212
import { GithubItemStateEnum, StateReason } from '../../src/github/interface';
1313
import { CodingAgentContext, OverviewContext, PullRequest } from '../../src/github/views';
14+
import { EDIT_TITLE_BUTTON_ID } from '../common/constants';
1415
import PullRequestContext from '../common/context';
1516
import { useStateProp } from '../common/hooks';
1617

@@ -129,7 +130,7 @@ function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurr
129130
</a>
130131
</h2>
131132
{canEdit ?
132-
<button title="Rename" onClick={() => setEditMode(true)} className="icon-button">
133+
<button id={EDIT_TITLE_BUTTON_ID} title="Rename" onClick={() => setEditMode(true)} className="icon-button">
133134
{editIcon}
134135
</button>
135136
: null}

webviews/editorWebview/index.css

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,46 @@ textarea:focus,
4848
}
4949

5050
.title {
51+
position: sticky;
52+
top: 0;
53+
z-index: 100;
5154
display: flex;
5255
align-items: flex-start;
5356
margin: 20px 0 24px;
5457
padding-bottom: 24px;
5558
border-bottom: 1px solid var(--vscode-list-inactiveSelectionBackground);
59+
background: var(--vscode-editor-background);
60+
}
61+
62+
.title .details {
63+
flex: 1;
64+
}
65+
66+
/* Shadow effect when stuck - only on bottom */
67+
.title.stuck::after {
68+
content: '';
69+
position: absolute;
70+
bottom: 0;
71+
left: 0;
72+
right: 0;
73+
height: 1px;
74+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
75+
pointer-events: none;
76+
}
77+
78+
/* Hide subtitle when stuck */
79+
.title.stuck .subtitle {
80+
display: none;
81+
}
82+
83+
/* Hide edit button when stuck */
84+
.title.stuck #edit-title-button {
85+
display: none;
86+
}
87+
88+
/* Adjust title size when stuck */
89+
.title.stuck .overview-title h2 {
90+
font-size: 18px;
5691
}
5792

5893
.title .pr-number {
@@ -567,6 +602,7 @@ small-button {
567602
display: flex;
568603
gap: 8px;
569604
padding-top: 4px;
605+
align-items: center;
570606
}
571607

572608
.header-actions>div:first-of-type {
@@ -1216,6 +1252,10 @@ code {
12161252
border-bottom: 1px solid var(--vscode-contrastBorder);
12171253
}
12181254

1255+
.vscode-high-contrast .title.stuck::after {
1256+
box-shadow: none;
1257+
}
1258+
12191259
.vscode-high-contrast .diff .diffLine {
12201260
background: none;
12211261
}
@@ -1242,6 +1282,10 @@ code {
12421282
padding-bottom: 0px;
12431283
}
12441284

1285+
.title.stuck .overview-title h2 {
1286+
font-size: 16px;
1287+
}
1288+
12451289
#app {
12461290
display: block;
12471291
}

webviews/editorWebview/overview.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,53 @@ const useMediaQuery = (query: string) => {
3131

3232
export const Overview = (pr: PullRequest) => {
3333
const isSingleColumnLayout = useMediaQuery('(max-width: 768px)');
34+
const titleRef = React.useRef<HTMLDivElement>(null);
35+
36+
React.useEffect(() => {
37+
const title = titleRef.current;
38+
39+
if (!title) {
40+
return;
41+
}
42+
43+
// Initially ensure title is not stuck
44+
title.classList.remove('stuck');
45+
46+
// Small threshold to account for sub-pixel rendering
47+
const STICKY_THRESHOLD = 1;
48+
49+
// Use scroll event with requestAnimationFrame to detect when title becomes sticky
50+
// Check if the title's top position is at the viewport top (sticky position)
51+
let ticking = false;
52+
const handleScroll = () => {
53+
if (!ticking) {
54+
window.requestAnimationFrame(() => {
55+
const rect = title.getBoundingClientRect();
56+
// Title is stuck when its top is at position 0 (sticky top: 0)
57+
if (rect.top <= STICKY_THRESHOLD) {
58+
title.classList.add('stuck');
59+
} else {
60+
title.classList.remove('stuck');
61+
}
62+
ticking = false;
63+
});
64+
ticking = true;
65+
}
66+
};
67+
68+
// Check initial state after a brief delay to ensure layout is settled
69+
const timeoutId = setTimeout(handleScroll, 100);
70+
71+
window.addEventListener('scroll', handleScroll, { passive: true });
72+
73+
return () => {
74+
clearTimeout(timeoutId);
75+
window.removeEventListener('scroll', handleScroll);
76+
};
77+
}, []);
3478

3579
return <>
36-
<div id="title" className="title">
80+
<div id="title" className="title" ref={titleRef}>
3781
<div className="details">
3882
<Header {...pr} />
3983
</div>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { default as assert } from 'assert';
7+
import * as React from 'react';
8+
import { cleanup, render } from 'react-testing-library';
9+
import { createSandbox, SinonSandbox } from 'sinon';
10+
11+
import { PRContext, default as PullRequestContext } from '../../common/context';
12+
import { Overview } from '../overview';
13+
import { PullRequestBuilder } from './builder/pullRequest';
14+
15+
describe('Overview', function () {
16+
let sinon: SinonSandbox;
17+
18+
beforeEach(function () {
19+
sinon = createSandbox();
20+
});
21+
22+
afterEach(function () {
23+
cleanup();
24+
sinon.restore();
25+
});
26+
27+
it('renders the PR header with title', function () {
28+
const pr = new PullRequestBuilder().build();
29+
const context = new PRContext(pr);
30+
31+
const out = render(
32+
<PullRequestContext.Provider value={context}>
33+
<Overview {...pr} />
34+
</PullRequestContext.Provider>,
35+
);
36+
37+
assert(out.container.querySelector('.title'));
38+
assert(out.container.querySelector('.overview-title'));
39+
});
40+
41+
it('applies sticky class when scrolled', function () {
42+
const pr = new PullRequestBuilder().build();
43+
const context = new PRContext(pr);
44+
45+
const out = render(
46+
<PullRequestContext.Provider value={context}>
47+
<Overview {...pr} />
48+
</PullRequestContext.Provider>,
49+
);
50+
51+
const titleElement = out.container.querySelector('.title');
52+
assert(titleElement);
53+
54+
// Initial state should not have sticky class
55+
assert(!titleElement.classList.contains('sticky'));
56+
});
57+
});

0 commit comments

Comments
 (0)