Skip to content

Commit cb46ded

Browse files
Copilotalexr00
andcommitted
Fix flickering with IntersectionObserver and pure CSS approach
Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com>
1 parent 623cbe2 commit cb46ded

File tree

3 files changed

+74
-96
lines changed

3 files changed

+74
-96
lines changed

webviews/components/header.tsx

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,8 @@ export function Header({
3232
owner,
3333
repo,
3434
busy,
35-
stateReason,
36-
isCompact
37-
}: PullRequest & { isCompact?: boolean }) {
35+
stateReason
36+
}: PullRequest) {
3837
const [currentTitle, setCurrentTitle] = useStateProp(title);
3938
const [inEditMode, setEditMode] = useState(false);
4039
const codingAgentEvent = mostRecentCopilotEvent(events);
@@ -52,16 +51,9 @@ export function Header({
5251
canEdit={canEdit}
5352
owner={owner}
5453
repo={repo}
55-
isCompact={isCompact}
5654
/>
57-
{!isCompact && <Subtitle state={state} stateReason={stateReason} head={head} base={base} author={author} isIssue={isIssue} isDraft={isDraft} codingAgentEvent={codingAgentEvent} />}
55+
<Subtitle state={state} stateReason={stateReason} head={head} base={base} author={author} isIssue={isIssue} isDraft={isDraft} codingAgentEvent={codingAgentEvent} />
5856
<div className="header-actions">
59-
{isCompact && (
60-
<div id="status" className={`status-badge-${getStatus(state, !!isDraft, isIssue, stateReason).color}`}>
61-
<span className='icon'>{getStatus(state, !!isDraft, isIssue, stateReason).icon}</span>
62-
<span>{getStatus(state, !!isDraft, isIssue, stateReason).text}</span>
63-
</div>
64-
)}
6557
<ButtonGroup
6658
isCurrentlyCheckedOut={isCurrentlyCheckedOut}
6759
isIssue={isIssue}
@@ -88,10 +80,9 @@ interface TitleProps {
8880
canEdit: boolean;
8981
owner: string;
9082
repo: string;
91-
isCompact?: boolean;
9283
}
9384

94-
function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurrentTitle, canEdit, owner, repo, isCompact }: TitleProps): JSX.Element {
85+
function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurrentTitle, canEdit, owner, repo }: TitleProps): JSX.Element {
9586
const { setTitle, copyPrLink } = useContext(PullRequestContext);
9687

9788
const titleForm = (
@@ -129,24 +120,22 @@ function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurr
129120
context['github:copyMenu'] = true;
130121

131122
const displayTitle = (
132-
<div className={`overview-title ${isCompact ? 'compact' : ''}`}>
123+
<div className="overview-title">
133124
<h2>
134125
<span dangerouslySetInnerHTML={{ __html: titleHTML }} />
135126
{' '}
136127
<a href={url} title={url} data-vscode-context={JSON.stringify(context)}>
137128
#{number}
138129
</a>
139130
</h2>
140-
{!isCompact && canEdit ?
131+
{canEdit ?
141132
<button title="Rename" onClick={() => setEditMode(true)} className="icon-button">
142133
{editIcon}
143134
</button>
144135
: null}
145-
{!isCompact && (
146-
<button title="Copy Link" onClick={copyPrLink} className="icon-button" aria-label="Copy Pull Request Link">
147-
{copyIcon}
148-
</button>
149-
)}
136+
<button title="Copy Link" onClick={copyPrLink} className="icon-button" aria-label="Copy Pull Request Link">
137+
{copyIcon}
138+
</button>
150139
</div>
151140
);
152141

webviews/editorWebview/index.css

Lines changed: 33 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -48,50 +48,57 @@ 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);
56-
transition: all 0.2s ease;
59+
background: var(--vscode-editor-background);
5760
}
5861

59-
.title.sticky {
60-
position: sticky;
62+
/* Shadow effect when stuck */
63+
.title::before {
64+
content: '';
65+
position: absolute;
6166
top: 0;
62-
z-index: 100;
67+
left: -32px;
68+
right: -32px;
69+
bottom: 0;
6370
background: var(--vscode-editor-background);
64-
margin: 0;
65-
padding: 8px 0;
66-
border-bottom: 1px solid var(--vscode-panel-border);
6771
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
72+
opacity: 0;
73+
transition: opacity 0.2s ease;
74+
pointer-events: none;
75+
z-index: -1;
6876
}
6977

70-
.title.sticky .details {
71-
width: 100%;
78+
.title.stuck::before {
79+
opacity: 1;
7280
}
7381

74-
.title.sticky .overview-title h2 {
75-
font-size: 18px;
76-
margin: 0;
82+
/* Hide subtitle when stuck */
83+
.title .subtitle {
84+
transition: opacity 0.2s ease, max-height 0.2s ease;
85+
max-height: 100px;
86+
overflow: hidden;
7787
}
7888

79-
.title.sticky .header-actions {
80-
padding-top: 0;
89+
.title.stuck .subtitle {
90+
opacity: 0;
91+
max-height: 0;
92+
pointer-events: none;
8193
}
8294

83-
.overview-title.compact {
84-
display: flex;
85-
align-items: center;
86-
gap: 8px;
95+
/* Adjust title size when stuck */
96+
.title .overview-title h2 {
97+
transition: font-size 0.2s ease;
8798
}
8899

89-
.overview-title.compact h2 {
100+
.title.stuck .overview-title h2 {
90101
font-size: 18px;
91-
margin: 0;
92-
white-space: nowrap;
93-
overflow: hidden;
94-
text-overflow: ellipsis;
95102
}
96103

97104
.title .pr-number {
@@ -605,15 +612,6 @@ small-button {
605612
align-items: center;
606613
}
607614

608-
.title.sticky .header-actions {
609-
flex-wrap: nowrap;
610-
}
611-
612-
.title.sticky .header-actions #status {
613-
margin-right: 0;
614-
flex-shrink: 0;
615-
}
616-
617615
.header-actions>div:first-of-type {
618616
flex: 1;
619617
}
@@ -1261,9 +1259,9 @@ code {
12611259
border-bottom: 1px solid var(--vscode-contrastBorder);
12621260
}
12631261

1264-
.vscode-high-contrast .title.sticky {
1265-
border: 1px solid var(--vscode-contrastBorder);
1262+
.vscode-high-contrast .title.stuck::before {
12661263
box-shadow: none;
1264+
border: 1px solid var(--vscode-contrastBorder);
12671265
}
12681266

12691267
.vscode-high-contrast .diff .diffLine {
@@ -1292,19 +1290,10 @@ code {
12921290
padding-bottom: 0px;
12931291
}
12941292

1295-
.title.sticky {
1296-
padding: 8px 0;
1297-
border-bottom: 1px solid var(--vscode-panel-border);
1298-
}
1299-
1300-
.title.sticky .overview-title h2 {
1293+
.title.stuck .overview-title h2 {
13011294
font-size: 16px;
13021295
}
13031296

1304-
.title.sticky .button-group {
1305-
flex-wrap: nowrap;
1306-
}
1307-
13081297
#app {
13091298
display: block;
13101299
}

webviews/editorWebview/overview.tsx

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,49 +29,49 @@ const useMediaQuery = (query: string) => {
2929
return matches;
3030
};
3131

32-
const STICKY_THRESHOLD = 80;
33-
const STICKY_THRESHOLD_BUFFER = 10;
34-
3532
export const Overview = (pr: PullRequest) => {
3633
const isSingleColumnLayout = useMediaQuery('(max-width: 768px)');
37-
const [isSticky, setIsSticky] = React.useState(false);
38-
const isStickyRef = React.useRef(isSticky);
39-
40-
// Keep ref in sync with state
41-
React.useEffect(() => {
42-
isStickyRef.current = isSticky;
43-
}, [isSticky]);
34+
const titleRef = React.useRef<HTMLDivElement>(null);
35+
const sentinelRef = React.useRef<HTMLDivElement>(null);
4436

4537
React.useEffect(() => {
46-
let ticking = false;
38+
const sentinel = sentinelRef.current;
39+
const title = titleRef.current;
40+
41+
if (!sentinel || !title) {
42+
return;
43+
}
4744

48-
const handleScroll = () => {
49-
if (!ticking) {
50-
window.requestAnimationFrame(() => {
51-
const scrollY = window.scrollY;
52-
const currentSticky = isStickyRef.current;
53-
// Use hysteresis to prevent flickering at the threshold
54-
// When not sticky, activate when scrollY > threshold
55-
// When sticky, deactivate when scrollY < (threshold - buffer)
56-
if (!currentSticky && scrollY > STICKY_THRESHOLD) {
57-
setIsSticky(true);
58-
} else if (currentSticky && scrollY < STICKY_THRESHOLD - STICKY_THRESHOLD_BUFFER) {
59-
setIsSticky(false);
60-
}
61-
ticking = false;
62-
});
63-
ticking = true;
45+
// Use IntersectionObserver to detect when the sentinel leaves the viewport
46+
// This indicates the title is stuck
47+
const observer = new IntersectionObserver(
48+
([entry]) => {
49+
// When sentinel is not intersecting, title is stuck
50+
if (entry.isIntersecting) {
51+
title.classList.remove('stuck');
52+
} else {
53+
title.classList.add('stuck');
54+
}
55+
},
56+
{
57+
threshold: [1],
58+
rootMargin: '0px'
6459
}
65-
};
60+
);
61+
62+
observer.observe(sentinel);
6663

67-
window.addEventListener('scroll', handleScroll, { passive: true });
68-
return () => window.removeEventListener('scroll', handleScroll);
64+
return () => {
65+
observer.disconnect();
66+
};
6967
}, []);
7068

7169
return <>
72-
<div id="title" className={`title ${isSticky ? 'sticky' : ''}`}>
70+
{/* Sentinel element that sits just above the sticky title */}
71+
<div ref={sentinelRef} style={{ height: '1px', marginTop: '-1px' }} />
72+
<div id="title" className="title" ref={titleRef}>
7373
<div className="details">
74-
<Header {...pr} isCompact={isSticky} />
74+
<Header {...pr} />
7575
</div>
7676
</div>
7777
{isSingleColumnLayout ?

0 commit comments

Comments
 (0)