Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -97,7 +99,7 @@ abstract class AbstractChatAttachmentWidget extends Disposable {
public readonly element: HTMLElement;
public readonly label: IResourceLabel;

private readonly _onDidDelete: event.Emitter<Event> = this._register(new event.Emitter<Event>());
protected readonly _onDidDelete: event.Emitter<Event> = this._register(new event.Emitter<Event>());
get onDidDelete(): event.Event<Event> {
return this._onDidDelete.event;
}
Expand Down Expand Up @@ -1572,3 +1574,129 @@ function addBasicContextMenu(accessor: ServicesAccessor, widget: HTMLElement, sc
}

export const chatAttachmentResourceContextKey = new RawContextKey<string>('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);
}
}));
}
}
3 changes: 3 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chat.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
136 changes: 136 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chatBackgroundTaskService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*---------------------------------------------------------------------------------------------
* 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';
import { IChatService } from '../common/chatService/chatService.js';

class ChatBackgroundTask extends Disposable implements IChatBackgroundTaskHandle {
private readonly _status: ISettableObservable<BackgroundTaskStatus>;
private readonly _statusMessage: ISettableObservable<string | undefined>;
private readonly _result: ISettableObservable<unknown | undefined>;

get status(): IObservable<BackgroundTaskStatus> { return this._status; }
get statusMessage(): IObservable<string | undefined> { return this._statusMessage; }
get result(): IObservable<unknown | undefined> { 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 {
if (this._status.get() !== BackgroundTaskStatus.Working) {
return;
}
this._result.set(result, undefined);
this._status.set(BackgroundTaskStatus.Completed, undefined);
}

Comment on lines +35 to +51
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChatBackgroundTask.cancel() sets status to Cancelled, but fail()/complete() can still overwrite it later. This causes cancel to end up as Failed when the terminal disposal handler runs (and can also flip Completed→Failed). Make complete()/fail() no-op unless the task is still in Working state (similar to cancel()), or otherwise enforce a one-way state transition.

Copilot uses AI. Check for mistakes.
fail(message?: string): void {
if (this._status.get() !== BackgroundTaskStatus.Working) {
return;
}
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<string, DisposableMap<string, ChatBackgroundTask>>());
private readonly _onDidChangeTasks = this._register(new Emitter<void>());

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: {
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<readonly IChatBackgroundTask[]> {
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);
Comment on lines +103 to +110
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

evictTask removes the task from the per-session map but leaves an empty session map behind. Over time, sessions that have had tasks will accumulate empty DisposableMaps. After deleting a task, if the per-session map is empty, deleteAndDispose that session entry too.

Copilot uses AI. Check for mistakes.
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();
}
}
16 changes: 16 additions & 0 deletions src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.$;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Comment on lines +2474 to +2479
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says tasks are evicted "so their context is included in this request", but eviction happens after sendRequest has already captured attachedContext. This is misleading; update the comment to reflect the actual intent (e.g. to avoid re-attaching the same completed tasks to subsequent requests / to remove completed tasks after they've been consumed).

Copilot uses AI. Check for mistakes.
}
}
}

const sent = ChatSendResult.isQueued(result) ? await result.deferred : result;
if (!ChatSendResult.isSent(sent)) {
return;
Expand Down
Loading
Loading