diff --git a/webviews/common/constants.ts b/webviews/common/constants.ts index f556c0be96..55a0367153 100644 --- a/webviews/common/constants.ts +++ b/webviews/common/constants.ts @@ -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'; diff --git a/webviews/components/header.tsx b/webviews/components/header.tsx index 9418515e19..7b65839d49 100644 --- a/webviews/components/header.tsx +++ b/webviews/components/header.tsx @@ -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'; @@ -129,7 +130,7 @@ function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurr {canEdit ? - : null} diff --git a/webviews/editorWebview/index.css b/webviews/editorWebview/index.css index 9fe8777f9e..1f05a8eb88 100644 --- a/webviews/editorWebview/index.css +++ b/webviews/editorWebview/index.css @@ -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 { @@ -567,6 +602,7 @@ small-button { display: flex; gap: 8px; padding-top: 4px; + align-items: center; } .header-actions>div:first-of-type { @@ -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; } @@ -1242,6 +1282,10 @@ code { padding-bottom: 0px; } + .title.stuck .overview-title h2 { + font-size: 16px; + } + #app { display: block; } diff --git a/webviews/editorWebview/overview.tsx b/webviews/editorWebview/overview.tsx index 36aeea2834..e6ac8a4471 100644 --- a/webviews/editorWebview/overview.tsx +++ b/webviews/editorWebview/overview.tsx @@ -31,9 +31,53 @@ const useMediaQuery = (query: string) => { export const Overview = (pr: PullRequest) => { const isSingleColumnLayout = useMediaQuery('(max-width: 768px)'); + const titleRef = React.useRef(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 <> -
+
diff --git a/webviews/editorWebview/test/overview.test.tsx b/webviews/editorWebview/test/overview.test.tsx new file mode 100644 index 0000000000..9a6e5980a3 --- /dev/null +++ b/webviews/editorWebview/test/overview.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + const titleElement = out.container.querySelector('.title'); + assert(titleElement); + + // Initial state should not have sticky class + assert(!titleElement.classList.contains('sticky')); + }); +});