From bddac8596ed18cebd1f4fd365ea2e9ab4e87c9e1 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 28 Jan 2026 21:23:10 +0100 Subject: [PATCH] inline chat input: clamp widget to viewport when scrolling with content (#291390) When the input has content, the widget now stays visible and clamps to viewport edges (respecting sticky scroll height) instead of hiding when the anchor line scrolls out of view. Fixes https://github.com/microsoft/vscode/issues/291075 --- .../browser/inlineChatOverlayWidget.ts | 32 ++++++++++++++----- .../browser/media/inlineChatOverlayWidget.css | 4 +++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index e256be7a65c..9585c9972d0 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -51,15 +51,15 @@ export class InlineChatInputWidget extends Disposable { private readonly _input: IActiveCodeEditor; private readonly _position = observableValue(this, null); readonly position: IObservable = this._position; - readonly minContentWidthInPx = constObservable(0); + private readonly _showStore = this._store.add(new DisposableStore()); + private readonly _stickyScrollHeight: IObservable; private _inlineStartAction: IAction | undefined; private _anchorLineNumber: number = 0; private _anchorLeft: number = 0; private _anchorAbove: boolean = false; - readonly allowEditorOverflow = true; constructor( private readonly _editorObs: ObservableCodeEditor, @@ -108,6 +108,10 @@ export class InlineChatInputWidget extends Disposable { this._input.setModel(model); this._input.layout({ width: 200, height: 18 }); + // Initialize sticky scroll height observable + const stickyScrollController = StickyScrollController.get(this._editorObs.editor); + this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0); + // Update placeholder based on selection state this._store.add(autorun(r => { const selection = this._editorObs.cursorSelection.read(r); @@ -216,8 +220,8 @@ export class InlineChatInputWidget extends Disposable { this._showStore.add(this._editorObs.createOverlayWidget({ domNode: this._domNode, position: this._position, - minContentWidthInPx: this.minContentWidthInPx, - allowEditorOverflow: this.allowEditorOverflow, + minContentWidthInPx: constObservable(0), + allowEditorOverflow: true, })); // If anchoring above, adjust position after render to account for widget height @@ -225,13 +229,14 @@ export class InlineChatInputWidget extends Disposable { this._updatePosition(); } - // Update position on scroll, hide if anchor line is out of view + // Update position on scroll, hide if anchor line is out of view (only when input is empty) this._showStore.add(this._editorObs.editor.onDidScrollChange(() => { const visibleRanges = this._editorObs.editor.getVisibleRanges(); const isLineVisible = visibleRanges.some(range => this._anchorLineNumber >= range.startLineNumber && this._anchorLineNumber <= range.endLineNumber ); - if (!isLineVisible) { + const hasContent = !!this._input.getModel().getValue(); + if (!isLineVisible && !hasContent) { this._hide(); } else { this._updatePosition(); @@ -244,6 +249,7 @@ export class InlineChatInputWidget extends Disposable { private _updatePosition(): void { const editor = this._editorObs.editor; + const lineHeight = editor.getOption(EditorOption.lineHeight); const top = editor.getTopForLineNumber(this._anchorLineNumber) - editor.getScrollTop(); let adjustedTop = top; @@ -251,12 +257,22 @@ export class InlineChatInputWidget extends Disposable { const widgetHeight = this._domNode.offsetHeight; adjustedTop = top - widgetHeight; } else { - const lineHeight = editor.getOption(EditorOption.lineHeight); adjustedTop = top + lineHeight; } + // Clamp to viewport bounds when anchor line is out of view + const stickyScrollHeight = this._stickyScrollHeight.get(); + const layoutInfo = editor.getLayoutInfo(); + const widgetHeight = this._domNode.offsetHeight; + const minTop = stickyScrollHeight; + const maxTop = layoutInfo.height - widgetHeight; + + const clampedTop = Math.max(minTop, Math.min(adjustedTop, maxTop)); + const isClamped = clampedTop !== adjustedTop; + this._domNode.classList.toggle('clamped', isClamped); + this._position.set({ - preference: { top: adjustedTop, left: this._anchorLeft }, + preference: { top: clampedTop, left: this._anchorLeft }, stackOrdinal: 10000, }, undefined); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css index 864a19e8ede..1af3ff339a1 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.inline-chat-gutter-menu.clamped { + transition: top 100ms; +} + .inline-chat-gutter-menu .input .monaco-editor-background { background-color: var(--vscode-menu-background); }