diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index d85ecf4321b..d877956a2c8 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -790,21 +790,13 @@ export class CancelAction extends Action2 { group: 'navigation', }, { id: MenuId.ChatEditorInlineExecute, - when: ContextKeyExpr.and( - ChatContextKeys.requestInProgress, - ChatContextKeys.remoteJobCreating.negate() - ), - order: 4, - group: 'navigation', - }, { - id: MenuId.ChatEditingEditorContent, when: ContextKeyExpr.and( ctxIsGlobalEditingSession.negate(), - ctxHasRequestInProgress + ctxHasRequestInProgress, ), order: 4, group: 'navigation', - }, + } ], keybinding: { weight: KeybindingWeight.WorkbenchContrib, diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts index 1d86d2d7257..6de32e2eebc 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts @@ -17,6 +17,7 @@ import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { EditorResourceAccessor, SideBySideEditor, TEXT_DIFF_EDITOR_ID } from '../../../../common/editor.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { ACTIVE_GROUP, IEditorService } from '../../../../services/editor/common/editorService.js'; +import { CTX_HOVER_MODE } from '../../../inlineChat/common/inlineChat.js'; import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; import { NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_EDITOR_FOCUSED } from '../../../notebook/common/notebookContextKeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -310,6 +311,11 @@ class ToggleDiffAction extends ChatEditingEditorAction { group: 'a_resolve', order: 2, when: ContextKeyExpr.and(ctxReviewModeEnabled) + }, { + id: MenuId.ChatEditorInlineExecute, + group: 'a_resolve', + order: 2, + when: ContextKeyExpr.and(ctxReviewModeEnabled, CTX_HOVER_MODE) }] }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts index 48c6be29680..01a96720e8f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts @@ -5,18 +5,17 @@ import './media/chatEditingEditorOverlay.css'; import { combinedDisposable, Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable, observableFromEvent, observableFromEventOpts, observableSignalFromEvent, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, observableFromEvent, observableSignalFromEvent, observableValue, transaction } from '../../../../../base/common/observable.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; -import { ActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { IActionRunner } from '../../../../../base/common/actions.js'; +import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction, IActionRunner } from '../../../../../base/common/actions.js'; import { $, addDisposableGenericMouseMoveListener, append } from '../../../../../base/browser/dom.js'; import { assertType } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; import { AcceptAction, navigationBearingFakeActionId, RejectAction } from './chatEditingEditorActions.js'; -import { IChatService } from '../../common/chatService/chatService.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { EditorGroupView } from '../../../../browser/parts/editor/editorGroupView.js'; @@ -24,17 +23,82 @@ import { Event } from '../../../../../base/common/event.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; -import { IInlineChatSessionService } from '../../../inlineChat/browser/inlineChatSessionService.js'; -import { InlineChatConfigKeys } from '../../../inlineChat/common/inlineChat.js'; import { isEqual } from '../../../../../base/common/resources.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { ObservableEditorSession } from './chatEditingEditorContextKeys.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +export class ChatEditingAcceptRejectActionViewItem extends ActionViewItem { + + private readonly _reveal = this._store.add(new MutableDisposable()); + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + private readonly _entry: IObservable, + private readonly _editor: { focus(): void } | undefined, + private readonly _keybindingService: IKeybindingService, + private readonly _primaryActionIds: readonly string[] = [AcceptAction.ID], + ) { + super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true }); + } + + override render(container: HTMLElement): void { + super.render(container); + + if (this._primaryActionIds.includes(this._action.id)) { + this.element?.classList.add('primary'); + } + + if (this._action.id === AcceptAction.ID) { + + const listener = this._store.add(new MutableDisposable()); + + this._store.add(autorun(r => { + + assertType(this.label); + assertType(this.element); + + const ctrl = this._entry.read(r)?.autoAcceptController.read(r); + if (ctrl) { + + const ratio = -100 * (ctrl.remaining / ctrl.total); + + this.element.style.setProperty('--vscode-action-item-auto-timeout', `${ratio}%`); + + this.element.classList.toggle('auto', true); + listener.value = addDisposableGenericMouseMoveListener(this.element, () => ctrl.cancel()); + } else { + this.element.classList.toggle('auto', false); + listener.clear(); + } + })); + } + } + + override set actionRunner(actionRunner: IActionRunner) { + super.actionRunner = actionRunner; + if (this._editor) { + this._reveal.value = actionRunner.onWillRun(_e => { + this._editor!.focus(); + }); + } + } + + override get actionRunner(): IActionRunner { + return super.actionRunner; + } + + protected override getTooltip(): string | undefined { + const value = super.getTooltip(); + if (!value) { + return value; + } + return this._keybindingService.appendKeybinding(value, this._action.id); + } +} + class ChatEditorOverlayWidget extends Disposable { private readonly _domNode: HTMLElement; @@ -50,7 +114,6 @@ class ChatEditorOverlayWidget extends Disposable { constructor( private readonly _editor: { focus(): void }, - @IChatService private readonly _chatService: IChatService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IInstantiationService private readonly _instaService: IInstantiationService, ) { @@ -60,42 +123,7 @@ class ChatEditorOverlayWidget extends Disposable { this._isBusy = derived(r => { const entry = this._entry.read(r); - const session = this._session.read(r); - return entry?.waitsForLastEdits.read(r) ?? !session?.isGlobalEditingSession; // aka inline chat - }); - - const requestMessage = derived(r => { - - const session = this._session.read(r); - const chatModel = session?.chatSessionResource && this._chatService.getSession(session?.chatSessionResource); - if (!session || !chatModel) { - return undefined; - } - - // For inline chat (non-global sessions), get progress directly from the chat model's current request/response - // This ensures progress messages appear immediately when streaming starts, before lastModifyingResponse is set - const response = session.isGlobalEditingSession - ? this._entry.read(r)?.lastModifyingResponse.read(r) - : chatModel.lastRequestObs.read(r)?.response; - - if (!response) { - return { message: localize('working', "Working...") }; - } - - const lastPart = observableFromEventOpts({ equalsFn: () => false }, response.onDidChange, () => response.response.value) - .read(r) - .filter(part => part.kind === 'progressMessage' || part.kind === 'toolInvocation') - .at(-1); - - if (lastPart?.kind === 'toolInvocation') { - return { message: lastPart.invocationMessage }; - - } else if (lastPart?.kind === 'progressMessage') { - return { message: lastPart.content }; - - } else { - return { message: localize('working', "Working...") }; - } + return entry?.waitsForLastEdits.read(r); }); @@ -106,16 +134,10 @@ class ChatEditorOverlayWidget extends Disposable { this._domNode.appendChild(progressNode); this._store.add(autorun(r => { - const value = requestMessage.read(r); const busy = this._isBusy.read(r); this._domNode.classList.toggle('busy', busy); - - if (!busy || !value || this._session.read(r)?.isGlobalEditingSession) { - textProgress.innerText = ''; - } else if (value) { - textProgress.innerText = renderAsPlaintext(value.message); - } + textProgress.innerText = ''; })); this._toolbarNode = document.createElement('div'); @@ -233,63 +255,7 @@ class ChatEditorOverlayWidget extends Disposable { } if (action.id === AcceptAction.ID || action.id === RejectAction.ID) { - return new class extends ActionViewItem { - - private readonly _reveal = this._store.add(new MutableDisposable()); - - constructor() { - super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true }); - } - - override render(container: HTMLElement): void { - super.render(container); - - if (action.id === AcceptAction.ID) { - this.element?.classList.add('primary'); - - const listener = this._store.add(new MutableDisposable()); - - this._store.add(autorun(r => { - - assertType(this.label); - assertType(this.element); - - const ctrl = that._entry.read(r)?.autoAcceptController.read(r); - if (ctrl) { - - const r = -100 * (ctrl.remaining / ctrl.total); - - this.element.style.setProperty('--vscode-action-item-auto-timeout', `${r}%`); - - this.element.classList.toggle('auto', true); - listener.value = addDisposableGenericMouseMoveListener(this.element, () => ctrl.cancel()); - } else { - this.element.classList.toggle('auto', false); - listener.clear(); - } - })); - } - } - - override set actionRunner(actionRunner: IActionRunner) { - super.actionRunner = actionRunner; - this._reveal.value = actionRunner.onWillRun(_e => { - that._editor.focus(); - }); - } - - override get actionRunner(): IActionRunner { - return super.actionRunner; - } - - protected override getTooltip(): string | undefined { - const value = super.getTooltip(); - if (!value) { - return value; - } - return that._keybindingService.appendKeybinding(value, action.id); - } - }; + return new ChatEditingAcceptRejectActionViewItem(action, options, that._entry, that._editor, that._keybindingService); } return undefined; @@ -318,10 +284,7 @@ class ChatEditingOverlayController { container: HTMLElement, group: IEditorGroup, @IInstantiationService instaService: IInstantiationService, - @IChatService chatService: IChatService, @IChatEditingService chatEditingService: IChatEditingService, - @IInlineChatSessionService inlineChatService: IInlineChatSessionService, - @IConfigurationService configurationService: IConfigurationService, ) { this._domNode.classList.add('chat-editing-editor-overlay'); @@ -369,18 +332,17 @@ class ChatEditingOverlayController { return undefined; } - return new ObservableEditorSession(uri, chatEditingService, inlineChatService).value.read(r); - }); - - const isInProgress = derived(r => { - - const session = sessionAndEntry.read(r)?.session; - if (!session) { - return false; + // Directly query global editing sessions (inline chat has its own overlay) + for (const session of chatEditingService.editingSessionsObs.read(r)) { + if (!session.isGlobalEditingSession) { + continue; + } + const entry = session.readEntry(uri, r); + if (entry) { + return { session, entry }; + } } - - const chatModel = chatService.getSession(session.chatSessionResource)!; - return chatModel.requestInProgress.read(r); + return undefined; }); this._store.add(autorun(r => { @@ -394,16 +356,7 @@ class ChatEditingOverlayController { const { session, entry } = data; - if (!session.isGlobalEditingSession && configurationService.getValue(InlineChatConfigKeys.RenderMode) === 'zone') { - // inline chat with zone UI - no need for chat overlay - hide(); - return; - } - - if ( - entry?.state.read(r) === ModifiedFileEntryState.Modified // any entry changing - || (!session.isGlobalEditingSession && isInProgress.read(r)) // inline chat request - ) { + if (entry?.state.read(r) === ModifiedFileEntryState.Modified) { // any session with changes const editorPane = group.activeEditorPane; assertType(editorPane); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index ac5eed62a31..54f5a5c9eee 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -27,6 +27,7 @@ import { refactorCommandId, sourceActionCommandId } from '../../../../editor/con registerEditorContribution(InlineChatController.ID, InlineChatController, EditorContributionInstantiation.Eager); // EAGER because of notebook dispose/create of editors registerAction2(InlineChatActions.KeepSessionAction2); +registerAction2(InlineChatActions.UndoSessionAction2); registerAction2(InlineChatActions.UndoAndCloseSessionAction2); // --- browser diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index d2970111d99..6e0c2c0ff52 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -11,7 +11,7 @@ import { EmbeddedDiffEditorWidget } from '../../../../editor/browser/widget/diff import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { InlineChatController, InlineChatRunOptions } from './inlineChatController.js'; -import { ACTION_ACCEPT_CHANGES, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED } from '../common/inlineChat.js'; +import { ACTION_ACCEPT_CHANGES, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_HOVER_MODE } from '../common/inlineChat.js'; import { ctxHasEditorModification, ctxHasRequestInProgress } from '../../chat/browser/chatEditing/chatEditingEditorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId } from '../../../../platform/actions/common/actions.js'; @@ -269,16 +269,15 @@ export class KeepSessionAction2 extends KeepOrUndoSessionAction { } } - -export class UndoAndCloseSessionAction2 extends KeepOrUndoSessionAction { +export class UndoSessionAction2 extends KeepOrUndoSessionAction { constructor() { super(false, { - id: 'inlineChat2.close', - title: localize2('close2', "Close"), + id: 'inlineChat2.undo', + title: localize2('undo', "Undo"), f1: true, - icon: Codicon.close, - precondition: CTX_INLINE_CHAT_VISIBLE, + icon: Codicon.discard, + precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE, CTX_HOVER_MODE), keybinding: [{ when: ContextKeyExpr.or( ContextKeyExpr.and(EditorContextKeys.focus, ctxHasEditorModification.negate()), @@ -290,7 +289,41 @@ export class UndoAndCloseSessionAction2 extends KeepOrUndoSessionAction { menu: [{ id: MenuId.ChatEditorInlineExecute, group: 'navigation', - order: 100 + order: 100, + when: ContextKeyExpr.and( + CTX_HOVER_MODE, + ctxHasRequestInProgress.negate(), + ctxHasEditorModification, + ) + }] + }); + } +} + + + +export class UndoAndCloseSessionAction2 extends KeepOrUndoSessionAction { + + constructor() { + super(false, { + id: 'inlineChat2.close', + title: localize2('close2', "Close"), + f1: true, + icon: Codicon.close, + precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE, CTX_HOVER_MODE.negate()), + keybinding: [{ + when: ContextKeyExpr.or( + ContextKeyExpr.and(EditorContextKeys.focus, ctxHasEditorModification.negate()), + ChatContextKeys.inputHasFocus, + ), + weight: KeybindingWeight.WorkbenchContrib + 1, + primary: KeyCode.Escape, + }], + menu: [{ + id: MenuId.ChatEditorInlineExecute, + group: 'navigation', + order: 100, + when: CTX_HOVER_MODE.negate() }] }); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 136a5e70fca..3015f9c9d39 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -52,7 +52,7 @@ import { CellUri, ICellEditOperation } from '../../notebook/common/notebookCommo import { INotebookService } from '../../notebook/common/notebookService.js'; import { CTX_INLINE_CHAT_VISIBLE, InlineChatConfigKeys } from '../common/inlineChat.js'; import { InlineChatAffordance } from './inlineChatAffordance.js'; -import { InlineChatInputOverlayWidget } from './inlineChatOverlayWidget.js'; +import { InlineChatInputOverlayWidget, InlineChatSessionOverlayWidget } from './inlineChatOverlayWidget.js'; import { IInlineChatSession2, IInlineChatSessionService } from './inlineChatSessionService.js'; import { EditorBasedInlineChatWidget } from './inlineChatWidget.js'; import { InlineChatZoneWidget } from './inlineChatZoneWidget.js'; @@ -139,12 +139,15 @@ export class InlineChatController implements IEditorContribution { @IMarkerDecorationsService private readonly _markerDecorationsService: IMarkerDecorationsService, @ILanguageModelsService private readonly _languageModelService: ILanguageModelsService, ) { + const editorObs = observableCodeEditor(_editor); + const ctxInlineChatVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService); const notebookAgentConfig = observableConfigValue(InlineChatConfigKeys.notebookAgent, false, this._configurationService); this._renderMode = observableConfigValue(InlineChatConfigKeys.RenderMode, 'zone', this._configurationService); const overlayWidget = this._store.add(this._instaService.createInstance(InlineChatInputOverlayWidget, this._editor)); + const sessionOverlayWidget = this._store.add(this._instaService.createInstance(InlineChatSessionOverlayWidget, editorObs)); this._gutterIndicator = this._store.add(this._instaService.createInstance(InlineChatAffordance, this._editor, overlayWidget)); this._zone = new Lazy(() => { @@ -218,7 +221,6 @@ export class InlineChatController implements IEditorContribution { }); - const editorObs = observableCodeEditor(_editor); const sessionsSignal = observableSignalFromEvent(this, _inlineChatSessionService.onDidChangeSessions); @@ -311,6 +313,25 @@ export class InlineChatController implements IEditorContribution { } })); + // Show progress overlay widget in hover mode when a request is in progress or edits are not yet settled + this._store.add(autorun(r => { + const session = visibleSessionObs.read(r); + const renderMode = this._renderMode.read(r); + if (!session || renderMode !== 'hover') { + sessionOverlayWidget.hide(); + return; + } + const lastRequest = session.chatModel.lastRequestObs.read(r); + const isInProgress = lastRequest?.response?.isInProgress.read(r); + const entry = session.editingSession.readEntry(session.uri, r); + const isNotSettled = !entry || entry.state.read(r) === ModifiedFileEntryState.Modified; + if (isInProgress || isNotSettled) { + sessionOverlayWidget.show(session); + } else { + sessionOverlayWidget.hide(); + } + })); + this._store.add(autorun(r => { const session = visibleSessionObs.read(r); if (session) { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index 32b9436c75b..27c79d605aa 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -3,23 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import './media/inlineChatSessionOverlay.css'; import * as dom from '../../../../base/browser/dom.js'; +import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IAction, Separator } from '../../../../base/common/actions.js'; import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableFromEventOpts, observableValue } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; -import { IObservable, observableValue } from '../../../../base/common/observable.js'; -import { IActiveCodeEditor, ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; +import { ContentWidgetPositionPreference, IActiveCodeEditor, ICodeEditor, IContentWidgetPosition, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; +import { ObservableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { IModelService } from '../../../../editor/common/services/model.js'; +import { localize } from '../../../../nls.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { localize } from '../../../../nls.js'; -import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ChatEditingAcceptRejectActionViewItem } from '../../chat/browser/chatEditing/chatEditingEditorOverlay.js'; import { ACTION_START } from '../common/inlineChat.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; @@ -27,7 +35,11 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOptions.js'; import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; import { InlineChatRunOptions } from './inlineChatController.js'; +import { IInlineChatSession2 } from './inlineChatSessionService.js'; import { Position } from '../../../../editor/common/core/position.js'; +import { SelectionDirection } from '../../../../editor/common/core/selection.js'; +import { CancelChatActionId } from '../../chat/browser/actions/chatExecuteActions.js'; +import { assertType } from '../../../../base/common/types.js'; /** * Overlay widget that displays a vertical action bar menu. @@ -272,3 +284,158 @@ export class InlineChatInputOverlayWidget extends Disposable implements IOverlay super.dispose(); } } + +/** + * Overlay widget that displays progress messages during inline chat requests. + */ +export class InlineChatSessionOverlayWidget extends Disposable { + + private readonly _domNode: HTMLElement; + private readonly _progressNode: HTMLElement; + private readonly _progressMessage: HTMLElement; + private readonly _toolbarNode: HTMLElement; + + private readonly _showStore = this._store.add(new DisposableStore()); + private readonly _session = observableValue(this, undefined); + private readonly _position = observableValue(this, null); + + constructor( + private readonly _editorObs: ObservableCodeEditor, + @IInstantiationService private readonly _instaService: IInstantiationService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + ) { + super(); + + // Create container with styling for session overlay + this._domNode = document.createElement('div'); + this._domNode.classList.add('inline-chat-session-overlay-widget'); + + // Create progress node + this._progressNode = document.createElement('div'); + this._progressNode.classList.add('progress'); + dom.append(this._progressNode, renderIcon(ThemeIcon.modify(Codicon.loading, 'spin'))); + this._progressMessage = dom.append(this._progressNode, dom.$('span.progress-message')); + this._domNode.appendChild(this._progressNode); + + // Create toolbar node + this._toolbarNode = document.createElement('div'); + this._toolbarNode.classList.add('toolbar'); + + // Set up progress message observable + const requestMessage = derived(r => { + const session = this._session.read(r); + const chatModel = session?.chatModel; + if (!session || !chatModel) { + return undefined; + } + + const response = chatModel.lastRequestObs.read(r)?.response; + if (!response) { + return { message: localize('working', "Working...") }; + } + + const lastPart = observableFromEventOpts({ equalsFn: () => false }, response.onDidChange, () => response.response.value) + .read(r) + .filter(part => part.kind === 'progressMessage' || part.kind === 'toolInvocation') + .at(-1); + + if (lastPart?.kind === 'toolInvocation') { + return { message: lastPart.invocationMessage }; + } else if (lastPart?.kind === 'progressMessage') { + return { message: lastPart.content }; + } else { + return { message: localize('working', "Working...") }; + } + }); + + this._store.add(autorun(r => { + const value = requestMessage.read(r); + if (value) { + this._progressMessage.innerText = renderAsPlaintext(value.message); + } else { + this._progressMessage.innerText = ''; + } + })); + } + + show(session: IInlineChatSession2): void { + assertType(this._editorObs.editor.hasModel()); + this._showStore.clear(); + + this._session.set(session, undefined); + + // Derived entry observable for this session + const entry = derived(r => session.editingSession.readEntry(session.uri, r)); + + // Keep busy class in sync with whether edits are being streamed + this._showStore.add(autorun(r => { + const e = entry.read(r); + const isBusy = !e || !!e.isCurrentlyBeingModifiedBy.read(r); + this._domNode.classList.toggle('busy', isBusy); + })); + + // Add toolbar + this._domNode.appendChild(this._toolbarNode); + this._showStore.add(toDisposable(() => this._toolbarNode.remove())); + + const that = this; + + this._showStore.add(this._instaService.createInstance(MenuWorkbenchToolBar, this._toolbarNode, MenuId.ChatEditorInlineExecute, { + telemetrySource: 'inlineChatProgress.overlayToolbar', + hiddenItemStrategy: HiddenItemStrategy.Ignore, + toolbarOptions: { + primaryGroup: () => true, + useSeparatorsInPrimaryActions: true + }, + menuOptions: { renderShortTitle: true }, + actionViewItemProvider: (action, options) => { + const primaryActions = [CancelChatActionId, 'inlineChat2.keep']; + const labeledActions = primaryActions.concat(['inlineChat2.undo']); + + if (!labeledActions.includes(action.id)) { + return undefined; // use default action view item with label + } + + return new ChatEditingAcceptRejectActionViewItem(action, options, entry, undefined, that._keybindingService, primaryActions); + } + })); + + // Position based on diff info, updating as changes stream in + const selection = this._editorObs.cursorSelection.get()!; + const above = selection.getDirection() === SelectionDirection.RTL; + + this._showStore.add(autorun(r => { + let newPosition = selection.getPosition(); + const e = entry.read(r); + const diffInfo = e?.diffInfo?.read(r); + const position = that._position.read(undefined)?.position; + if (diffInfo && position) { + for (const change of diffInfo.changes) { + if (change.modified.contains(position.lineNumber)) { + newPosition = new Position(change.modified.startLineNumber - 1, 1); + break; + } + } + } + + this._position.set({ + position: newPosition, + preference: [above ? ContentWidgetPositionPreference.ABOVE : ContentWidgetPositionPreference.BELOW] + }, undefined); + })); + + // Create content widget + this._showStore.add(this._editorObs.createContentWidget({ + domNode: this._domNode, + position: this._position, + allowEditorOverflow: true, + })); + } + + hide(): void { + this._position.set(null, undefined); + this._domNode.classList.remove('busy'); + this._session.set(undefined, undefined); + this._showStore.clear(); + } +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatSessionOverlay.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatSessionOverlay.css new file mode 100644 index 00000000000..ca01b58f3bb --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatSessionOverlay.css @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.inline-chat-session-overlay-widget { + padding: 2px 4px; + color: var(--vscode-foreground); + background-color: var(--vscode-editorWidget-background); + border-radius: 6px; + border: 1px solid var(--vscode-contrastBorder); + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + z-index: 10; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + overflow: hidden; +} + +@keyframes inline-chat-session-pulse { + 0% { + box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); + } + 50% { + box-shadow: 0 2px 8px 4px var(--vscode-widget-shadow); + } + 100% { + box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); + } +} + +.inline-chat-session-overlay-widget.busy { + animation: inline-chat-session-pulse ease-in 2.3s infinite; +} + +.inline-chat-session-overlay-widget .progress { + align-items: center; + display: none; + padding: 5px 0 5px 5px; + font-size: 12px; + overflow: hidden; + gap: 6px; +} + +.inline-chat-session-overlay-widget.busy .progress { + display: inline-flex; +} + +.inline-chat-session-overlay-widget .progress .progress-message { + white-space: nowrap; + max-width: 13em; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 8px; +} + +.inline-chat-session-overlay-widget.busy .progress .codicon { + color: var(--vscode-foreground); +} + +.inline-chat-session-overlay-widget .action-item > .action-label { + padding: 4px 6px; + font-size: 11px; + line-height: 14px; + border-radius: 4px; +} + +.inline-chat-session-overlay-widget .monaco-action-bar .actions-container { + gap: 4px; +} + +.inline-chat-session-overlay-widget .action-item.primary > .action-label { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.monaco-workbench .inline-chat-session-overlay-widget .monaco-action-bar .action-item.primary > .action-label:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.inline-chat-session-overlay-widget .action-item > .action-label.codicon:not(.separator) { + color: var(--vscode-foreground); + width: 22px; + height: 22px; + padding: 0; + font-size: 16px; + line-height: 22px; + display: flex; + align-items: center; + justify-content: center; +} + +.chat-editor-overlay-widget .action-item.primary > .action-label { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.inline-chat-session-overlay-widget .monaco-action-bar .action-item.disabled { + + > .action-label.codicon::before, + > .action-label.codicon, + > .action-label, + > .action-label:hover { + color: var(--vscode-button-separator); + opacity: 1; + } +} diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 311cb19ddda..0d324f3fdb5 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -129,6 +129,8 @@ export const CTX_INLINE_CHAT_V2_ENABLED = ContextKeyExpr.or( ContextKeyExpr.and(NOTEBOOK_IS_ACTIVE_EDITOR, CTX_INLINE_CHAT_HAS_NOTEBOOK_AGENT) ); +export const CTX_HOVER_MODE = ContextKeyExpr.equals('config.inlineChat.renderMode', 'hover'); + // --- (selected) action identifier export const ACTION_START = 'inlineChat.start';