Path: blob/main/src/vs/workbench/contrib/chat/browser/chatQuick.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as dom from '../../../../base/browser/dom.js';6import { Orientation, Sash } from '../../../../base/browser/ui/sash/sash.js';7import { disposableTimeout } from '../../../../base/common/async.js';8import { CancellationToken } from '../../../../base/common/cancellation.js';9import { Emitter, Event } from '../../../../base/common/event.js';10import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';11import { Selection } from '../../../../editor/common/core/selection.js';12import { MenuId } from '../../../../platform/actions/common/actions.js';13import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';14import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';15import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';16import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js';17import { IQuickInputService, IQuickWidget } from '../../../../platform/quickinput/common/quickInput.js';18import { editorBackground, inputBackground, quickInputBackground, quickInputForeground } from '../../../../platform/theme/common/colorRegistry.js';19import { IQuickChatOpenOptions, IQuickChatService, showChatView } from './chat.js';20import { ChatWidget } from './chatWidget.js';21import { ChatModel, isCellTextEditOperation } from '../common/chatModel.js';22import { IParsedChatRequest } from '../common/chatParserTypes.js';23import { IChatProgress, IChatService } from '../common/chatService.js';24import { IViewsService } from '../../../services/views/common/viewsService.js';25import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../common/theme.js';26import { ChatAgentLocation } from '../common/constants.js';2728export class QuickChatService extends Disposable implements IQuickChatService {29readonly _serviceBrand: undefined;3031private readonly _onDidClose = this._register(new Emitter<void>());32get onDidClose() { return this._onDidClose.event; }3334private _input: IQuickWidget | undefined;35// TODO@TylerLeonhardt: support multiple chat providers eventually36private _currentChat: QuickChat | undefined;37private _container: HTMLElement | undefined;3839constructor(40@IQuickInputService private readonly quickInputService: IQuickInputService,41@IChatService private readonly chatService: IChatService,42@IInstantiationService private readonly instantiationService: IInstantiationService,43) {44super();45}4647get enabled(): boolean {48return !!this.chatService.isEnabled(ChatAgentLocation.Panel);49}5051get focused(): boolean {52const widget = this._input?.widget as HTMLElement | undefined;53if (!widget) {54return false;55}56return dom.isAncestorOfActiveElement(widget);57}5859toggle(options?: IQuickChatOpenOptions): void {60// If the input is already shown, hide it. This provides a toggle behavior of the quick61// pick. This should not happen when there is a query.62if (this.focused && !options?.query) {63this.close();64} else {65this.open(options);66// If this is a partial query, the value should be cleared when closed as otherwise it67// would remain for the next time the quick chat is opened in any context.68if (options?.isPartialQuery) {69const disposable = this._store.add(Event.once(this.onDidClose)(() => {70this._currentChat?.clearValue();71this._store.delete(disposable);72}));73}74}75}7677open(options?: IQuickChatOpenOptions): void {78if (this._input) {79if (this._currentChat && options?.query) {80this._currentChat.focus();81this._currentChat.setValue(options.query, options.selection);82if (!options.isPartialQuery) {83this._currentChat.acceptInput();84}85return;86}87return this.focus();88}8990const disposableStore = new DisposableStore();9192this._input = this.quickInputService.createQuickWidget();93this._input.contextKey = 'chatInputVisible';94this._input.ignoreFocusOut = true;95disposableStore.add(this._input);9697this._container ??= dom.$('.interactive-session');98this._input.widget = this._container;99100this._input.show();101if (!this._currentChat) {102this._currentChat = this.instantiationService.createInstance(QuickChat);103104// show needs to come after the quickpick is shown105this._currentChat.render(this._container);106} else {107this._currentChat.show();108}109110disposableStore.add(this._input.onDidHide(() => {111disposableStore.dispose();112this._currentChat!.hide();113this._input = undefined;114this._onDidClose.fire();115}));116117this._currentChat.focus();118119if (options?.query) {120this._currentChat.setValue(options.query, options.selection);121if (!options.isPartialQuery) {122this._currentChat.acceptInput();123}124}125}126focus(): void {127this._currentChat?.focus();128}129close(): void {130this._input?.dispose();131this._input = undefined;132}133async openInChatView(): Promise<void> {134await this._currentChat?.openChatView();135this.close();136}137}138139class QuickChat extends Disposable {140// TODO@TylerLeonhardt: be responsive to window size141static DEFAULT_MIN_HEIGHT = 200;142private static readonly DEFAULT_HEIGHT_OFFSET = 100;143144private widget!: ChatWidget;145private sash!: Sash;146private model: ChatModel | undefined;147private _currentQuery: string | undefined;148private readonly maintainScrollTimer: MutableDisposable<IDisposable> = this._register(new MutableDisposable<IDisposable>());149private _deferUpdatingDynamicLayout: boolean = false;150151constructor(152@IInstantiationService private readonly instantiationService: IInstantiationService,153@IContextKeyService private readonly contextKeyService: IContextKeyService,154@IChatService private readonly chatService: IChatService,155@ILayoutService private readonly layoutService: ILayoutService,156@IViewsService private readonly viewsService: IViewsService,157) {158super();159}160161clear() {162this.model?.dispose();163this.model = undefined;164this.updateModel();165this.widget.inputEditor.setValue('');166}167168focus(selection?: Selection): void {169if (this.widget) {170this.widget.focusInput();171const value = this.widget.inputEditor.getValue();172if (value) {173this.widget.inputEditor.setSelection(selection ?? {174startLineNumber: 1,175startColumn: 1,176endLineNumber: 1,177endColumn: value.length + 1178});179}180}181}182183hide(): void {184this.widget.setVisible(false);185// Maintain scroll position for a short time so that if the user re-shows the chat186// the same scroll position will be used.187this.maintainScrollTimer.value = disposableTimeout(() => {188// At this point, clear this mutable disposable which will be our signal that189// the timer has expired and we should stop maintaining scroll position190this.maintainScrollTimer.clear();191}, 30 * 1000); // 30 seconds192}193194show(): void {195this.widget.setVisible(true);196// If the mutable disposable is set, then we are keeping the existing scroll position197// so we should not update the layout.198if (this._deferUpdatingDynamicLayout) {199this._deferUpdatingDynamicLayout = false;200this.widget.updateDynamicChatTreeItemLayout(2, this.maxHeight);201}202if (!this.maintainScrollTimer.value) {203this.widget.layoutDynamicChatTreeItemMode();204}205}206207render(parent: HTMLElement): void {208if (this.widget) {209// NOTE: if this changes, we need to make sure disposables in this function are tracked differently.210throw new Error('Cannot render quick chat twice');211}212const scopedInstantiationService = this._register(this.instantiationService.createChild(213new ServiceCollection([214IContextKeyService,215this._register(this.contextKeyService.createScoped(parent))216])217));218this.widget = this._register(219scopedInstantiationService.createInstance(220ChatWidget,221ChatAgentLocation.Panel,222{ isQuickChat: true },223{ autoScroll: true, renderInputOnTop: true, renderStyle: 'compact', menus: { inputSideToolbar: MenuId.ChatInputSide, telemetrySource: 'chatQuick' }, enableImplicitContext: true },224{225listForeground: quickInputForeground,226listBackground: quickInputBackground,227overlayBackground: EDITOR_DRAG_AND_DROP_BACKGROUND,228inputEditorBackground: inputBackground,229resultEditorBackground: editorBackground230}));231this.widget.render(parent);232this.widget.setVisible(true);233this.widget.setDynamicChatTreeItemLayout(2, this.maxHeight);234this.updateModel();235this.sash = this._register(new Sash(parent, { getHorizontalSashTop: () => parent.offsetHeight }, { orientation: Orientation.HORIZONTAL }));236this.registerListeners(parent);237}238239private get maxHeight(): number {240return this.layoutService.mainContainerDimension.height - QuickChat.DEFAULT_HEIGHT_OFFSET;241}242243private registerListeners(parent: HTMLElement): void {244this._register(this.layoutService.onDidLayoutMainContainer(() => {245if (this.widget.visible) {246this.widget.updateDynamicChatTreeItemLayout(2, this.maxHeight);247} else {248// If the chat is not visible, then we should defer updating the layout249// because it relies on offsetHeight which only works correctly250// when the chat is visible.251this._deferUpdatingDynamicLayout = true;252}253}));254this._register(this.widget.inputEditor.onDidChangeModelContent((e) => {255this._currentQuery = this.widget.inputEditor.getValue();256}));257this._register(this.widget.onDidClear(() => this.clear()));258this._register(this.widget.onDidChangeHeight((e) => this.sash.layout()));259const width = parent.offsetWidth;260this._register(this.sash.onDidStart(() => {261this.widget.isDynamicChatTreeItemLayoutEnabled = false;262}));263this._register(this.sash.onDidChange((e) => {264if (e.currentY < QuickChat.DEFAULT_MIN_HEIGHT || e.currentY > this.maxHeight) {265return;266}267this.widget.layout(e.currentY, width);268this.sash.layout();269}));270this._register(this.sash.onDidReset(() => {271this.widget.isDynamicChatTreeItemLayoutEnabled = true;272this.widget.layoutDynamicChatTreeItemMode();273}));274}275276async acceptInput() {277return this.widget.acceptInput();278}279280async openChatView(): Promise<void> {281const widget = await showChatView(this.viewsService);282if (!widget?.viewModel || !this.model) {283return;284}285286for (const request of this.model.getRequests()) {287if (request.response?.response.value || request.response?.result) {288289290const message: IChatProgress[] = [];291for (const item of request.response.response.value) {292if (item.kind === 'textEditGroup') {293for (const group of item.edits) {294message.push({295kind: 'textEdit',296edits: group,297uri: item.uri298});299}300} else if (item.kind === 'notebookEditGroup') {301for (const group of item.edits) {302if (isCellTextEditOperation(group)) {303message.push({304kind: 'textEdit',305edits: [group.edit],306uri: group.uri307});308} else {309message.push({310kind: 'notebookEdit',311edits: [group],312uri: item.uri313});314}315}316} else {317message.push(item);318}319}320321this.chatService.addCompleteRequest(widget.viewModel.sessionId,322request.message as IParsedChatRequest,323request.variableData,324request.attempt,325{326message,327result: request.response.result,328followups: request.response.followups329});330} else if (request.message) {331332}333}334335const value = this.widget.inputEditor.getValue();336if (value) {337widget.inputEditor.setValue(value);338}339widget.focusInput();340}341342setValue(value: string, selection?: Selection): void {343this.widget.inputEditor.setValue(value);344this.focus(selection);345}346347clearValue(): void {348this.widget.inputEditor.setValue('');349}350351private updateModel(): void {352this.model ??= this.chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None);353if (!this.model) {354throw new Error('Could not start chat session');355}356357this.widget.setModel(this.model, { inputValue: this._currentQuery });358}359}360361362