Skip to content
Open
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 @@ -252,6 +252,13 @@ export const ToggleAgentModeActionId = 'workbench.action.chat.toggleAgentMode';
export interface IToggleChatModeArgs {
modeId: ChatModeKind | string;
sessionResource: URI | undefined;
/**
* Unique input URI of the chat widget that initiated the mode change. Used to
* disambiguate when multiple chat widgets are showing the same session (e.g.
* a chat view and a chat editor in an editor group). When provided, the widget
* is resolved by input URI first, falling back to session resource.
*/
inputUri?: URI;
}

type ChatModeChangeClassification = {
Expand Down Expand Up @@ -302,9 +309,16 @@ class ToggleChatModeAction extends Action2 {

const arg = args.at(0) as IToggleChatModeArgs | undefined;
let widget: IChatWidget | undefined;
if (arg?.sessionResource) {
// Prefer input URI over session resource: multiple chat widgets can share a
// session resource (e.g. chat view + chat editor), but each widget's input URI
// is unique. This ensures the mode picker targets the widget it is hosted in.
if (arg?.inputUri) {
widget = chatWidgetService.getWidgetByInputUri(arg.inputUri);
}
if (!widget && arg?.sessionResource) {
widget = chatWidgetService.getWidgetBySessionResource(arg.sessionResource);
} else {
}
if (!widget) {
widget = getEditingSessionContext(accessor, args)?.chatWidget;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
currentMode: this._currentModeObservable,
currentChatModes,
sessionResource: () => this._widget?.viewModel?.sessionResource,
inputUri: () => this.inputUri,
// Direct setter for hosts that embed `ChatInputPart` without
// registering an `IChatWidget` (e.g. the automations dialog).
// The picker only calls this when `sessionResource()` is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export interface IModePickerDelegate {
readonly sessionResource: () => URI | undefined;
/** Direct mode-change callback for hosts without a registered IChatWidget (bypasses ToggleAgentModeActionId). */
readonly setMode?: (mode: IChatMode) => void;
/**
* Unique input URI of the chat widget hosting this picker. Required to
* disambiguate when multiple chat widgets share a session resource (e.g. a
* chat view and a chat editor showing the same session), so mode changes
* target the widget the user actually clicked in.
*/
readonly inputUri?: () => URI | undefined;
/**
* When set, the mode picker will show custom agents whose target matches this value.
* Custom agents without a target are always shown in all session types. If no agents match the target, shows a default "Agent" option.
Expand Down Expand Up @@ -154,7 +161,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem {
}
const result = await commandService.executeCommand(
ToggleAgentModeActionId,
{ modeId: mode.id, sessionResource: this.delegate.sessionResource() } satisfies IToggleChatModeArgs
{ modeId: mode.id, sessionResource: this.delegate.sessionResource(), inputUri: this.delegate.inputUri?.() } satisfies IToggleChatModeArgs
);
if (this.element) {
this.renderLabel(this.element);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { DisposableStore } from '../../../../../../base/common/lifecycle.js';
import { constObservable, observableValue } from '../../../../../../base/common/observable.js';
import { URI } from '../../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js';
import { CommandsRegistry, ICommandService } from '../../../../../../platform/commands/common/commands.js';
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js';
import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js';
import { type IChatAcceptInputOptions, IChatWidget, IChatWidgetService } from '../../../browser/chat.js';
import { ChatSubmitAction, ExecuteHandoffActionId, GetHandoffsActionId, registerChatExecuteActions } from '../../../browser/actions/chatExecuteActions.js';
import { ChatSubmitAction, ExecuteHandoffActionId, GetHandoffsActionId, IToggleChatModeArgs, registerChatExecuteActions, ToggleAgentModeActionId } from '../../../browser/actions/chatExecuteActions.js';
import { IChatMode, IChatModes, IChatModeService, ICustomAgentInfo } from '../../../common/chatModes.js';
import { ChatModeKind } from '../../../common/constants.js';
import { IHandOff } from '../../../common/promptSyntax/promptFileParser.js';
Expand Down Expand Up @@ -445,3 +445,144 @@ suite('ChatSubmitAction', () => {
assert.deepStrictEqual(acceptedOptions, { cancelCurrentRequest: true });
});
});

suite('ToggleChatModeAction', () => {
const store = new DisposableStore();
let instantiationService: TestInstantiationService;

let chatExecuteActions: DisposableStore;
suiteSetup(() => {
chatExecuteActions = registerChatExecuteActions();
});

suiteTeardown(() => {
chatExecuteActions.dispose();
});

setup(() => {
instantiationService = store.add(new TestInstantiationService());
instantiationService.stub(ITelemetryService, NullTelemetryService);
instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService);
});

teardown(() => {
store.clear();
});

ensureNoDisposablesAreLeakedInTestSuite();

/**
* Build a minimal chat widget whose input records setChatMode calls. The
* viewModel's model has no editingSession so handleModeSwitch short-circuits.
*/
function createMockWidget(currentMode: IChatMode, chatModes: IChatModes, sessionResource: URI, inputUri: URI): {
widget: IChatWidget;
setChatModeCalls: string[];
} {
const setChatModeCalls: string[] = [];
const widget = {
input: {
currentModeObs: constObservable(currentMode),
currentChatModesObs: constObservable(chatModes),
currentModeKind: currentMode.kind,
inputUri,
setChatMode: (id: string) => {
setChatModeCalls.push(id);
},
},
viewModel: {
sessionResource,
model: {
sessionResource,
getRequests: () => [],
editingSession: undefined,
},
},
} as unknown as IChatWidget;
return { widget, setChatModeCalls };
}

test('resolves widget by inputUri when multiple widgets share a session resource (issue #275200)', async () => {
const askMode = createMockMode({ id: 'ask', kind: ChatModeKind.Ask, isBuiltin: true });
const agentMode = createMockMode({ id: 'agent', kind: ChatModeKind.Agent, isBuiltin: true });
const chatModeService = new MockChatModeService({ builtin: [askMode, agentMode], custom: [] });
const modes = await chatModeService.getLocalModes();

const sharedSession = URI.parse('chat-session://shared/1');
const panelInputUri = URI.parse('vscode-chat-input:input-panel');
const editorInputUri = URI.parse('vscode-chat-input:input-editor');

// Two widgets with the SAME session resource but DIFFERENT input URIs.
// Simulates a chat panel and chat editor both showing the same session.
const panel = createMockWidget(askMode, modes, sharedSession, panelInputUri);
const editor = createMockWidget(askMode, modes, sharedSession, editorInputUri);

const mockWidgetService = new class extends MockChatWidgetService {
// find-by-session returns the FIRST widget (the bug path) — here, the panel.
override getWidgetBySessionResource(resource: URI) {
return resource.toString() === sharedSession.toString() ? panel.widget : undefined;
}
override getWidgetByInputUri(uri: URI) {
if (uri.toString() === panelInputUri.toString()) {
return panel.widget;
}
if (uri.toString() === editorInputUri.toString()) {
return editor.widget;
}
return undefined;
}
};

instantiationService.set(IChatWidgetService, mockWidgetService);
instantiationService.set(IChatModeService, chatModeService);

const handler = CommandsRegistry.getCommand(ToggleAgentModeActionId)?.handler;
assert.ok(handler);

// User clicks the mode picker in the EDITOR widget, which passes its inputUri.
const args: IToggleChatModeArgs = {
modeId: 'agent',
sessionResource: sharedSession,
inputUri: editorInputUri,
};
await runCommandAsync<void>(handler, instantiationService, args);

// The editor widget (the one the user clicked in) should have its mode changed.
// Before the fix, the panel would incorrectly receive the mode change because
// getWidgetBySessionResource returned the first match.
assert.deepStrictEqual(editor.setChatModeCalls, ['agent'], 'editor widget should receive setChatMode');
assert.deepStrictEqual(panel.setChatModeCalls, [], 'panel widget should NOT receive setChatMode');
});

test('falls back to sessionResource when inputUri is not provided (back-compat)', async () => {
const askMode = createMockMode({ id: 'ask', kind: ChatModeKind.Ask, isBuiltin: true });
const agentMode = createMockMode({ id: 'agent', kind: ChatModeKind.Agent, isBuiltin: true });
const chatModeService = new MockChatModeService({ builtin: [askMode, agentMode], custom: [] });
const modes = await chatModeService.getLocalModes();

const sessionResource = URI.parse('chat-session://only/1');
const inputUri = URI.parse('vscode-chat-input:input-only');
const { widget, setChatModeCalls } = createMockWidget(askMode, modes, sessionResource, inputUri);

const mockWidgetService = new class extends MockChatWidgetService {
override getWidgetBySessionResource(resource: URI) {
return resource.toString() === sessionResource.toString() ? widget : undefined;
}
};

instantiationService.set(IChatWidgetService, mockWidgetService);
instantiationService.set(IChatModeService, chatModeService);

const handler = CommandsRegistry.getCommand(ToggleAgentModeActionId)?.handler;
assert.ok(handler);

// Legacy callers that don't pass inputUri should still work via sessionResource.
const args: IToggleChatModeArgs = {
modeId: 'agent',
sessionResource,
};
await runCommandAsync<void>(handler, instantiationService, args);

assert.deepStrictEqual(setChatModeCalls, ['agent']);
});
});