Path: blob/main/src/vs/workbench/contrib/chat/browser/widgetHosts/chatQuick.ts
4780 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 { Emitter, Event } from '../../../../../base/common/event.js';9import { MarkdownString } from '../../../../../base/common/htmlContent.js';10import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';11import { autorun } from '../../../../../base/common/observable.js';12import { URI } from '../../../../../base/common/uri.js';13import { Selection } from '../../../../../editor/common/core/selection.js';14import { localize } from '../../../../../nls.js';15import { MenuId } from '../../../../../platform/actions/common/actions.js';16import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';17import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';18import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';19import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';20import product from '../../../../../platform/product/common/product.js';21import { IQuickInputService, IQuickWidget } from '../../../../../platform/quickinput/common/quickInput.js';22import { editorBackground, inputBackground, quickInputBackground, quickInputForeground } from '../../../../../platform/theme/common/colorRegistry.js';23import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../../common/theme.js';24import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js';25import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js';26import { isCellTextEditOperationArray } from '../../common/model/chatModel.js';27import { ChatMode } from '../../common/chatModes.js';28import { IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js';29import { IChatModelReference, IChatProgress, IChatService } from '../../common/chatService/chatService.js';30import { ChatAgentLocation } from '../../common/constants.js';31import { IChatWidgetService, IQuickChatOpenOptions, IQuickChatService } from '../chat.js';32import { ChatWidget } from '../widget/chatWidget.js';3334export class QuickChatService extends Disposable implements IQuickChatService {35readonly _serviceBrand: undefined;3637private readonly _onDidClose = this._register(new Emitter<void>());38get onDidClose() { return this._onDidClose.event; }3940private _input: IQuickWidget | undefined;41// TODO@TylerLeonhardt: support multiple chat providers eventually42private _currentChat: QuickChat | undefined;43private _container: HTMLElement | undefined;4445constructor(46@IQuickInputService private readonly quickInputService: IQuickInputService,47@IChatService private readonly chatService: IChatService,48@IInstantiationService private readonly instantiationService: IInstantiationService,49) {50super();51}5253get enabled(): boolean {54return !!this.chatService.isEnabled(ChatAgentLocation.Chat);55}5657get focused(): boolean {58const widget = this._input?.widget as HTMLElement | undefined;59if (!widget) {60return false;61}62return dom.isAncestorOfActiveElement(widget);63}6465get sessionResource(): URI | undefined {66return this._input && this._currentChat?.sessionResource;67}6869toggle(options?: IQuickChatOpenOptions): void {70// If the input is already shown, hide it. This provides a toggle behavior of the quick71// pick. This should not happen when there is a query.72if (this.focused && !options?.query) {73this.close();74} else {75this.open(options);76// If this is a partial query, the value should be cleared when closed as otherwise it77// would remain for the next time the quick chat is opened in any context.78if (options?.isPartialQuery) {79const disposable = this._store.add(Event.once(this.onDidClose)(() => {80this._currentChat?.clearValue();81this._store.delete(disposable);82}));83}84}85}8687open(options?: IQuickChatOpenOptions): void {88if (this._input) {89if (this._currentChat && options?.query) {90this._currentChat.focus();91this._currentChat.setValue(options.query, options.selection);92if (!options.isPartialQuery) {93this._currentChat.acceptInput();94}95return;96}97return this.focus();98}99100const disposableStore = new DisposableStore();101102this._input = this.quickInputService.createQuickWidget();103this._input.contextKey = 'chatInputVisible';104this._input.ignoreFocusOut = true;105disposableStore.add(this._input);106107this._container ??= dom.$('.interactive-session');108this._input.widget = this._container;109110this._input.show();111if (!this._currentChat) {112this._currentChat = this.instantiationService.createInstance(QuickChat);113114// show needs to come after the quickpick is shown115this._currentChat.render(this._container);116} else {117this._currentChat.show();118}119120disposableStore.add(this._input.onDidHide(() => {121disposableStore.dispose();122this._currentChat!.hide();123this._input = undefined;124this._onDidClose.fire();125}));126127this._currentChat.focus();128129if (options?.query) {130this._currentChat.setValue(options.query, options.selection);131if (!options.isPartialQuery) {132this._currentChat.acceptInput();133}134}135}136focus(): void {137this._currentChat?.focus();138}139close(): void {140this._input?.dispose();141this._input = undefined;142}143async openInChatView(): Promise<void> {144await this._currentChat?.openChatView();145this.close();146}147}148149class QuickChat extends Disposable {150// TODO@TylerLeonhardt: be responsive to window size151static DEFAULT_MIN_HEIGHT = 200;152private static readonly DEFAULT_HEIGHT_OFFSET = 100;153154private widget!: ChatWidget;155private sash!: Sash;156private modelRef: IChatModelReference | undefined;157private readonly maintainScrollTimer: MutableDisposable<IDisposable> = this._register(new MutableDisposable<IDisposable>());158private _deferUpdatingDynamicLayout: boolean = false;159160public get sessionResource() {161return this.modelRef?.object.sessionResource;162}163164constructor(165@IInstantiationService private readonly instantiationService: IInstantiationService,166@IContextKeyService private readonly contextKeyService: IContextKeyService,167@IChatService private readonly chatService: IChatService,168@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,169@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,170@IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService,171@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,172) {173super();174}175176private clear() {177this.modelRef?.dispose();178this.modelRef = undefined;179this.updateModel();180this.widget.inputEditor.setValue('');181return Promise.resolve();182}183184focus(selection?: Selection): void {185if (this.widget) {186this.widget.focusInput();187const value = this.widget.inputEditor.getValue();188if (value) {189this.widget.inputEditor.setSelection(selection ?? {190startLineNumber: 1,191startColumn: 1,192endLineNumber: 1,193endColumn: value.length + 1194});195}196}197}198199hide(): void {200this.widget.setVisible(false);201// Maintain scroll position for a short time so that if the user re-shows the chat202// the same scroll position will be used.203this.maintainScrollTimer.value = disposableTimeout(() => {204// At this point, clear this mutable disposable which will be our signal that205// the timer has expired and we should stop maintaining scroll position206this.maintainScrollTimer.clear();207}, 30 * 1000); // 30 seconds208}209210show(): void {211this.widget.setVisible(true);212// If the mutable disposable is set, then we are keeping the existing scroll position213// so we should not update the layout.214if (this._deferUpdatingDynamicLayout) {215this._deferUpdatingDynamicLayout = false;216this.widget.updateDynamicChatTreeItemLayout(2, this.maxHeight);217}218if (!this.maintainScrollTimer.value) {219this.widget.layoutDynamicChatTreeItemMode();220}221}222223render(parent: HTMLElement): void {224if (this.widget) {225// NOTE: if this changes, we need to make sure disposables in this function are tracked differently.226throw new Error('Cannot render quick chat twice');227}228const scopedInstantiationService = this._register(this.instantiationService.createChild(229new ServiceCollection([230IContextKeyService,231this._register(this.contextKeyService.createScoped(parent))232])233));234this.widget = this._register(235scopedInstantiationService.createInstance(236ChatWidget,237ChatAgentLocation.Chat,238{ isQuickChat: true },239{240autoScroll: true,241renderInputOnTop: true,242renderStyle: 'compact',243menus: { inputSideToolbar: MenuId.ChatInputSide, telemetrySource: 'chatQuick' },244enableImplicitContext: true,245defaultMode: ChatMode.Ask,246clear: () => this.clear(),247},248{249listForeground: quickInputForeground,250listBackground: quickInputBackground,251overlayBackground: EDITOR_DRAG_AND_DROP_BACKGROUND,252inputEditorBackground: inputBackground,253resultEditorBackground: editorBackground254}));255this.widget.render(parent);256this.widget.setVisible(true);257this.widget.setDynamicChatTreeItemLayout(2, this.maxHeight);258this.updateModel();259this.sash = this._register(new Sash(parent, { getHorizontalSashTop: () => parent.offsetHeight }, { orientation: Orientation.HORIZONTAL }));260this.setupDisclaimer(parent);261this.registerListeners(parent);262}263264private setupDisclaimer(parent: HTMLElement): void {265const disclaimerElement = dom.append(parent, dom.$('.disclaimer.hidden'));266const disposables = this._store.add(new DisposableStore());267268this._register(autorun(reader => {269disposables.clear();270dom.reset(disclaimerElement);271272const sentiment = this.chatEntitlementService.sentimentObs.read(reader);273const anonymous = this.chatEntitlementService.anonymousObs.read(reader);274const requestInProgress = this.chatService.requestInProgressObs.read(reader);275276const showDisclaimer = !sentiment.installed && anonymous && !requestInProgress;277disclaimerElement.classList.toggle('hidden', !showDisclaimer);278279if (showDisclaimer) {280const renderedMarkdown = disposables.add(this.markdownRendererService.render(new MarkdownString(localize({ key: 'termsDisclaimer', comment: ['{Locked="]({2})"}', '{Locked="]({3})"}'] }, "By continuing with {0} Copilot, you agree to {1}'s [Terms]({2}) and [Privacy Statement]({3})", product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.termsStatementUrl ?? '', product.defaultChatAgent?.privacyStatementUrl ?? ''), { isTrusted: true })));281disclaimerElement.appendChild(renderedMarkdown.element);282}283}));284}285286private get maxHeight(): number {287return this.layoutService.mainContainerDimension.height - QuickChat.DEFAULT_HEIGHT_OFFSET;288}289290private registerListeners(parent: HTMLElement): void {291this._register(this.layoutService.onDidLayoutMainContainer(() => {292if (this.widget.visible) {293this.widget.updateDynamicChatTreeItemLayout(2, this.maxHeight);294} else {295// If the chat is not visible, then we should defer updating the layout296// because it relies on offsetHeight which only works correctly297// when the chat is visible.298this._deferUpdatingDynamicLayout = true;299}300}));301this._register(this.widget.onDidChangeHeight((e) => this.sash.layout()));302const width = parent.offsetWidth;303this._register(this.sash.onDidStart(() => {304this.widget.isDynamicChatTreeItemLayoutEnabled = false;305}));306this._register(this.sash.onDidChange((e) => {307if (e.currentY < QuickChat.DEFAULT_MIN_HEIGHT || e.currentY > this.maxHeight) {308return;309}310this.widget.layout(e.currentY, width);311this.sash.layout();312}));313this._register(this.sash.onDidReset(() => {314this.widget.isDynamicChatTreeItemLayoutEnabled = true;315this.widget.layoutDynamicChatTreeItemMode();316}));317}318319async acceptInput() {320return this.widget.acceptInput();321}322323async openChatView(): Promise<void> {324const widget = await this.chatWidgetService.revealWidget();325const model = this.modelRef?.object;326if (!widget?.viewModel || !model) {327return;328}329330for (const request of model.getRequests()) {331if (request.response?.response.value || request.response?.result) {332333334const message: IChatProgress[] = [];335for (const item of request.response.response.value) {336if (item.kind === 'textEditGroup') {337for (const group of item.edits) {338message.push({339kind: 'textEdit',340edits: group,341uri: item.uri342});343}344} else if (item.kind === 'notebookEditGroup') {345for (const group of item.edits) {346if (isCellTextEditOperationArray(group)) {347message.push({348kind: 'textEdit',349edits: group.map(e => e.edit),350uri: group[0].uri351});352} else {353message.push({354kind: 'notebookEdit',355edits: group,356uri: item.uri357});358}359}360} else {361message.push(item);362}363}364365this.chatService.addCompleteRequest(widget.viewModel.sessionResource,366request.message as IParsedChatRequest,367request.variableData,368request.attempt,369{370message,371result: request.response.result,372followups: request.response.followups373});374} else if (request.message) {375376}377}378379const value = this.widget.getViewState();380if (value) {381widget.viewModel.model.inputModel.setState(value);382}383widget.focusInput();384}385386setValue(value: string, selection?: Selection): void {387this.widget.inputEditor.setValue(value);388this.focus(selection);389}390391clearValue(): void {392this.widget.inputEditor.setValue('');393}394395private updateModel(): void {396this.modelRef ??= this.chatService.startSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true });397const model = this.modelRef?.object;398if (!model) {399throw new Error('Could not start chat session');400}401402this.modelRef.object.inputModel.setState({ inputText: '', selections: [] });403this.widget.setModel(model);404}405406override dispose(): void {407this.modelRef?.dispose();408this.modelRef = undefined;409super.dispose();410}411}412413414