From 547358a81c01ba3524bc07b323bedbbb51468bc4 Mon Sep 17 00:00:00 2001 From: meganrogge Date: Wed, 15 Apr 2026 19:24:22 -0400 Subject: [PATCH 1/2] get it mostly working --- .../attachments/chatAttachmentWidgets.ts | 132 +++++++++++++++++- .../contrib/chat/browser/chat.contribution.ts | 3 + .../chat/browser/chatBackgroundTaskService.ts | 121 ++++++++++++++++ .../contrib/chat/browser/widget/chatWidget.ts | 16 +++ .../browser/widget/input/chatInputPart.ts | 70 +++++++++- .../common/attachments/chatVariableEntries.ts | 13 +- .../contrib/chat/common/chatBackgroundTask.ts | 71 ++++++++++ .../browser/tools/runInTerminalTool.ts | 60 ++++++-- 8 files changed, 465 insertions(+), 21 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chatBackgroundTaskService.ts create mode 100644 src/vs/workbench/contrib/chat/common/chatBackgroundTask.ts diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index a8db686ac2316..52a60643222cb 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -19,6 +19,7 @@ import { Iterable } from '../../../../../base/common/iterator.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; +import { autorun } from '../../../../../base/common/observable.js'; import { basename, dirname } from '../../../../../base/common/path.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -63,7 +64,8 @@ import { ITerminalService } from '../../../terminal/browser/terminal.js'; import { IChatContentReference } from '../../common/chatService/chatService.js'; import { coerceImageBuffer } from '../../common/chatImageExtraction.js'; import { ChatConfiguration } from '../../common/constants.js'; -import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, IPromptTextVariableEntry, ISCMHistoryItemVariableEntry, MAX_IMAGES_PER_REQUEST, OmittedState, PromptFileVariableKind, ChatRequestToolReferenceEntry, ISCMHistoryItemChangeVariableEntry, ISCMHistoryItemChangeRangeVariableEntry, ITerminalVariableEntry, isStringVariableEntry } from '../../common/attachments/chatVariableEntries.js'; +import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, IPromptTextVariableEntry, ISCMHistoryItemVariableEntry, MAX_IMAGES_PER_REQUEST, OmittedState, PromptFileVariableKind, ChatRequestToolReferenceEntry, ISCMHistoryItemChangeVariableEntry, ISCMHistoryItemChangeRangeVariableEntry, ITerminalVariableEntry, isStringVariableEntry, IChatBackgroundTaskVariableEntry } from '../../common/attachments/chatVariableEntries.js'; +import { BackgroundTaskStatus, IChatBackgroundTaskService } from '../../common/chatBackgroundTask.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../common/languageModels.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { ILanguageModelToolsService, isToolSet } from '../../common/tools/languageModelToolsService.js'; @@ -97,7 +99,7 @@ abstract class AbstractChatAttachmentWidget extends Disposable { public readonly element: HTMLElement; public readonly label: IResourceLabel; - private readonly _onDidDelete: event.Emitter = this._register(new event.Emitter()); + protected readonly _onDidDelete: event.Emitter = this._register(new event.Emitter()); get onDidDelete(): event.Event { return this._onDidDelete.event; } @@ -1572,3 +1574,129 @@ function addBasicContextMenu(accessor: ServicesAccessor, widget: HTMLElement, sc } export const chatAttachmentResourceContextKey = new RawContextKey('chatAttachmentResource', undefined, { type: 'URI', description: localize('resource', "The full value of the chat attachment resource, including scheme and path") }); + +export class BackgroundTaskAttachmentWidget extends AbstractChatAttachmentWidget { + constructor( + attachment: IChatBackgroundTaskVariableEntry, + currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, + container: HTMLElement, + contextResourceLabels: ResourceLabels, + @ICommandService commandService: ICommandService, + @IOpenerService openerService: IOpenerService, + @IConfigurationService configurationService: IConfigurationService, + @IHoverService private readonly hoverService: IHoverService, + @IChatBackgroundTaskService private readonly chatBackgroundTaskService: IChatBackgroundTaskService, + ) { + super(attachment, options, container, contextResourceLabels, currentLanguageModel, commandService, openerService, configurationService); + + const sourceLabel = attachment.source.kind === 'terminal' + ? localize('bgTask.terminal', "terminal") + : attachment.source.serverLabel; + + const task = this.chatBackgroundTaskService.getTask(attachment.taskId); + if (task) { + // Reactively update the chip appearance when the task status changes + this._register(autorun(reader => { + const status = task.status.read(reader); + switch (status) { + case BackgroundTaskStatus.Working: + this.label.setLabel(`$(${ThemeIcon.modify(Codicon.loading, 'spin').id})\u00A0${attachment.name}`, undefined); + this.element.classList.add('shimmer-progress'); + this.element.ariaLabel = this.appendDeletionHint(localize('bgTask.ariaLabel.working', "Background task, {0}, running", attachment.name)); + break; + case BackgroundTaskStatus.Completed: + this.label.setLabel(`$(${Codicon.check.id})\u00A0${attachment.name}`, undefined); + this.element.classList.remove('shimmer-progress'); + this.element.ariaLabel = this.appendDeletionHint(localize('bgTask.ariaLabel.completed', "Background task, {0}, completed", attachment.name)); + break; + case BackgroundTaskStatus.Failed: + this.label.setLabel(`$(${Codicon.error.id})\u00A0${attachment.name}`, undefined); + this.element.classList.remove('shimmer-progress'); + this.element.ariaLabel = this.appendDeletionHint(localize('bgTask.ariaLabel.failed', "Background task, {0}, failed", attachment.name)); + break; + case BackgroundTaskStatus.Cancelled: + this.label.setLabel(`$(${Codicon.close.id})\u00A0${attachment.name}`, undefined); + this.element.classList.remove('shimmer-progress'); + this.element.ariaLabel = this.appendDeletionHint(localize('bgTask.ariaLabel.cancelled', "Background task, {0}, cancelled", attachment.name)); + break; + } + })); + } else { + // Fallback if the task is not found (e.g. already evicted) + this.label.setLabel(`$(${Codicon.check.id})\u00A0${attachment.name}`, undefined); + this.element.ariaLabel = this.appendDeletionHint(localize('bgTask.ariaLabel.completed', "Background task, {0}, completed", attachment.name)); + } + + this._register(this.hoverService.setupDelayedHover(this.element, () => { + const currentTask = this.chatBackgroundTaskService.getTask(attachment.taskId); + const status = currentTask?.status.get(); + switch (status) { + case BackgroundTaskStatus.Completed: + return { ...commonHoverOptions, content: localize('bgTask.hover.completed', "Background task '{0}' completed ({1})", attachment.name, sourceLabel) }; + case BackgroundTaskStatus.Failed: { + const msg = currentTask?.statusMessage.get(); + return { + ...commonHoverOptions, content: msg + ? localize('bgTask.hover.failedMsg', "Background task '{0}' failed: {1}", attachment.name, msg) + : localize('bgTask.hover.failed', "Background task '{0}' failed ({1})", attachment.name, sourceLabel) + }; + } + case BackgroundTaskStatus.Cancelled: + return { ...commonHoverOptions, content: localize('bgTask.hover.cancelled', "Background task '{0}' was cancelled", attachment.name) }; + default: + return { ...commonHoverOptions, content: localize('bgTask.hover.working', "Background task '{0}' is running ({1})", attachment.name, sourceLabel) }; + } + }, commonHoverLifecycleOptions)); + + // Set up the clear button here (after super() has returned and DI services are available) + // rather than in attachClearButton() which is called during super() before services are injected. + this._setupClearButton(); + } + + /** No-op: the base class calls this during super(), before DI services are available. */ + protected override attachClearButton(): void { } + + private _setupClearButton(): void { + const task = this.chatBackgroundTaskService.getTask((this.attachment as IChatBackgroundTaskVariableEntry).taskId); + + const clearButton = new Button(this.element, { + supportIcons: true, + hoverDelegate: createInstantHoverDelegate(), + title: task?.status.get() === BackgroundTaskStatus.Working + ? localize('bgTask.clearButton.cancel', "Cancel") + : localize('bgTask.clearButton.dismiss', "Dismiss"), + }); + clearButton.element.tabIndex = -1; + clearButton.icon = Codicon.close; + this._register(clearButton); + + if (task) { + // Reactively update the button title based on task status + this._register(autorun(reader => { + const status = task.status.read(reader); + clearButton.element.title = status === BackgroundTaskStatus.Working + ? localize('bgTask.clearButton.cancel', "Cancel") + : localize('bgTask.clearButton.dismiss', "Dismiss"); + })); + } + + this._register(event.Event.once(clearButton.onDidClick)((e) => { + // Only cancel running tasks (kills the terminal); completed tasks just get dismissed + if (task && task.status.get() === BackgroundTaskStatus.Working) { + task.cancel(); + } + this._onDidDelete.fire(e); + })); + this._register(dom.addStandardDisposableListener(this.element, dom.EventType.KEY_DOWN, e => { + if (e.keyCode === KeyCode.Backspace || e.keyCode === KeyCode.Delete) { + e.preventDefault(); + e.stopPropagation(); + if (task && task.status.get() === BackgroundTaskStatus.Working) { + task.cancel(); + } + this._onDidDelete.fire(e.browserEvent); + } + })); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f374ed43467d0..33200bf40b0ae 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -58,6 +58,8 @@ import { ILanguageModelsService, LanguageModelsService } from '../common/languag import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; +import { IChatBackgroundTaskService } from '../common/chatBackgroundTask.js'; +import { ChatBackgroundTaskServiceImpl } from './chatBackgroundTaskService.js'; import { agentPluginDiscoveryRegistry, IAgentPluginService } from '../common/plugins/agentPluginService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { isTildePath, PromptsConfig } from '../common/promptSyntax/config/config.js'; @@ -2168,6 +2170,7 @@ registerSingleton(IAgentPluginRepositoryService, AgentPluginRepositoryService, I registerSingleton(IPluginGitService, BrowserPluginGitCommandService, InstantiationType.Delayed); registerSingleton(IPluginInstallService, PluginInstallService, InstantiationType.Delayed); registerSingleton(ILanguageModelToolsService, LanguageModelToolsService, InstantiationType.Delayed); +registerSingleton(IChatBackgroundTaskService, ChatBackgroundTaskServiceImpl, InstantiationType.Delayed); registerSingleton(ILanguageModelToolsConfirmationService, LanguageModelToolsConfirmationService, InstantiationType.Delayed); registerSingleton(IVoiceChatService, VoiceChatService, InstantiationType.Delayed); registerSingleton(IChatCodeBlockContextProviderService, ChatCodeBlockContextProviderService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatBackgroundTaskService.ts b/src/vs/workbench/contrib/chat/browser/chatBackgroundTaskService.ts new file mode 100644 index 0000000000000..a5a48f940f3eb --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatBackgroundTaskService.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { IObservable, ISettableObservable, observableFromEventOpts, observableValue } from '../../../../base/common/observable.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { BackgroundTaskSource, BackgroundTaskStatus, IChatBackgroundTask, IChatBackgroundTaskHandle, IChatBackgroundTaskService } from '../common/chatBackgroundTask.js'; + +class ChatBackgroundTask extends Disposable implements IChatBackgroundTaskHandle { + private readonly _status: ISettableObservable; + private readonly _statusMessage: ISettableObservable; + private readonly _result: ISettableObservable; + + get status(): IObservable { return this._status; } + get statusMessage(): IObservable { return this._statusMessage; } + get result(): IObservable { return this._result; } + + constructor( + readonly taskId: string, + readonly name: string, + readonly source: BackgroundTaskSource, + private readonly _cancel: () => void, + ) { + super(); + this._status = observableValue('bgTaskStatus', BackgroundTaskStatus.Working); + this._statusMessage = observableValue('bgTaskMessage', undefined); + this._result = observableValue('bgTaskResult', undefined); + } + + cancel(): void { + if (this._status.get() !== BackgroundTaskStatus.Working) { + return; + } + this._status.set(BackgroundTaskStatus.Cancelled, undefined); + this._cancel(); + } + + complete(result: unknown): void { + this._result.set(result, undefined); + this._status.set(BackgroundTaskStatus.Completed, undefined); + } + + fail(message?: string): void { + this._statusMessage.set(message, undefined); + this._status.set(BackgroundTaskStatus.Failed, undefined); + } + + updateStatusMessage(message: string): void { + this._statusMessage.set(message, undefined); + } +} + +export class ChatBackgroundTaskServiceImpl extends Disposable implements IChatBackgroundTaskService { + declare readonly _serviceBrand: undefined; + + private readonly _tasksBySession = this._register(new DisposableMap>()); + private readonly _onDidChangeTasks = this._register(new Emitter()); + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + createTask(sessionResource: URI, options: { + name: string; + source: BackgroundTaskSource; + onCancel?: () => void; + }): IChatBackgroundTaskHandle { + const taskId = generateUuid(); + const task = new ChatBackgroundTask(taskId, options.name, options.source, () => { + options.onCancel?.(); + }); + + this._trackTask(sessionResource, task); + this._logService.debug(`[ChatBackgroundTaskService] Created task ${taskId} for session ${sessionResource.toString()}`); + return task; + } + + getTasksForSession(sessionResource: URI): IObservable { + const key = sessionResource.toString(); + return observableFromEventOpts({ equalsFn: () => false }, this._onDidChangeTasks.event, () => { + const map = this._tasksBySession.get(key); + return map ? [...map.values()] : []; + }); + } + + getTask(taskId: string): IChatBackgroundTask | undefined { + for (const map of this._tasksBySession.values()) { + const task = map.get(taskId); + if (task) { + return task; + } + } + return undefined; + } + + evictTask(taskId: string): void { + for (const [, map] of this._tasksBySession) { + if (map.has(taskId)) { + map.deleteAndDispose(taskId); + this._onDidChangeTasks.fire(); + return; + } + } + } + + private _trackTask(sessionResource: URI, task: ChatBackgroundTask): void { + const key = sessionResource.toString(); + if (!this._tasksBySession.has(key)) { + this._tasksBySession.set(key, new DisposableMap()); + } + this._tasksBySession.get(key)!.set(task.taskId, task); + this._onDidChangeTasks.fire(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 0c1fea3afa6d7..d16a38317c575 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -83,6 +83,7 @@ import { IChatTipService } from '../chatTipService.js'; import { ChatTipContentPart } from './chatContentParts/chatTipContentPart.js'; import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; +import { BackgroundTaskStatus, IChatBackgroundTaskService } from '../../common/chatBackgroundTask.js'; import { IChatDebugService } from '../../common/chatDebugService.js'; const $ = dom.$; @@ -413,6 +414,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService, @IChatTipService private readonly chatTipService: IChatTipService, @IChatDebugService private readonly chatDebugService: IChatDebugService, + @IChatBackgroundTaskService private readonly chatBackgroundTaskService: IChatBackgroundTaskService, ) { super(); @@ -2004,6 +2006,10 @@ export class ChatWidget extends Disposable implements IChatWidget { this.listWidget.setViewModel(this.viewModel); + // Re-render attached context now that the session resource is available + // so the background task autorun is set up. + this.input.renderAttachedContext(); + if (this._lockedAgent) { let placeholder = this.chatSessionsService.getChatSessionContribution(this._lockedAgent.id)?.inputPlaceholder; if (!placeholder) { @@ -2465,6 +2471,16 @@ export class ChatWidget extends Disposable implements IChatWidget { this.updateChatViewVisibility(); this.input.acceptInput(options?.storeToHistory ?? isUserQuery); + // Evict completed background tasks so their context is included in this request + if (submittedSessionResource) { + const tasks = this.chatBackgroundTaskService.getTasksForSession(submittedSessionResource).get(); + for (const task of tasks) { + if (task.status.get() === BackgroundTaskStatus.Completed || task.status.get() === BackgroundTaskStatus.Failed || task.status.get() === BackgroundTaskStatus.Cancelled) { + this.chatBackgroundTaskService.evictTask(task.taskId); + } + } + } + const sent = ChatSendResult.isQueued(result) ? await result.deferred : result; if (!ChatSendResult.isSent(sent)) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index a5f66faa11455..8edc2afd9d5b2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -82,7 +82,7 @@ import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEd import { InlineChatConfigKeys } from '../../../../inlineChat/common/inlineChat.js'; import { IChatViewTitleActionContext } from '../../../common/actions/chatActions.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; -import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry, MAX_IMAGES_PER_REQUEST, OmittedState } from '../../../common/attachments/chatVariableEntries.js'; +import { ChatRequestVariableSet, IChatBackgroundTaskVariableEntry, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry, MAX_IMAGES_PER_REQUEST, OmittedState } from '../../../common/attachments/chatVariableEntries.js'; import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatQuestionCarousel, IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType } from '../../../common/chatSessionsService.js'; @@ -101,7 +101,8 @@ import { AgentSessionProviders, getAgentSessionProvider } from '../../agentSessi import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; import { IChatAttachmentWidgetRegistry } from '../../attachments/chatAttachmentWidgetRegistry.js'; -import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; +import { BackgroundTaskAttachmentWidget, DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; +import { BackgroundTaskStatus, IChatBackgroundTaskService } from '../../../common/chatBackgroundTask.js'; import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; import { IChatWidget, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; @@ -265,6 +266,34 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge public getAttachedContext() { const contextArr = new ChatRequestVariableSet(); contextArr.add(...this.attachmentModel.attachments, ...this.chatContextService.getWorkspaceContextItems()); + + // Include completed/failed background tasks as attachments + const sessionResource = this._widget?.viewModel?.model.sessionResource; + if (sessionResource) { + const tasks = this._chatBackgroundTaskService.getTasksForSession(sessionResource).get(); + for (const task of tasks) { + const status = task.status.get(); + if (status === BackgroundTaskStatus.Completed || status === BackgroundTaskStatus.Failed) { + const output = typeof task.result.get() === 'string' ? task.result.get() as string : ''; + const failMessage = task.statusMessage.get(); + const modelDesc = status === BackgroundTaskStatus.Failed + ? `Background task '${task.name}' failed${failMessage ? ': ' + failMessage : ''}` + : `Background task '${task.name}' completed successfully`; + contextArr.add({ + kind: 'backgroundTask', + id: `backgroundTask:${task.taskId}`, + name: task.name, + fullName: localize('bgTask.fullName', "Background Task: {0}", task.name), + icon: status === BackgroundTaskStatus.Completed ? Codicon.check : Codicon.error, + value: output, + modelDescription: modelDesc, + taskId: task.taskId, + source: task.source, + } satisfies IChatBackgroundTaskVariableEntry); + } + } + } + return contextArr; } @@ -550,6 +579,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, @IChatAttachmentWidgetRegistry private readonly _chatAttachmentWidgetRegistry: IChatAttachmentWidgetRegistry, + @IChatBackgroundTaskService private readonly _chatBackgroundTaskService: IChatBackgroundTaskService, ) { super(); @@ -2586,8 +2616,40 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge hasImplicitContext = implicitContextWidget.hasRenderedContexts; } - dom.setVisibility(Boolean(this.options.renderInputToolbarBelowInput || hasAttachments || hasImplicitContext), this.attachmentsContainer); - dom.setVisibility(hasAttachments || hasImplicitContext, this.attachedContextContainer); + // Render background task chips from the background task service + let hasBackgroundTasks = false; + const sessionResource = this._widget?.viewModel?.model.sessionResource; + if (sessionResource) { + const bgTaskStore = new DisposableStore(); + store.add(bgTaskStore); + store.add(autorun(reader => { + const tasks = this._chatBackgroundTaskService.getTasksForSession(sessionResource).read(reader); + bgTaskStore.clear(); + hasBackgroundTasks = tasks.length > 0; + for (const task of tasks) { + const attachment: IChatBackgroundTaskVariableEntry = { + kind: 'backgroundTask', + id: `backgroundTask:${task.taskId}`, + name: task.name, + icon: Codicon.loading, + value: '', + modelDescription: '', + taskId: task.taskId, + source: task.source, + }; + const widget = this.instantiationService.createInstance(BackgroundTaskAttachmentWidget, attachment, this._currentLanguageModel.read(reader), { shouldFocusClearButton: false, supportsDeletion: true }, container, this._contextResourceLabels); + bgTaskStore.add(widget); + bgTaskStore.add(widget.onDidDelete(() => { + this._chatBackgroundTaskService.evictTask(task.taskId); + })); + } + dom.setVisibility(Boolean(this.options.renderInputToolbarBelowInput || hasAttachments || hasImplicitContext || hasBackgroundTasks), this.attachmentsContainer); + dom.setVisibility(hasAttachments || hasImplicitContext || hasBackgroundTasks, this.attachedContextContainer); + })); + } + + dom.setVisibility(Boolean(this.options.renderInputToolbarBelowInput || hasAttachments || hasImplicitContext || hasBackgroundTasks), this.attachmentsContainer); + dom.setVisibility(hasAttachments || hasImplicitContext || hasBackgroundTasks, this.attachedContextContainer); if (!attachments.length) { this._indexOfLastAttachedContextDeletedWithKeyboard = -1; this._indexOfLastOpenedContext = -1; diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index 6ce816b06cdc4..9790efd81289d 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -19,6 +19,7 @@ import { IChatRequestVariableValue } from './chatVariables.js'; import { IToolData, IToolSet } from '../tools/languageModelToolsService.js'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../../../base/common/buffer.js'; import { Mutable } from '../../../../../base/common/types.js'; +import { BackgroundTaskSource } from '../chatBackgroundTask.js'; interface IBaseChatRequestVariableEntry { @@ -332,6 +333,12 @@ export interface IChatRequestSessionReferenceVariableEntry extends IBaseChatRequ readonly value: URI; } +export interface IChatBackgroundTaskVariableEntry extends IBaseChatRequestVariableEntry { + readonly kind: 'backgroundTask'; + readonly taskId: string; + readonly source: BackgroundTaskSource; +} + export type IChatRequestVariableEntry = IGenericChatRequestVariableEntry | IChatRequestImplicitVariableEntry | IChatRequestPasteVariableEntry | ISymbolVariableEntry | ICommandResultVariableEntry | IDiagnosticVariableEntry | IImageVariableEntry | IChatRequestToolEntry | IChatRequestToolSetEntry @@ -339,7 +346,7 @@ export type IChatRequestVariableEntry = IGenericChatRequestVariableEntry | IChat | IPromptFileVariableEntry | IPromptTextVariableEntry | ISCMHistoryItemVariableEntry | ISCMHistoryItemChangeVariableEntry | ISCMHistoryItemChangeRangeVariableEntry | ITerminalVariableEntry | IChatRequestStringVariableEntry | IChatRequestWorkspaceVariableEntry | IDebugVariableEntry | IAgentFeedbackVariableEntry - | IChatRequestDebugEventsVariableEntry | IChatRequestSessionReferenceVariableEntry; + | IChatRequestDebugEventsVariableEntry | IChatRequestSessionReferenceVariableEntry | IChatBackgroundTaskVariableEntry; export namespace IChatRequestVariableEntry { @@ -440,6 +447,10 @@ export function isChatRequestFileEntry(obj: IChatRequestVariableEntry): obj is I return obj.kind === 'file'; } +export function isBackgroundTaskVariableEntry(obj: IChatRequestVariableEntry): obj is IChatBackgroundTaskVariableEntry { + return obj.kind === 'backgroundTask'; +} + export function isPromptFileVariableEntry(obj: IChatRequestVariableEntry): obj is IPromptFileVariableEntry { return obj.kind === 'promptFile'; } diff --git a/src/vs/workbench/contrib/chat/common/chatBackgroundTask.ts b/src/vs/workbench/contrib/chat/common/chatBackgroundTask.ts new file mode 100644 index 0000000000000..8124a7cceeca6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatBackgroundTask.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IChatBackgroundTaskService = createDecorator('chatBackgroundTaskService'); + +export const enum BackgroundTaskStatus { + Working = 'working', + Completed = 'completed', + Failed = 'failed', + Cancelled = 'cancelled', +} + +export type BackgroundTaskSource = + | { readonly kind: 'terminal'; readonly termId: string; readonly commandName: string } + | { readonly kind: 'mcp'; readonly serverId: string; readonly serverLabel: string; readonly toolCallId: string }; + +export interface IChatBackgroundTask { + readonly taskId: string; + readonly name: string; + readonly source: BackgroundTaskSource; + readonly status: IObservable; + readonly statusMessage: IObservable; + readonly result: IObservable; + cancel(): void; +} + +export interface ISerializedBackgroundTask { + readonly taskId: string; + readonly name: string; + readonly source: BackgroundTaskSource; + readonly status: BackgroundTaskStatus; +} + +export interface IChatBackgroundTaskHandle extends IChatBackgroundTask { + complete(result: unknown): void; + fail(message?: string): void; + updateStatusMessage(message: string): void; +} + +export interface IChatBackgroundTaskService { + readonly _serviceBrand: undefined; + + /** + * Create and track a background task. + * Returns a handle that can be used to update the task status imperatively. + */ + createTask(sessionResource: URI, options: { + name: string; + source: BackgroundTaskSource; + onCancel?: () => void; + }): IChatBackgroundTaskHandle; + + /** + * Get all active background tasks for a session. + */ + getTasksForSession(sessionResource: URI): IObservable; + + /** + * Get a specific background task by its ID across all sessions. + */ + getTask(taskId: string): IChatBackgroundTask | undefined; + + /** Remove a task after its result has been consumed by a request. */ + evictTask(taskId: string): void; +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 87d83447017b8..9dbecc575214b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -76,6 +76,7 @@ import { clamp } from '../../../../../../base/common/numbers.js'; import { IOutputAnalyzer } from './outputAnalyzer.js'; import { SandboxOutputAnalyzer, outputLooksSandboxBlocked } from './sandboxOutputAnalyzer.js'; import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js'; +import { BackgroundTaskStatus, IChatBackgroundTaskService } from '../../../../chat/common/chatBackgroundTask.js'; import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, type ITerminalSandboxResolvedNetworkDomains } from '../../common/terminalSandboxService.js'; import { LanguageModelPartAudience } from '../../../../chat/common/languageModels.js'; import { isSessionAutoApproveLevel, isTerminalAutoApproveAllowed, isToolEligibleForTerminalAutoApproval } from './terminalToolAutoApprove.js'; @@ -551,6 +552,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @IChatBackgroundTaskService private readonly _chatBackgroundTaskService: IChatBackgroundTaskService, ) { super(); @@ -2094,6 +2096,18 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return; } + // Create a background task to track this terminal command + const backgroundTask = this._chatBackgroundTaskService.createTask(chatSessionResource, { + name: commandName, + source: { kind: 'terminal', termId, commandName }, + onCancel: () => { + RunInTerminalTool._activeExecutions.get(termId)?.dispose(); + RunInTerminalTool._activeExecutions.delete(termId); + terminalInstance.dispose(); + disposeNotification(); + }, + }); + // Capture model/mode/tools from the last request so the steering message // uses the same settings as the original conversation (not defaults). const lastRequest = sessionRef.object.lastRequest; @@ -2228,26 +2242,44 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { disposeNotification(); const exitCode = command.exitCode; - const exitCodeText = exitCode !== undefined ? ` with exit code ${exitCode}` : ''; const currentOutput = execution.getOutput(); - const message = `[Terminal ${termId} notification: command completed${exitCodeText}. Use send_to_terminal to send another command or kill_terminal to stop it.]\nTerminal output:\n${currentOutput}`; - - this._logService.debug(`RunInTerminalTool: Command completed in background terminal ${termId}, notifying chat session`); - - this._chatService.sendRequest(chatSessionResource, message, { - ...sendOptions, - queue: ChatRequestQueueKind.Steering, - isSystemInitiated: true, - systemInitiatedLabel: localize('terminalCommandCompleted', "`{0}` completed", commandName), - terminalExecutionId: termId, - }).catch(e => { - this._logService.warn(`RunInTerminalTool: Failed to send completion notification for terminal ${termId}`, e); - }); + + this._logService.debug(`RunInTerminalTool: Command completed in background terminal ${termId}, updating background task`); + + // Update the background task with the result instead of sending a steering message + if (exitCode !== undefined && exitCode !== 0) { + backgroundTask.fail(`Exited with code ${exitCode}\n${currentOutput ?? ''}`); + } else { + backgroundTask.complete(currentOutput ?? ''); + } })); + // Handle the case where the command already completed before the listener + // was registered (e.g. fast commands like `ls`). + const execution = RunInTerminalTool._activeExecutions.get(termId); + if (execution) { + execution.completionPromise.then(result => { + // Only act if the background task is still in working state + // (the onCommandFinished listener may have already handled it) + if (backgroundTask.status.get() === BackgroundTaskStatus.Working) { + const currentOutput = execution.getOutput(); + this._logService.debug(`RunInTerminalTool: Command already completed in background terminal ${termId}, updating background task via completionPromise`); + if (result.exitCode !== undefined && result.exitCode !== 0) { + backgroundTask.fail(`Exited with code ${result.exitCode}\n${currentOutput ?? ''}`); + } else { + backgroundTask.complete(currentOutput ?? ''); + } + disposeNotification(); + } + }).catch(() => { + // Execution errored - already handled elsewhere + }); + } + // Clean up all background resources when the terminal is disposed // (e.g. user closes the terminal) to avoid leaking listeners and monitors. store.add(terminalInstance.onDisposed(() => { + backgroundTask.fail(localize('terminalDisposed', "Terminal was closed")); disposeNotification(); })); From a99709008db35bbd5e0f509dd709a6fc19b2ac8d Mon Sep 17 00:00:00 2001 From: meganrogge Date: Wed, 15 Apr 2026 19:29:25 -0400 Subject: [PATCH 2/2] fix issues --- .../chat/browser/chatBackgroundTaskService.ts | 15 +++++++++++++++ .../chat/browser/widget/input/chatInputPart.ts | 9 ++++----- .../contrib/chat/common/chatBackgroundTask.ts | 7 ------- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatBackgroundTaskService.ts b/src/vs/workbench/contrib/chat/browser/chatBackgroundTaskService.ts index a5a48f940f3eb..e426fcc6a2e67 100644 --- a/src/vs/workbench/contrib/chat/browser/chatBackgroundTaskService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatBackgroundTaskService.ts @@ -10,6 +10,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { BackgroundTaskSource, BackgroundTaskStatus, IChatBackgroundTask, IChatBackgroundTaskHandle, IChatBackgroundTaskService } from '../common/chatBackgroundTask.js'; +import { IChatService } from '../common/chatService/chatService.js'; class ChatBackgroundTask extends Disposable implements IChatBackgroundTaskHandle { private readonly _status: ISettableObservable; @@ -41,11 +42,17 @@ class ChatBackgroundTask extends Disposable implements IChatBackgroundTaskHandle } complete(result: unknown): void { + if (this._status.get() !== BackgroundTaskStatus.Working) { + return; + } this._result.set(result, undefined); this._status.set(BackgroundTaskStatus.Completed, undefined); } fail(message?: string): void { + if (this._status.get() !== BackgroundTaskStatus.Working) { + return; + } this._statusMessage.set(message, undefined); this._status.set(BackgroundTaskStatus.Failed, undefined); } @@ -63,8 +70,16 @@ export class ChatBackgroundTaskServiceImpl extends Disposable implements IChatBa constructor( @ILogService private readonly _logService: ILogService, + @IChatService private readonly _chatService: IChatService, ) { super(); + + this._register(this._chatService.onDidDisposeSession(e => { + for (const sessionResource of e.sessionResources) { + this._tasksBySession.deleteAndDispose(sessionResource.toString()); + } + this._onDidChangeTasks.fire(); + })); } createTask(sessionResource: URI, options: { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 8edc2afd9d5b2..d47fa763d6fc2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2617,7 +2617,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } // Render background task chips from the background task service - let hasBackgroundTasks = false; const sessionResource = this._widget?.viewModel?.model.sessionResource; if (sessionResource) { const bgTaskStore = new DisposableStore(); @@ -2625,7 +2624,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge store.add(autorun(reader => { const tasks = this._chatBackgroundTaskService.getTasksForSession(sessionResource).read(reader); bgTaskStore.clear(); - hasBackgroundTasks = tasks.length > 0; for (const task of tasks) { const attachment: IChatBackgroundTaskVariableEntry = { kind: 'backgroundTask', @@ -2643,13 +2641,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._chatBackgroundTaskService.evictTask(task.taskId); })); } + const hasBackgroundTasks = tasks.length > 0; dom.setVisibility(Boolean(this.options.renderInputToolbarBelowInput || hasAttachments || hasImplicitContext || hasBackgroundTasks), this.attachmentsContainer); dom.setVisibility(hasAttachments || hasImplicitContext || hasBackgroundTasks, this.attachedContextContainer); })); + } else { + dom.setVisibility(Boolean(this.options.renderInputToolbarBelowInput || hasAttachments || hasImplicitContext), this.attachmentsContainer); + dom.setVisibility(hasAttachments || hasImplicitContext, this.attachedContextContainer); } - - dom.setVisibility(Boolean(this.options.renderInputToolbarBelowInput || hasAttachments || hasImplicitContext || hasBackgroundTasks), this.attachmentsContainer); - dom.setVisibility(hasAttachments || hasImplicitContext || hasBackgroundTasks, this.attachedContextContainer); if (!attachments.length) { this._indexOfLastAttachedContextDeletedWithKeyboard = -1; this._indexOfLastOpenedContext = -1; diff --git a/src/vs/workbench/contrib/chat/common/chatBackgroundTask.ts b/src/vs/workbench/contrib/chat/common/chatBackgroundTask.ts index 8124a7cceeca6..a945e587981ff 100644 --- a/src/vs/workbench/contrib/chat/common/chatBackgroundTask.ts +++ b/src/vs/workbench/contrib/chat/common/chatBackgroundTask.ts @@ -30,13 +30,6 @@ export interface IChatBackgroundTask { cancel(): void; } -export interface ISerializedBackgroundTask { - readonly taskId: string; - readonly name: string; - readonly source: BackgroundTaskSource; - readonly status: BackgroundTaskStatus; -} - export interface IChatBackgroundTaskHandle extends IChatBackgroundTask { complete(result: unknown): void; fail(message?: string): void;