Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b28ad03
Initial plan
Copilot Dec 10, 2025
eadb467
Initial plan for sticky PR/Issue headers
Copilot Dec 10, 2025
f3da833
Add sticky header functionality with compact mode
Copilot Dec 10, 2025
79ab7d6
Add high contrast mode and mobile responsiveness support
Copilot Dec 10, 2025
e52df1b
Add test for sticky header functionality
Copilot Dec 10, 2025
a88fb0b
Address code review: add constant and use requestAnimationFrame
Copilot Dec 10, 2025
e9e5ee1
Fix flickering at threshold with hysteresis
Copilot Dec 12, 2025
623cbe2
Fix useEffect dependency issue with ref
Copilot Dec 12, 2025
cb46ded
Fix flickering with IntersectionObserver and pure CSS approach
Copilot Dec 15, 2025
515a52c
Fix subtitle visibility and remove side borders/shadows
Copilot Dec 15, 2025
53ff457
Fix subtitle visibility by adding flex: 1 to .details
Copilot Dec 15, 2025
3375693
Fix IntersectionObserver to prevent stuck state on page load
Copilot Dec 15, 2025
afd4627
Fix sentinel observer to use threshold 0 without negative rootMargin
Copilot Dec 16, 2025
863c9f4
Explicitly remove stuck class on mount and use threshold 1
Copilot Dec 16, 2025
030cf1c
Replace IntersectionObserver with scroll-based position detection
Copilot Dec 16, 2025
99f5a0c
Add requestAnimationFrame throttling and extract threshold constant
Copilot Dec 16, 2025
8c2d4bb
reduce diff
alexr00 Dec 17, 2025
0d5f9a8
Hide edit title button when in sticky mode
Copilot Dec 17, 2025
1ce8868
Merge branch 'main' into copilot/add-sticky-header-functionality
alexr00 Dec 17, 2025
86aa087
Use ID instead of title attribute for edit button selector
Copilot Dec 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions webviews/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@
* ID for the main comment textarea element in the PR description page.
*/
export const COMMENT_TEXTAREA_ID = 'comment-textarea';

/**
* ID for the edit title button in the PR/Issue header.
*/
export const EDIT_TITLE_BUTTON_ID = 'edit-title-button';
3 changes: 2 additions & 1 deletion webviews/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { copilotEventToStatus, CopilotPRStatus, mostRecentCopilotEvent } from '.
import { CopilotStartedEvent, TimelineEvent } from '../../src/common/timelineEvent';
import { GithubItemStateEnum, StateReason } from '../../src/github/interface';
import { CodingAgentContext, OverviewContext, PullRequest } from '../../src/github/views';
import { EDIT_TITLE_BUTTON_ID } from '../common/constants';
import PullRequestContext from '../common/context';
import { useStateProp } from '../common/hooks';

Expand Down Expand Up @@ -129,7 +130,7 @@ function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurr
</a>
</h2>
{canEdit ?
<button title="Rename" onClick={() => setEditMode(true)} className="icon-button">
<button id={EDIT_TITLE_BUTTON_ID} title="Rename" onClick={() => setEditMode(true)} className="icon-button">

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these strings (such as Rename here) get localized?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, there isn't a good story for localizing strings in webviews.

{editIcon}
</button>
: null}
Expand Down
44 changes: 44 additions & 0 deletions webviews/editorWebview/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,46 @@ textarea:focus,
}

.title {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: flex-start;
margin: 20px 0 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--vscode-list-inactiveSelectionBackground);
background: var(--vscode-editor-background);
}

.title .details {
flex: 1;
}

/* Shadow effect when stuck - only on bottom */
.title.stuck::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
pointer-events: none;
}

/* Hide subtitle when stuck */
.title.stuck .subtitle {
display: none;
}

/* Hide edit button when stuck */
.title.stuck #edit-title-button {
display: none;
}

/* Adjust title size when stuck */
.title.stuck .overview-title h2 {
font-size: 18px;
}

.title .pr-number {
Expand Down Expand Up @@ -567,6 +602,7 @@ small-button {
display: flex;
gap: 8px;
padding-top: 4px;
align-items: center;
}

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

.vscode-high-contrast .title.stuck::after {
box-shadow: none;
}

.vscode-high-contrast .diff .diffLine {
background: none;
}
Expand All @@ -1242,6 +1282,10 @@ code {
padding-bottom: 0px;
}

.title.stuck .overview-title h2 {
font-size: 16px;
}

#app {
display: block;
}
Expand Down
46 changes: 45 additions & 1 deletion webviews/editorWebview/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,53 @@ const useMediaQuery = (query: string) => {

export const Overview = (pr: PullRequest) => {
const isSingleColumnLayout = useMediaQuery('(max-width: 768px)');
const titleRef = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
const title = titleRef.current;

if (!title) {
return;
}

// Initially ensure title is not stuck
title.classList.remove('stuck');

// Small threshold to account for sub-pixel rendering
const STICKY_THRESHOLD = 1;

// Use scroll event with requestAnimationFrame to detect when title becomes sticky
// Check if the title's top position is at the viewport top (sticky position)
let ticking = false;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
const rect = title.getBoundingClientRect();
// Title is stuck when its top is at position 0 (sticky top: 0)
if (rect.top <= STICKY_THRESHOLD) {
title.classList.add('stuck');
} else {
title.classList.remove('stuck');
}
ticking = false;
});
ticking = true;
}
};

// Check initial state after a brief delay to ensure layout is settled
const timeoutId = setTimeout(handleScroll, 100);

window.addEventListener('scroll', handleScroll, { passive: true });

return () => {
clearTimeout(timeoutId);
window.removeEventListener('scroll', handleScroll);
};
}, []);

return <>
<div id="title" className="title">
<div id="title" className="title" ref={titleRef}>
<div className="details">
<Header {...pr} />
</div>
Expand Down
57 changes: 57 additions & 0 deletions webviews/editorWebview/test/overview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { default as assert } from 'assert';
import * as React from 'react';
import { cleanup, render } from 'react-testing-library';
import { createSandbox, SinonSandbox } from 'sinon';

import { PRContext, default as PullRequestContext } from '../../common/context';
import { Overview } from '../overview';
import { PullRequestBuilder } from './builder/pullRequest';

describe('Overview', function () {
let sinon: SinonSandbox;

beforeEach(function () {
sinon = createSandbox();
});

afterEach(function () {
cleanup();
sinon.restore();
});

it('renders the PR header with title', function () {
const pr = new PullRequestBuilder().build();
const context = new PRContext(pr);

const out = render(
<PullRequestContext.Provider value={context}>
<Overview {...pr} />
</PullRequestContext.Provider>,
);

assert(out.container.querySelector('.title'));
assert(out.container.querySelector('.overview-title'));
});

it('applies sticky class when scrolled', function () {
const pr = new PullRequestBuilder().build();
const context = new PRContext(pr);

const out = render(
<PullRequestContext.Provider value={context}>
<Overview {...pr} />
</PullRequestContext.Provider>,
);

const titleElement = out.container.querySelector('.title');
assert(titleElement);

// Initial state should not have sticky class
assert(!titleElement.classList.contains('sticky'));
});
});