Generically exit sidebar chat after delegating (#280384)

* Initial plan

* Add delegation event to exit panel chat when chatSessions API delegates to new session

Co-authored-by: joshspicer <23246594+joshspicer@users.noreply.github.com>

* Fix: Store viewModel reference to avoid potential null reference during delegation exit

Co-authored-by: joshspicer <23246594+joshspicer@users.noreply.github.com>

* fix

* tidy

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joshspicer <23246594+joshspicer@users.noreply.github.com>
This commit is contained in:
Copilot
2025-12-01 16:39:42 -08:00
committed by GitHub
parent 4687600147
commit e7c75be005
6 changed files with 100 additions and 92 deletions

View File

@@ -40,7 +40,6 @@ import { IChatWidgetService } from '../chat.js';
import { CHAT_SETUP_ACTION_ID } from './chatActions.js'; import { CHAT_SETUP_ACTION_ID } from './chatActions.js';
import { PromptFileVariableKind, toPromptFileVariableEntry } from '../../common/chatVariableEntries.js'; import { PromptFileVariableKind, toPromptFileVariableEntry } from '../../common/chatVariableEntries.js';
import { NEW_CHAT_SESSION_ACTION_ID } from '../chatSessions/common.js'; import { NEW_CHAT_SESSION_ACTION_ID } from '../chatSessions/common.js';
import { isResponseVM } from '../../common/chatViewModel.js';
export const enum ActionLocation { export const enum ActionLocation {
ChatWidget = 'chatWidget', ChatWidget = 'chatWidget',
@@ -285,57 +284,7 @@ class CreateRemoteAgentJobAction {
}); });
if (requestData) { if (requestData) {
await requestData.responseCompletePromise; await widget.handleDelegationExitIfNeeded(requestData.agent);
const checkAndClose = () => {
const items = widget.viewModel?.getItems() ?? [];
const lastItem = items[items.length - 1];
if (lastItem && isResponseVM(lastItem) && lastItem.isComplete && !lastItem.model.isPendingConfirmation.get()) {
return true;
}
return false;
};
if (checkAndClose()) {
await widget.clear();
return;
}
// Monitor subsequent responses when pending confirmations block us from closing
await new Promise<void>((resolve, reject) => {
let disposed = false;
let disposable: IDisposable | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (!disposed) {
disposed = true;
if (timeout !== undefined) {
clearTimeout(timeout);
}
if (disposable) {
disposable.dispose();
}
}
};
try {
disposable = widget.viewModel!.onDidChange(() => {
if (checkAndClose()) {
cleanup();
resolve();
}
});
timeout = setTimeout(() => {
cleanup();
resolve();
}, 30_000); // 30 second timeout
} catch (e) {
cleanup();
reject(e);
}
});
await widget.clear();
} }
} catch (e) { } catch (e) {
console.error('Error creating remote coding agent job', e); console.error('Error creating remote coding agent job', e);

View File

@@ -262,6 +262,7 @@ export interface IChatWidget {
clear(): Promise<void>; clear(): Promise<void>;
getViewState(): IChatModelInputState | undefined; getViewState(): IChatModelInputState | undefined;
lockToCodingAgent(name: string, displayName: string, agentId?: string): void; lockToCodingAgent(name: string, displayName: string, agentId?: string): void;
handleDelegationExitIfNeeded(agent: IChatAgentData | undefined): Promise<void>;
delegateScrollFromMouseWheelEvent(event: IMouseWheelEvent): void; delegateScrollFromMouseWheelEvent(event: IMouseWheelEvent): void;
} }

View File

@@ -699,6 +699,15 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
.filter(contribution => this._isContributionAvailable(contribution)); .filter(contribution => this._isContributionAvailable(contribution));
} }
getChatSessionContribution(chatSessionType: string): IChatSessionsExtensionPoint | undefined {
const contribution = this._contributions.get(chatSessionType)?.contribution;
if (!contribution) {
return undefined;
}
return this._isContributionAvailable(contribution) ? contribution : undefined;
}
getAllChatSessionItemProviders(): IChatSessionItemProvider[] { getAllChatSessionItemProviders(): IChatSessionItemProvider[] {
return [...this._itemsProviders.values()].filter(provider => { return [...this._itemsProviders.values()].filter(provider => {
// Check if the provider's corresponding contribution is available // Check if the provider's corresponding contribution is available

View File

@@ -1317,46 +1317,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
this.input.setValue(`@${agentId} ${promptToUse}`, false); this.input.setValue(`@${agentId} ${promptToUse}`, false);
this.input.focus(); this.input.focus();
// Auto-submit for delegated chat sessions // Auto-submit for delegated chat sessions
this.acceptInput().then(async (response) => { this.acceptInput().catch(e => this.logService.error('Failed to handle handoff continueOn', e));
if (!response || !this.viewModel) {
return;
}
// Wait for response to complete without any user-pending confirmations
const checkForComplete = () => {
const items = this.viewModel?.getItems() ?? [];
const lastItem = items[items.length - 1];
if (lastItem && isResponseVM(lastItem) && lastItem.model && lastItem.isComplete && !lastItem.model.isPendingConfirmation.get()) {
return true;
}
return false;
};
if (checkForComplete()) {
await this.clear();
return;
}
await new Promise<void>(resolve => {
const disposable = this.viewModel!.onDidChange(() => {
if (checkForComplete()) {
cleanup();
resolve();
}
});
const timeout = setTimeout(() => {
cleanup();
resolve();
}, 30000); // 30 second timeout
const cleanup = () => {
clearTimeout(timeout);
disposable.dispose();
};
});
// Clear parent editor
await this.clear();
}).catch(e => this.logService.error('Failed to handle handoff continueOn', e));
} else if (handoff.agent) { } else if (handoff.agent) {
// Regular handoff to specified agent // Regular handoff to specified agent
this._switchToAgentByName(handoff.agent); this._switchToAgentByName(handoff.agent);
@@ -1371,6 +1332,87 @@ export class ChatWidget extends Disposable implements IChatWidget {
} }
} }
async handleDelegationExitIfNeeded(agent: IChatAgentData | undefined): Promise<void> {
if (!this._shouldExitAfterDelegation(agent)) {
return;
}
try {
await this._handleDelegationExit();
} catch (e) {
this.logService.error('Failed to handle delegation exit', e);
}
}
private _shouldExitAfterDelegation(agent: IChatAgentData | undefined): boolean {
if (!agent) {
return false;
}
if (!isIChatViewViewContext(this.viewContext)) {
return false;
}
const contribution = this.chatSessionsService.getChatSessionContribution(agent.id);
if (!contribution) {
return false;
}
if (contribution.canDelegate !== true) {
return false;
}
return true;
}
/**
* Handles the exit of the panel chat when a delegation to another session occurs.
* Waits for the response to complete and any pending confirmations to be resolved,
* then clears the widget.
*/
private async _handleDelegationExit(): Promise<void> {
const viewModel = this.viewModel;
if (!viewModel) {
return;
}
// Check if response is already complete without pending confirmations
const checkForComplete = () => {
const items = viewModel.getItems();
const lastItem = items[items.length - 1];
if (lastItem && isResponseVM(lastItem) && lastItem.model && lastItem.isComplete && !lastItem.model.isPendingConfirmation.get()) {
return true;
}
return false;
};
if (checkForComplete()) {
await this.clear();
return;
}
// Wait for response to complete with a timeout
await new Promise<void>(resolve => {
const disposable = viewModel.onDidChange(() => {
if (checkForComplete()) {
cleanup();
resolve();
}
});
const timeout = setTimeout(() => {
cleanup();
resolve();
}, 30_000); // 30 second timeout
const cleanup = () => {
clearTimeout(timeout);
disposable.dispose();
};
});
// Clear the widget after delegation completes
await this.clear();
}
setVisible(visible: boolean): void { setVisible(visible: boolean): void {
const wasVisible = this._visible; const wasVisible = this._visible;
this._visible = visible; this._visible = visible;
@@ -2288,6 +2330,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
this.input.acceptInput(isUserQuery); this.input.acceptInput(isUserQuery);
this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand }); this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand });
this.handleDelegationExitIfNeeded(result.agent);
this.currentRequest = result.responseCompletePromise.then(() => { this.currentRequest = result.responseCompletePromise.then(() => {
const responses = this.viewModel?.getItems().filter(isResponseVM); const responses = this.viewModel?.getItems().filter(isResponseVM);
const lastResponse = responses?.[responses.length - 1]; const lastResponse = responses?.[responses.length - 1];

View File

@@ -162,6 +162,8 @@ export interface IChatSessionsService {
readonly onDidChangeAvailability: Event<void>; readonly onDidChangeAvailability: Event<void>;
readonly onDidChangeInProgress: Event<void>; readonly onDidChangeInProgress: Event<void>;
getChatSessionContribution(chatSessionType: string): IChatSessionsExtensionPoint | undefined;
registerChatSessionItemProvider(provider: IChatSessionItemProvider): IDisposable; registerChatSessionItemProvider(provider: IChatSessionItemProvider): IDisposable;
activateChatSessionItemProvider(chatSessionType: string): Promise<IChatSessionItemProvider | undefined>; activateChatSessionItemProvider(chatSessionType: string): Promise<IChatSessionItemProvider | undefined>;
getAllChatSessionItemProviders(): IChatSessionItemProvider[]; getAllChatSessionItemProviders(): IChatSessionItemProvider[];

View File

@@ -70,6 +70,10 @@ export class MockChatSessionsService implements IChatSessionsService {
return this.contributions; return this.contributions;
} }
getChatSessionContribution(chatSessionType: string): IChatSessionsExtensionPoint | undefined {
return this.contributions.find(contrib => contrib.type === chatSessionType);
}
setContributions(contributions: IChatSessionsExtensionPoint[]): void { setContributions(contributions: IChatSessionsExtensionPoint[]): void {
this.contributions = contributions; this.contributions = contributions;
} }