Feat: Show branch picker in core (#296559)

* refactor: simplify interface declarations and improve method naming in NewChatWidget

* refactor: update local mode handling in NewChatWidget to support folder isolation

* refactor: implement SessionTargetPicker for managing session targets and isolation modes

* refactor: integrate IsolationModePicker into NewChatWidget for improved session management

* refactor: consolidate IsolationModePicker and SessionTargetPicker imports in NewChatViewPane

* refactor: remove isolation mode pickers from NewChatWidget and update extension picker logic for Cloud target only

* refactor: simplify folder change handling in NewChatWidget and remove unused notification logic

* refactor: introduce IPendingSession interface and implement Local/RemotePendingSession classes for session management

* refactor: replace IPendingSession with INewSession for improved session management

* refactor: move pending session creation to SessionsManagementService for better session handling

* refactor: update session creation to return a promise and enhance session handling

* Update customization: sessions.json

* fix: correct import path for new session classes in sessionsManagementService

* remove

* fix: update session target terminology from 'Folder' to 'Local' in SessionTargetPicker

* feat: add branch selection functionality to new chat session

* feat: enhance branch picker UI and functionality in chat session

* feat: enhance repository opening logic in NewChatWidget

* refactor: simplify repository opening logic in NewChatWidget

* feat: implement BranchPicker component for selecting git branches

* feat: improve session management in NewChatWidget and RemoteNewSession

* feat: enhance BranchPicker to use a toggleable section for Copilot worktree branches

* feat: add option handling to NewSession interface and implement in RemoteNewSession

* feat: update BranchPicker to use a toggleable section for worktree branches and improve filtering options

* feat: select the first non-worktree branch by default in BranchPicker

* feat: refine BranchPicker to select main or master branch by default and streamline worktree filtering

* feat: enhance LocalNewSession to notify session option changes and include service dependencies

* feat: update INewSession to allow string values in setOption and refactor LocalNewSession to use setOption for notifying changes

* feat: refactor session request handling to use INewSession and simplify sendRequestForNewSession method

* feat: add modelId to INewSession interface and implement modelId handling in NewChatWidget

* feat: update session management to support new session options and refactor sendRequestForNewSession method

* feat: enhance session management by adding query and attached context handling in NewSession and SessionsManagementService

* feat: update default isolation mode in IsolationModePicker to 'folder'

* feat: enhance session management by clearing sent sessions in NewChatWidget and SessionsManagementService

* feat: update LocalNewSession to set isolation mode to 'folder' and clear branch on repo URI change

* feat: enhance LocalNewSession to handle string and object types in setOption method

* feat: refactor session creation in NewChatWidget to use createNewSession method

* feat: simplify session management by removing unused session map in NewChatWidget

* feat: initialize repoUri in LocalNewSession constructor only if provided

* feat: update NewChatWidget to open repository based on session's repoUri

* feat: improve folder change handling in NewChatWidget to open repository and render extension pickers

* feat: enhance session change handling in NewChatWidget and NewSession to support specific change types

* feat: add context change handling to render extension pickers in NewChatWidget

* feat: update BranchPicker and IsolationModePicker to handle disabled states based on repository availability

* feat: update IsolationModePicker to handle disabled state based on repository availability

* feat: add disabled state styles for session chat picker slot

* feat: update BranchPicker to toggle disabled state on slot element

* feat: update NewChatWidget to toggle branch picker visibility based on isolation mode

* feat: update isolation mode references from 'folder' to 'workspace'

* feat: disable curated models display in NewChatWidget
This commit is contained in:
Sandeep Somavarapu
2026-02-20 20:30:59 +01:00
committed by GitHub
parent f186967cb8
commit daddf4fcae
7 changed files with 901 additions and 546 deletions
@@ -0,0 +1,197 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as dom from '../../../../base/browser/dom.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
import { localize } from '../../../../nls.js';
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js';
import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js';
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { INewSession } from './newSession.js';
const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-';
const FILTER_THRESHOLD = 10;
interface IBranchItem {
readonly name: string;
}
/**
* A self-contained widget for selecting a git branch.
* Uses `IGitRepository.getRefs` to list local branches.
* Copilot worktree branches are shown in a collapsible section;
* other branches are listed without a section header.
* Writes the selected branch to the new session object.
*/
export class BranchPicker extends Disposable {
private _selectedBranch: string | undefined;
private _newSession: INewSession | undefined;
private _branches: string[] = [];
private readonly _onDidChange = this._register(new Emitter<string | undefined>());
readonly onDidChange: Event<string | undefined> = this._onDidChange.event;
private readonly _renderDisposables = this._register(new DisposableStore());
private _slotElement: HTMLElement | undefined;
private _triggerElement: HTMLElement | undefined;
get selectedBranch(): string | undefined {
return this._selectedBranch;
}
constructor(
@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,
) {
super();
}
/**
* Sets the new session that this picker writes to.
*/
setNewSession(session: INewSession | undefined): void {
this._newSession = session;
}
/**
* Sets the git repository and loads its branches.
* When undefined, the picker is shown disabled.
*/
async setRepository(repository: IGitRepository | undefined): Promise<void> {
this._branches = [];
this._selectedBranch = undefined;
if (!repository) {
this._newSession?.setBranch(undefined);
this._updateTriggerLabel();
return;
}
const refs = await repository.getRefs({ pattern: 'refs/heads' });
this._branches = refs
.map(ref => ref.name)
.filter((name): name is string => !!name)
.filter(name => !name.includes(COPILOT_WORKTREE_PATTERN));
// Select main, master, or the first branch by default
const defaultBranch = this._branches.find(b => b === 'main')
?? this._branches.find(b => b === 'master')
?? this._branches[0];
if (defaultBranch) {
this._selectBranch(defaultBranch);
}
this._updateTriggerLabel();
}
/**
* Renders the branch picker trigger into the given container.
*/
render(container: HTMLElement): void {
this._renderDisposables.clear();
const slot = dom.append(container, dom.$('.sessions-chat-picker-slot'));
this._slotElement = slot;
this._renderDisposables.add({ dispose: () => slot.remove() });
const trigger = dom.append(slot, dom.$('a.action-label'));
trigger.tabIndex = 0;
trigger.role = 'button';
this._triggerElement = trigger;
this._updateTriggerLabel();
this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => {
dom.EventHelper.stop(e, true);
this.showPicker();
}));
this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => {
if (e.key === 'Enter' || e.key === ' ') {
dom.EventHelper.stop(e, true);
this.showPicker();
}
}));
}
/**
* Shows or hides the picker.
*/
setVisible(visible: boolean): void {
if (this._slotElement) {
this._slotElement.style.display = visible ? '' : 'none';
}
}
/**
* Shows the branch picker dropdown anchored to the trigger element.
*/
showPicker(): void {
if (!this._triggerElement || this.actionWidgetService.isVisible || this._branches.length === 0) {
return;
}
const items = this._buildItems();
const triggerElement = this._triggerElement;
const delegate: IActionListDelegate<IBranchItem> = {
onSelect: (item) => {
this.actionWidgetService.hide();
this._selectBranch(item.name);
},
onHide: () => { triggerElement.focus(); },
};
const totalActions = items.filter(i => i.kind === ActionListItemKind.Action).length;
this.actionWidgetService.show<IBranchItem>(
'branchPicker',
false,
items,
delegate,
this._triggerElement,
undefined,
[],
{
getAriaLabel: (item) => item.label ?? '',
getWidgetAriaLabel: () => localize('branchPicker.ariaLabel', "Branch Picker"),
},
totalActions > FILTER_THRESHOLD ? { showFilter: true, filterPlaceholder: localize('branchPicker.filter', "Filter branches...") } : undefined,
);
}
private _buildItems(): IActionListItem<IBranchItem>[] {
return this._branches.map(branch => ({
kind: ActionListItemKind.Action,
label: branch,
group: { title: '', icon: this._selectedBranch === branch ? Codicon.check : Codicon.blank },
item: { name: branch },
}));
}
private _selectBranch(branch: string): void {
if (this._selectedBranch !== branch) {
this._selectedBranch = branch;
this._newSession?.setBranch(branch);
this._onDidChange.fire(branch);
this._updateTriggerLabel();
}
}
private _updateTriggerLabel(): void {
if (!this._triggerElement) {
return;
}
dom.clearNode(this._triggerElement);
const isDisabled = this._branches.length === 0;
const label = this._selectedBranch ?? localize('branchPicker.select', "Branch");
dom.append(this._triggerElement, renderIcon(Codicon.gitBranch));
const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label'));
labelSpan.textContent = label;
dom.append(this._triggerElement, renderIcon(Codicon.chevronDown));
this._slotElement?.classList.toggle('disabled', isDisabled);
}
}
@@ -17,6 +17,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js';
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { INewSession } from './newSession.js';
const STORAGE_KEY_LAST_FOLDER = 'agentSessions.lastPickedFolder';
const STORAGE_KEY_RECENT_FOLDERS = 'agentSessions.recentlyPickedFolders';
@@ -42,6 +43,7 @@ export class FolderPicker extends Disposable {
private _selectedFolderUri: URI | undefined;
private _recentlyPickedFolders: URI[] = [];
private _cachedRecentFolders: { uri: URI; label?: string }[] = [];
private _newSession: INewSession | undefined;
private _triggerElement: HTMLElement | undefined;
private readonly _renderDisposables = this._register(new DisposableStore());
@@ -50,6 +52,14 @@ export class FolderPicker extends Disposable {
return this._selectedFolderUri;
}
/**
* Sets the pending session that this picker writes to.
* When the user selects a folder, it calls `setRepoUri` on the session.
*/
setNewSession(session: INewSession | undefined): void {
this._newSession = session;
}
constructor(
@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,
@IStorageService private readonly storageService: IStorageService,
@@ -175,6 +185,7 @@ export class FolderPicker extends Disposable {
this._addToRecentlyPickedFolders(folderUri);
this.storageService.store(STORAGE_KEY_LAST_FOLDER, folderUri.toString(), StorageScope.PROFILE, StorageTarget.MACHINE);
this._updateTriggerLabel(this._triggerElement);
this._newSession?.setRepoUri(folderUri);
this._onDidSelectFolder.fire(folderUri);
}
@@ -298,6 +298,17 @@
color: var(--vscode-foreground);
}
.sessions-chat-picker-slot.disabled .action-label {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
.sessions-chat-picker-slot.disabled .action-label:hover {
background-color: transparent;
color: var(--vscode-descriptionForeground);
}
.sessions-chat-picker-slot .action-label .codicon {
font-size: 14px;
flex-shrink: 0;
@@ -7,14 +7,11 @@ import './media/chatWidget.css';
import './media/chatWelcomePart.css';
import * as dom from '../../../../base/browser/dom.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { toAction } from '../../../../base/common/actions.js';
import { Radio } from '../../../../base/browser/ui/radio/radio.js';
import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Emitter } from '../../../../base/common/event.js';
import { KeyCode } from '../../../../base/common/keyCodes.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { IObservable, observableValue } from '../../../../base/common/observable.js';
import { observableValue } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js';
@@ -32,149 +29,39 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { isEqual } from '../../../../base/common/resources.js';
import { localize } from '../../../../nls.js';
import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js';
import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js';
import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js';
import { SearchableOptionPickerActionItem } from '../../../../workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.js';
import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js';
import { IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js';
import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';
import { IModelPickerDelegate } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js';
import { EnhancedModelPickerActionItem } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.js';
import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js';
import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js';
import { IViewDescriptorService } from '../../../../workbench/common/views.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js';
import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js';
import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js';
import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
import { isString } from '../../../../base/common/types.js';
import { NewChatContextAttachments } from './newChatContextAttachments.js';
import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js';
import { FolderPicker } from './folderPicker.js';
// #region --- Target Config ---
/**
* A dropdown menu action item that shows an icon, a text label, and a chevron.
*/
class LabeledDropdownMenuActionViewItem extends DropdownMenuActionViewItem {
protected override renderLabel(element: HTMLElement): null {
// Render icon as a separate codicon element
const classNames = typeof this.options.classNames === 'string'
? this.options.classNames.split(/\s+/g).filter(s => !!s)
: (this.options.classNames ?? []);
if (classNames.length > 0) {
const icon = dom.append(element, dom.$('span'));
icon.classList.add('codicon', ...classNames);
}
// Add text label (not affected by codicon font)
const label = dom.append(element, dom.$('span.sessions-chat-dropdown-label'));
label.textContent = this._action.label;
// Add chevron
dom.append(element, renderIcon(Codicon.chevronDown));
return null;
}
}
/**
* Tracks which agent session targets are available and which is selected.
* Targets are fixed at construction time; only the selection changes.
*/
export interface ITargetConfig {
readonly allowedTargets: IObservable<ReadonlySet<AgentSessionProviders>>;
readonly selectedTarget: IObservable<AgentSessionProviders | undefined>;
readonly onDidChangeSelectedTarget: Event<AgentSessionProviders | undefined>;
readonly onDidChangeAllowedTargets: Event<ReadonlySet<AgentSessionProviders>>;
setSelectedTarget(target: AgentSessionProviders): void;
}
export interface ITargetConfigOptions {
allowedTargets: AgentSessionProviders[];
defaultTarget?: AgentSessionProviders;
}
class TargetConfig extends Disposable implements ITargetConfig {
private readonly _allowedTargets = observableValue<ReadonlySet<AgentSessionProviders>>('allowedTargets', new Set());
readonly allowedTargets: IObservable<ReadonlySet<AgentSessionProviders>> = this._allowedTargets;
private readonly _selectedTarget = observableValue<AgentSessionProviders | undefined>('selectedTarget', undefined);
readonly selectedTarget: IObservable<AgentSessionProviders | undefined> = this._selectedTarget;
private readonly _onDidChangeSelectedTarget = this._register(new Emitter<AgentSessionProviders | undefined>());
readonly onDidChangeSelectedTarget = this._onDidChangeSelectedTarget.event;
private readonly _onDidChangeAllowedTargets = this._register(new Emitter<ReadonlySet<AgentSessionProviders>>());
readonly onDidChangeAllowedTargets = this._onDidChangeAllowedTargets.event;
constructor(options: ITargetConfigOptions) {
super();
const initialSet = new Set(options.allowedTargets);
this._allowedTargets.set(initialSet, undefined);
const defaultTarget = options.defaultTarget && initialSet.has(options.defaultTarget)
? options.defaultTarget
: initialSet.values().next().value;
this._selectedTarget.set(defaultTarget, undefined);
}
setSelectedTarget(target: AgentSessionProviders): void {
const allowed = this._allowedTargets.get();
if (!allowed.has(target)) {
throw new Error(`Target "${target}" is not in the allowed set`);
}
if (this._selectedTarget.get() !== target) {
this._selectedTarget.set(target, undefined);
this._onDidChangeSelectedTarget.fire(target);
}
}
setAllowedTargets(targets: AgentSessionProviders[]): void {
const newSet = new Set(targets);
this._allowedTargets.set(newSet, undefined);
this._onDidChangeAllowedTargets.fire(newSet);
// If the currently selected target is no longer allowed, switch to the first allowed target
const current = this._selectedTarget.get();
if (current && !newSet.has(current)) {
const fallback = newSet.values().next().value;
this._selectedTarget.set(fallback, undefined);
this._onDidChangeSelectedTarget.fire(fallback);
}
}
}
// #endregion
import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js';
import { IsolationModePicker, SessionTargetPicker } from './sessionTargetPicker.js';
import { BranchPicker } from './branchPicker.js';
import { INewSession } from './newSession.js';
// #region --- Chat Welcome Widget ---
/**
* Data passed to the `onSendRequest` callback when the user submits a query.
*/
export interface INewChatSendRequestData {
readonly resource: URI;
readonly target: AgentSessionProviders;
readonly query: string;
readonly sendOptions: IChatSendRequestOptions;
readonly selectedOptions: ReadonlyMap<string, IChatSessionProviderOptionItem>;
readonly folderUri?: URI;
readonly attachedContext?: IChatRequestVariableEntry[];
}
/**
* Options for creating a `NewChatWidget`.
*/
export interface INewChatWidgetOptions {
readonly targetConfig: ITargetConfigOptions;
readonly onSendRequest?: (data: INewChatSendRequestData) => void;
interface INewChatWidgetOptions {
readonly allowedTargets: AgentSessionProviders[];
readonly defaultTarget: AgentSessionProviders;
readonly sessionPosition?: ChatSessionPosition;
}
@@ -187,26 +74,25 @@ export interface INewChatWidgetOptions {
*/
class NewChatWidget extends Disposable {
private readonly _targetConfig: TargetConfig;
private readonly _targetPicker: SessionTargetPicker;
private readonly _isolationModePicker: IsolationModePicker;
private readonly _branchPicker: BranchPicker;
private readonly _options: INewChatWidgetOptions;
// Input
private _editor!: CodeEditorWidget;
private readonly _currentLanguageModel = observableValue<ILanguageModelChatMetadataAndIdentifier | undefined>('currentLanguageModel', undefined);
private readonly _modelPickerDisposable = this._register(new MutableDisposable());
private _pendingSessionResource: URI | undefined;
// Pending session
private readonly _newSession = this._register(new MutableDisposable<INewSession>());
private readonly _newSessionListener = this._register(new MutableDisposable());
// Welcome part
private readonly _welcomeContentDisposables = this._register(new DisposableStore());
private _pickersContainer: HTMLElement | undefined;
private _targetDropdownContainer: HTMLElement | undefined;
private _extensionPickersLeftContainer: HTMLElement | undefined;
private _extensionPickersRightContainer: HTMLElement | undefined;
private _inputSlot: HTMLElement | undefined;
private _localModeContainer: HTMLElement | undefined;
private _localModeDropdownContainer: HTMLElement | undefined;
private _localModePickersContainer: HTMLElement | undefined;
private _localMode: 'workspace' | 'worktree' = 'worktree';
private readonly _folderPicker: FolderPicker;
private _folderPickerContainer: HTMLElement | undefined;
private readonly _pickerWidgets = new Map<string, ChatSessionPickerActionItem | SearchableOptionPickerActionItem>();
@@ -227,56 +113,30 @@ class NewChatWidget extends Disposable {
@IConfigurationService private readonly configurationService: IConfigurationService,
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IContextMenuService private readonly contextMenuService: IContextMenuService,
@IContextMenuService contextMenuService: IContextMenuService,
@ILogService private readonly logService: ILogService,
@IHoverService _hoverService: IHoverService,
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
@ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
@IGitService private readonly gitService: IGitService,
) {
super();
this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments));
this._folderPicker = this._register(this.instantiationService.createInstance(FolderPicker));
this._targetConfig = this._register(new TargetConfig(options.targetConfig));
this._targetPicker = this._register(new SessionTargetPicker(options.allowedTargets, options.defaultTarget));
this._isolationModePicker = this._register(this.instantiationService.createInstance(IsolationModePicker));
this._branchPicker = this._register(this.instantiationService.createInstance(BranchPicker));
this._options = options;
// When folder changes, notify extension and re-render pickers
this._register(this._folderPicker.onDidSelectFolder(() => {
this._notifyFolderSelection();
this._renderExtensionPickers(true);
// When target changes, create new session
this._register(this._targetPicker.onDidChangeTarget((target) => {
this._createNewSession();
const isLocal = target === AgentSessionProviders.Background;
this._isolationModePicker.setVisible(isLocal);
this._branchPicker.setVisible(isLocal);
}));
// When target changes, regenerate pending resource
this._register(this._targetConfig.onDidChangeSelectedTarget(() => {
this._generatePendingSessionResource();
this._notifyFolderSelection();
this._renderExtensionPickers(true);
this._renderLocalModePicker();
}));
this._register(this._targetConfig.onDidChangeAllowedTargets(() => {
if (this._targetDropdownContainer) {
dom.clearNode(this._targetDropdownContainer);
this._renderTargetDropdown(this._targetDropdownContainer);
}
this._renderExtensionPickers(true);
}));
// Listen for option group changes to re-render pickers
this._register(this.chatSessionsService.onDidChangeOptionGroups(() => this._renderExtensionPickers()));
// React to chat session option changes
this._register(this.chatSessionsService.onDidChangeSessionOptions((e: URI | undefined) => {
if (this._pendingSessionResource && isEqual(this._pendingSessionResource, e)) {
this._syncOptionsFromSession(this._pendingSessionResource);
this._renderExtensionPickers();
}
}));
const workspaceFolderCountKey = new Set([WorkspaceFolderCountContext.key]);
this._register(this.contextKeyService.onDidChangeContext(e => {
if (e.affectsSome(workspaceFolderCountKey)) {
this._renderExtensionPickers(true);
}
if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) {
this._renderExtensionPickers(true);
}
@@ -314,11 +174,17 @@ class NewChatWidget extends Disposable {
this._createBottomToolbar(inputArea);
this._inputSlot.appendChild(inputArea);
// Local mode picker (below the input, shown when Local is selected)
this._localModeContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-local-mode'));
this._localModeDropdownContainer = dom.append(this._localModeContainer, dom.$('.sessions-chat-local-mode-left'));
dom.append(this._localModeContainer, dom.$('.sessions-chat-local-mode-spacer'));
this._localModePickersContainer = dom.append(this._localModeContainer, dom.$('.sessions-chat-local-mode-right'));
// Isolation mode and branch pickers (below the input, shown when Local target is selected)
const isolationContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-local-mode'));
this._isolationModePicker.render(isolationContainer);
dom.append(isolationContainer, dom.$('.sessions-chat-local-mode-spacer'));
const branchContainer = dom.append(isolationContainer, dom.$('.sessions-chat-local-mode-right'));
this._branchPicker.render(branchContainer);
// Set initial visibility based on default target
const isLocal = this._targetPicker.selectedTarget === AgentSessionProviders.Background;
this._isolationModePicker.setVisible(isLocal);
this._branchPicker.setVisible(isLocal);
// Render target buttons & extension pickers
this._renderOptionGroupPickers();
@@ -326,49 +192,75 @@ class NewChatWidget extends Disposable {
// Initialize model picker
this._initDefaultModel();
// Generate pending resource for option changes
this._generatePendingSessionResource();
// Render local mode picker
this._renderLocalModePicker();
// Create initial session
this._createNewSession();
// Reveal
welcomeElement.classList.add('revealed');
}
private _getEffectiveTarget(): AgentSessionProviders | undefined {
const target = this._targetConfig.selectedTarget.get();
if (target === AgentSessionProviders.Local && this._localMode === 'worktree') {
return AgentSessionProviders.Background;
}
return target;
}
private readonly _pendingSessionResources = new Map<string, URI>();
private _generatePendingSessionResource(): void {
const target = this._getEffectiveTarget();
if (!target || target === AgentSessionProviders.Local) {
this._pendingSessionResource = undefined;
return;
}
// Reuse existing pending resource for the same target type
const existing = this._pendingSessionResources.get(target);
if (existing) {
this._pendingSessionResource = existing;
return;
}
this._pendingSessionResource = getResourceForNewChatSession({
private async _createNewSession(): Promise<void> {
const target = this._targetPicker.selectedTarget;
const defaultRepoUri = this._folderPicker.selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri;
const resource = getResourceForNewChatSession({
type: target,
position: this._options.sessionPosition ?? ChatSessionPosition.Sidebar,
displayName: '',
});
this._pendingSessionResources.set(target, this._pendingSessionResource);
this.sessionsManagementService.createNewPendingSession(this._pendingSessionResource,)
.catch((err) => this.logService.trace('Failed to create pending session:', err));
try {
const session = await this.sessionsManagementService.createNewSessionForTarget(target, resource, defaultRepoUri);
this._setNewSession(session);
} catch (e) {
this.logService.error('Failed to create new session:', e);
}
}
private _setNewSession(session: INewSession): void {
this._newSession.value = session;
// Wire pickers to the new session
this._folderPicker.setNewSession(session);
this._isolationModePicker.setNewSession(session);
this._branchPicker.setNewSession(session);
// Set the current model on the session
const currentModel = this._currentLanguageModel.get();
if (currentModel) {
session.setModelId(currentModel.identifier);
}
// Open repository for the session's repoUri
if (session.repoUri) {
this._openRepository(session.repoUri);
}
// Render extension pickers for the new session
this._renderExtensionPickers(true);
// Listen for session changes
this._newSessionListener.value = session.onDidChange((changeType) => {
if (changeType === 'repoUri' && session.repoUri) {
this._openRepository(session.repoUri);
}
if (changeType === 'isolationMode') {
this._branchPicker.setVisible(session.isolationMode === 'worktree');
}
if (changeType === 'options') {
this._syncOptionsFromSession(session.resource);
this._renderExtensionPickers();
}
});
}
private _openRepository(folderUri: URI): void {
this.gitService.openRepository(folderUri).then(repository => {
this._isolationModePicker.setRepository(repository);
this._branchPicker.setRepository(repository);
}).catch(() => {
this._isolationModePicker.setRepository(undefined);
this._branchPicker.setRepository(undefined);
});
}
// --- Editor ---
@@ -435,9 +327,9 @@ class NewChatWidget extends Disposable {
* Local targets use the workspace folder; cloud targets construct a github-remote-file:// URI.
*/
private _getContextFolderUri(): URI | undefined {
const target = this._getEffectiveTarget();
const target = this._targetPicker.selectedTarget;
if (!target || target === AgentSessionProviders.Local || target === AgentSessionProviders.Background) {
if (target === AgentSessionProviders.Background) {
return this._folderPicker.selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri;
}
@@ -481,10 +373,11 @@ class NewChatWidget extends Disposable {
currentModel: this._currentLanguageModel,
setModel: (model: ILanguageModelChatMetadataAndIdentifier) => {
this._currentLanguageModel.set(model, undefined);
this._newSession.value?.setModelId(model.identifier);
},
getModels: () => this._getAvailableModels(),
canManageModels: () => true,
showCuratedModels: () => this._localMode === 'workspace',
showCuratedModels: () => false,
};
const pickerOptions: IChatInputPickerOptions = {
@@ -530,9 +423,6 @@ class NewChatWidget extends Disposable {
if (!model.metadata.isUserSelectable) {
return false;
}
if (model.metadata.targetChatSessionType === AgentSessionProviders.Background) {
return false;
}
return true;
}
@@ -543,15 +433,15 @@ class NewChatWidget extends Disposable {
return;
}
this._disposePickerWidgets();
this._clearExtensionPickers();
dom.clearNode(this._pickersContainer);
const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers'));
// Left half: target switcher (right-justified within its half)
const leftHalf = dom.append(pickersRow, dom.$('.sessions-chat-pickers-left-half'));
this._targetDropdownContainer = dom.append(leftHalf, dom.$('.sessions-chat-dropdown-wrapper'));
this._renderTargetDropdown(this._targetDropdownContainer);
const targetDropdownContainer = dom.append(leftHalf, dom.$('.sessions-chat-dropdown-wrapper'));
this._targetPicker.render(targetDropdownContainer);
// Right half: separator + pickers (left-justified within its half)
const rightHalf = dom.append(pickersRow, dom.$('.sessions-chat-pickers-right-half'));
@@ -565,140 +455,17 @@ class NewChatWidget extends Disposable {
this._renderExtensionPickers();
}
private _renderTargetDropdown(container: HTMLElement): void {
const allowed = this._targetConfig.allowedTargets.get();
if (allowed.size === 0) {
return;
}
const activeType = this._targetConfig.selectedTarget.get() ?? AgentSessionProviders.Local;
const targets = [AgentSessionProviders.Local, AgentSessionProviders.Cloud].filter(t => allowed.has(t));
const activeIndex = targets.indexOf(activeType);
const radio = new Radio({
items: targets.map(target => ({
text: getAgentSessionProviderName(target),
isActive: target === activeType,
})),
});
this._welcomeContentDisposables.add(radio);
container.appendChild(radio.domNode);
if (activeIndex >= 0) {
radio.setActiveItem(activeIndex);
}
this._welcomeContentDisposables.add(radio.onDidSelect(index => {
this._targetConfig.setSelectedTarget(targets[index]);
}));
}
// --- Local mode picker (Workspace / Worktree) ---
private readonly _localModeDisposables = this._register(new DisposableStore());
private _renderLocalModePicker(): void {
if (!this._localModeContainer || !this._localModeDropdownContainer || !this._localModePickersContainer) {
return;
}
this._localModeDisposables.clear();
dom.clearNode(this._localModeDropdownContainer);
dom.clearNode(this._localModePickersContainer);
const selectedTarget = this._targetConfig.selectedTarget.get();
if (selectedTarget !== AgentSessionProviders.Local) {
this._localModeContainer.style.visibility = 'hidden';
return;
}
this._localModeContainer.style.visibility = '';
// Dropdown button for Workspace / Worktree
const modeLabel = this._localMode === 'workspace'
? localize('localMode.workspace', "Workspace")
: localize('localMode.worktree', "Worktree");
const modeIcon = this._localMode === 'workspace' ? Codicon.folder : Codicon.worktree;
const modeAction = toAction({ id: 'localMode', label: modeLabel, run: () => { } });
const modeDropdown = this._localModeDisposables.add(new LabeledDropdownMenuActionViewItem(
modeAction,
{
getActions: () => [
toAction({
id: 'localMode.workspace',
label: localize('localMode.workspace', "Workspace"),
checked: this._localMode === 'workspace',
run: () => this._setLocalMode('workspace'),
}),
toAction({
id: 'localMode.worktree',
label: localize('localMode.worktree', "Worktree"),
checked: this._localMode === 'worktree',
run: () => this._setLocalMode('worktree'),
}),
],
},
this.contextMenuService,
{ classNames: [...ThemeIcon.asClassNameArray(modeIcon)] }
));
const modeSlot = dom.append(this._localModeDropdownContainer, dom.$('.sessions-chat-picker-slot'));
modeDropdown.render(modeSlot);
// Render pickers in the right side
this._renderLocalModePickers();
}
private _setLocalMode(mode: 'workspace' | 'worktree'): void {
if (this._localMode !== mode) {
this._localMode = mode;
this._generatePendingSessionResource();
this._notifyFolderSelection();
this._renderLocalModePicker();
}
}
private _notifyFolderSelection(): void {
this._selectedOptions.clear();
if (!this._pendingSessionResource) {
return;
}
const folderUri = this._folderPicker.selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri;
if (folderUri) {
this.chatSessionsService.notifySessionOptionsChange(
this._pendingSessionResource,
[{ optionId: 'repository', value: folderUri.fsPath }]
).catch((err) => this.logService.error('Failed to notify extension of folder selection:', err));
}
}
private _renderLocalModePickers(): void {
if (!this._localModePickersContainer) {
return;
}
dom.clearNode(this._localModePickersContainer);
if (this._localMode === 'worktree') {
// Worktree mode: render extension pickers for Background provider
this._renderExtensionPickersInContainer(this._localModePickersContainer, AgentSessionProviders.Background);
}
}
// --- Welcome: Extension option pickers ---
// --- Welcome: Extension option pickers (Cloud target only) ---
private _renderExtensionPickers(force?: boolean): void {
if (!this._extensionPickersRightContainer) {
return;
}
const activeSessionType = this._getEffectiveTarget();
if (!activeSessionType) {
this._clearExtensionPickers();
return;
}
const activeSessionType = this._targetPicker.selectedTarget;
// For Local target, show folder picker in top row and handle bottom row
if (this._targetConfig.selectedTarget.get() === AgentSessionProviders.Local) {
// Extension pickers are only shown for Cloud target
if (activeSessionType === AgentSessionProviders.Background) {
this._clearExtensionPickers();
if (this._folderPickerContainer) {
this._folderPickerContainer.style.display = '';
@@ -706,7 +473,6 @@ class NewChatWidget extends Disposable {
if (this._extensionPickersLeftContainer) {
this._extensionPickersLeftContainer.style.display = 'block';
}
this._renderLocalModePicker();
return;
}
@@ -742,16 +508,6 @@ class NewChatWidget extends Disposable {
return;
}
visibleGroups.sort((a, b) => {
// Repo/folder pickers first, then others
const aRepo = isRepoOrFolderGroup(a) ? 0 : 1;
const bRepo = isRepoOrFolderGroup(b) ? 0 : 1;
if (aRepo !== bRepo) {
return aRepo - bRepo;
}
return (a.when ? 1 : 0) - (b.when ? 1 : 0);
});
if (!force && this._pickerWidgets.size === visibleGroups.length) {
const allMatch = visibleGroups.every(g => this._pickerWidgets.has(g.id));
if (allMatch) {
@@ -761,7 +517,6 @@ class NewChatWidget extends Disposable {
this._clearExtensionPickers();
// Show the separator between target switcher and extension pickers
if (this._extensionPickersLeftContainer) {
this._extensionPickersLeftContainer.style.display = 'block';
}
@@ -783,12 +538,7 @@ class NewChatWidget extends Disposable {
this._updateOptionContextKey(optionGroup.id, option.id);
emitter.fire(option);
if (this._pendingSessionResource) {
this.chatSessionsService.notifySessionOptionsChange(
this._pendingSessionResource,
[{ optionId: optionGroup.id, value: option }]
).catch((err) => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err));
}
this._newSession.value?.setOption(optionGroup.id, option);
this._renderExtensionPickers(true);
},
@@ -796,7 +546,7 @@ class NewChatWidget extends Disposable {
const groups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType);
return groups?.find((g: { id: string }) => g.id === optionGroup.id);
},
getSessionResource: () => this._pendingSessionResource,
getSessionResource: () => this._newSession.value?.resource,
};
const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } });
@@ -808,77 +558,7 @@ class NewChatWidget extends Disposable {
this._pickerWidgetDisposables.add(widget);
this._pickerWidgets.set(optionGroup.id, widget);
// All pickers go to the right
const targetContainer = this._extensionPickersRightContainer!;
const slot = dom.append(targetContainer, dom.$('.sessions-chat-picker-slot'));
widget.render(slot);
}
}
private _renderExtensionPickersInContainer(container: HTMLElement, sessionType: AgentSessionProviders): void {
const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(sessionType);
if (!optionGroups || optionGroups.length === 0) {
return;
}
const visibleGroups: IChatSessionProviderOptionGroup[] = [];
for (const group of optionGroups) {
if (isModelOptionGroup(group)) {
continue;
}
if (group.id === 'repository') {
continue;
}
const hasItems = group.items.length > 0 || (group.commands || []).length > 0 || !!group.searchable;
const passesWhenClause = this._evaluateOptionGroupVisibility(group);
if (hasItems && passesWhenClause) {
visibleGroups.push(group);
}
}
for (const optionGroup of visibleGroups) {
const initialItem = this._getDefaultOptionForGroup(optionGroup);
const initialState = { group: optionGroup, item: initialItem };
if (initialItem) {
this._updateOptionContextKey(optionGroup.id, initialItem.id);
}
const emitter = this._getOrCreateOptionEmitter(optionGroup.id);
const itemDelegate: IChatSessionPickerDelegate = {
getCurrentOption: () => this._selectedOptions.get(optionGroup.id) ?? this._getDefaultOptionForGroup(optionGroup),
onDidChangeOption: emitter.event,
setOption: (option: IChatSessionProviderOptionItem) => {
this._selectedOptions.set(optionGroup.id, option);
this._updateOptionContextKey(optionGroup.id, option.id);
emitter.fire(option);
if (this._pendingSessionResource) {
this.chatSessionsService.notifySessionOptionsChange(
this._pendingSessionResource,
[{ optionId: optionGroup.id, value: option }]
).catch((err) => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err));
}
this._renderLocalModePickers();
},
getOptionGroup: () => {
const groups = this.chatSessionsService.getOptionGroupsForSessionType(sessionType);
return groups?.find((g: { id: string }) => g.id === optionGroup.id);
},
getSessionResource: () => this._pendingSessionResource,
};
const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } });
const widget = this.instantiationService.createInstance(
optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem,
action, initialState, itemDelegate
);
this._localModeDisposables.add(widget);
const slot = dom.append(container, dom.$('.sessions-chat-picker-slot'));
const slot = dom.append(this._extensionPickersRightContainer!, dom.$('.sessions-chat-picker-slot'));
widget.render(slot);
}
}
@@ -897,8 +577,8 @@ class NewChatWidget extends Disposable {
return selectedOption;
}
if (this._pendingSessionResource) {
const sessionOption = this.chatSessionsService.getSessionOption(this._pendingSessionResource, optionGroup.id);
if (this._newSession.value) {
const sessionOption = this.chatSessionsService.getSessionOption(this._newSession.value.resource, optionGroup.id);
if (!isString(sessionOption)) {
return sessionOption;
}
@@ -908,7 +588,7 @@ class NewChatWidget extends Disposable {
}
private _syncOptionsFromSession(sessionResource: URI): void {
const activeSessionType = this._getEffectiveTarget();
const activeSessionType = this._targetPicker.selectedTarget;
if (!activeSessionType) {
return;
}
@@ -959,12 +639,6 @@ class NewChatWidget extends Disposable {
return emitter;
}
private _disposePickerWidgets(): void {
this._pickerWidgetDisposables.clear();
this._pickerWidgets.clear();
this._optionEmitters.clear();
}
private _clearExtensionPickers(): void {
this._pickerWidgetDisposables.clear();
this._pickerWidgets.clear();
@@ -984,50 +658,23 @@ class NewChatWidget extends Disposable {
private _send(): void {
const query = this._editor.getModel()?.getValue().trim();
if (!query) {
const session = this._newSession.value;
if (!query || !session) {
return;
}
const target = this._getEffectiveTarget();
if (!target) {
this.logService.warn('ChatWelcomeWidget: No target selected, cannot create session');
return;
}
session.setQuery(query);
session.setAttachedContext(
this._contextAttachments.attachments.length > 0 ? [...this._contextAttachments.attachments] : undefined
);
const position = this._options.sessionPosition ?? ChatSessionPosition.Sidebar;
const resource = this._pendingSessionResource
?? getResourceForNewChatSession({ type: target, position, displayName: '' });
const contribution = target !== AgentSessionProviders.Local
? this.chatSessionsService.getChatSessionContribution(target)
: undefined;
const sendOptions: IChatSendRequestOptions = {
location: ChatAgentLocation.Chat,
userSelectedModelId: this._currentLanguageModel.get()?.identifier,
modeInfo: {
kind: ChatModeKind.Agent,
isBuiltin: true,
modeInstructions: undefined,
modeId: 'agent',
applyCodeBlockSuggestionId: undefined,
},
agentIdSilent: contribution?.type,
attachedContext: this._contextAttachments.attachments.length > 0 ? [...this._contextAttachments.attachments] : undefined,
};
const folderUri = this._folderPicker.selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri;
this._options.onSendRequest?.({
resource,
target,
query,
sendOptions,
selectedOptions: new Map(this._selectedOptions),
folderUri,
attachedContext: this._contextAttachments.attachments.length > 0 ? [...this._contextAttachments.attachments] : undefined,
});
this.sessionsManagementService.sendRequestForNewSession(
session.resource
).catch(e => this.logService.error('Failed to send request:', e));
// Clear sent session so a fresh one is created next time
this._newSession.clear();
this._newSessionListener.clear();
this._contextAttachments.clear();
}
@@ -1037,16 +684,12 @@ class NewChatWidget extends Disposable {
this._editor?.layout();
}
setVisible(_visible: boolean): void {
// no-op
}
focusInput(): void {
this._editor?.focus();
}
updateAllowedTargets(targets: AgentSessionProviders[]): void {
this._targetConfig.setAllowedTargets(targets);
this._targetPicker.updateAllowedTargets(targets);
}
}
@@ -1074,9 +717,7 @@ export class NewChatViewPane extends ViewPane {
@IOpenerService openerService: IOpenerService,
@IThemeService themeService: IThemeService,
@IHoverService hoverService: IHoverService,
@ISessionsManagementService private readonly activeSessionService: ISessionsManagementService,
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
@ILogService private readonly logService: ILogService,
) {
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
}
@@ -1087,15 +728,8 @@ export class NewChatViewPane extends ViewPane {
this._widget = this._register(this.instantiationService.createInstance(
NewChatWidget,
{
targetConfig: {
allowedTargets: this.computeAllowedTargets(),
defaultTarget: AgentSessionProviders.Local,
},
onSendRequest: (data) => {
this.activeSessionService.sendRequestForNewSession(
data.resource, data.query, data.sendOptions, data.selectedOptions, data.folderUri
).catch(e => this.logService.error('NewChatViewPane: Failed to open session and send request', e));
},
allowedTargets: this.computeAllowedTargets(),
defaultTarget: AgentSessionProviders.Background,
} satisfies INewChatWidgetOptions,
));
@@ -1108,7 +742,7 @@ export class NewChatViewPane extends ViewPane {
}
private computeAllowedTargets(): AgentSessionProviders[] {
const targets: AgentSessionProviders[] = [AgentSessionProviders.Local, AgentSessionProviders.Cloud];
const targets: AgentSessionProviders[] = [AgentSessionProviders.Background, AgentSessionProviders.Cloud];
return targets;
}
@@ -1124,7 +758,6 @@ export class NewChatViewPane extends ViewPane {
override setVisible(visible: boolean): void {
super.setVisible(visible);
this._widget?.setVisible(visible);
if (visible) {
this._widget?.focusInput();
}
@@ -1157,20 +790,3 @@ function isRepoOrFolderGroup(group: IChatSessionProviderOptionGroup): boolean {
nameLower === 'repository' || nameLower === 'repositories' ||
nameLower === 'folder' || nameLower === 'folders';
}
function getAgentSessionProviderName(provider: AgentSessionProviders): string {
switch (provider) {
case AgentSessionProviders.Local:
return localize('chat.session.providerLabel.local', "Local");
case AgentSessionProviders.Background:
return localize('chat.session.providerLabel.background', "Worktree");
case AgentSessionProviders.Cloud:
return localize('chat.session.providerLabel.cloud', "Cloud");
case AgentSessionProviders.Claude:
return 'Claude';
case AgentSessionProviders.Codex:
return 'Codex';
case AgentSessionProviders.Growth:
return 'Growth';
}
}
@@ -0,0 +1,221 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { isEqual } from '../../../../base/common/resources.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { IsolationMode } from './sessionTargetPicker.js';
import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
import { IActiveSessionItem } from '../../sessions/browser/sessionsManagementService.js';
import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options';
/**
* A new session represents a session being configured before the first
* request is sent. It holds the user's selections (repoUri, isolationMode)
* and fires a single event when any property changes.
*/
export interface INewSession extends IDisposable {
readonly resource: URI;
readonly target: AgentSessionProviders;
readonly activeSessionItem: IActiveSessionItem;
readonly repoUri: URI | undefined;
readonly isolationMode: IsolationMode;
readonly branch: string | undefined;
readonly modelId: string | undefined;
readonly query: string | undefined;
readonly attachedContext: IChatRequestVariableEntry[] | undefined;
readonly selectedOptions: ReadonlyMap<string, IChatSessionProviderOptionItem>;
readonly onDidChange: Event<NewSessionChangeType>;
setRepoUri(uri: URI): void;
setIsolationMode(mode: IsolationMode): void;
setBranch(branch: string | undefined): void;
setModelId(modelId: string | undefined): void;
setQuery(query: string): void;
setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void;
setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void;
}
const REPOSITORY_OPTION_ID = 'repository';
const BRANCH_OPTION_ID = 'branch';
const ISOLATION_OPTION_ID = 'isolation';
/**
* Local new session for Background agent sessions.
* Fires `onDidChange` for both `repoUri` and `isolationMode` changes.
* Notifies the extension service with session options for each property change.
*/
export class LocalNewSession extends Disposable implements INewSession {
private _repoUri: URI | undefined;
private _isolationMode: IsolationMode = 'worktree';
private _branch: string | undefined;
private _modelId: string | undefined;
private _query: string | undefined;
private _attachedContext: IChatRequestVariableEntry[] | undefined;
private readonly _onDidChange = this._register(new Emitter<NewSessionChangeType>());
readonly onDidChange: Event<NewSessionChangeType> = this._onDidChange.event;
readonly target = AgentSessionProviders.Background;
readonly selectedOptions = new Map<string, IChatSessionProviderOptionItem>();
get resource(): URI { return this.activeSessionItem.resource; }
get repoUri(): URI | undefined { return this._repoUri; }
get isolationMode(): IsolationMode { return this._isolationMode; }
get branch(): string | undefined { return this._branch; }
get modelId(): string | undefined { return this._modelId; }
get query(): string | undefined { return this._query; }
get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; }
constructor(
readonly activeSessionItem: IActiveSessionItem,
defaultRepoUri: URI | undefined,
private readonly chatSessionsService: IChatSessionsService,
private readonly logService: ILogService,
) {
super();
if (defaultRepoUri) {
this._repoUri = defaultRepoUri;
this.setOption(REPOSITORY_OPTION_ID, defaultRepoUri.fsPath);
}
}
setRepoUri(uri: URI): void {
this._repoUri = uri;
this._isolationMode = 'workspace';
this._branch = undefined;
this._onDidChange.fire('repoUri');
this.setOption(REPOSITORY_OPTION_ID, uri.fsPath);
}
setIsolationMode(mode: IsolationMode): void {
if (this._isolationMode !== mode) {
this._isolationMode = mode;
this._onDidChange.fire('isolationMode');
this.setOption(ISOLATION_OPTION_ID, mode);
}
}
setBranch(branch: string | undefined): void {
if (this._branch !== branch) {
this._branch = branch;
this._onDidChange.fire('branch');
this.setOption(BRANCH_OPTION_ID, branch ?? '');
}
}
setModelId(modelId: string | undefined): void {
this._modelId = modelId;
}
setQuery(query: string): void {
this._query = query;
}
setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void {
this._attachedContext = context;
}
setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void {
if (typeof value === 'string') {
this.selectedOptions.set(optionId, { id: value, name: value });
} else {
this.selectedOptions.set(optionId, value);
}
this.chatSessionsService.notifySessionOptionsChange(
this.resource,
[{ optionId, value }]
).catch((err) => this.logService.error(`Failed to notify session option ${optionId} change:`, err));
}
}
/**
* Remote new session for Cloud agent sessions.
* Fires `onDidChange` and notifies the extension service when `repoUri` changes.
* Ignores `isolationMode` (not relevant for cloud).
*/
export class RemoteNewSession extends Disposable implements INewSession {
private _repoUri: URI | undefined;
private _isolationMode: IsolationMode = 'worktree';
private _modelId: string | undefined;
private _query: string | undefined;
private _attachedContext: IChatRequestVariableEntry[] | undefined;
private readonly _onDidChange = this._register(new Emitter<NewSessionChangeType>());
readonly onDidChange: Event<NewSessionChangeType> = this._onDidChange.event;
readonly selectedOptions = new Map<string, IChatSessionProviderOptionItem>();
get resource(): URI { return this.activeSessionItem.resource; }
get repoUri(): URI | undefined { return this._repoUri; }
get isolationMode(): IsolationMode { return this._isolationMode; }
get branch(): string | undefined { return undefined; }
get modelId(): string | undefined { return this._modelId; }
get query(): string | undefined { return this._query; }
get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; }
constructor(
readonly activeSessionItem: IActiveSessionItem,
readonly target: AgentSessionProviders,
private readonly chatSessionsService: IChatSessionsService,
private readonly logService: ILogService,
) {
super();
// Listen for extension-driven option group and session option changes
this._register(this.chatSessionsService.onDidChangeOptionGroups(() => {
this._onDidChange.fire('options');
}));
this._register(this.chatSessionsService.onDidChangeSessionOptions((e: URI | undefined) => {
if (isEqual(this.resource, e)) {
this._onDidChange.fire('options');
}
}));
}
setRepoUri(uri: URI): void {
this._repoUri = uri;
this._onDidChange.fire('repoUri');
this.setOption('repository', uri.fsPath);
}
setIsolationMode(_mode: IsolationMode): void {
// No-op for remote sessions — isolation mode is not relevant
}
setBranch(_branch: string | undefined): void {
// No-op for remote sessions — branch is not relevant
}
setModelId(modelId: string | undefined): void {
this._modelId = modelId;
}
setQuery(query: string): void {
this._query = query;
}
setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void {
this._attachedContext = context;
}
setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void {
if (typeof value !== 'string') {
this.selectedOptions.set(optionId, value);
}
this._onDidChange.fire('options');
this.chatSessionsService.notifySessionOptionsChange(
this.resource,
[{ optionId, value }]
).catch((err) => this.logService.error(`Failed to notify extension of ${optionId} change:`, err));
}
}
@@ -0,0 +1,274 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as dom from '../../../../base/browser/dom.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { toAction } from '../../../../base/common/actions.js';
import { Radio } from '../../../../base/browser/ui/radio/radio.js';
import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { localize } from '../../../../nls.js';
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js';
import { INewSession } from './newSession.js';
/**
* A dropdown menu action item that shows an icon, a text label, and a chevron.
*/
class LabeledDropdownMenuActionViewItem extends DropdownMenuActionViewItem {
protected override renderLabel(element: HTMLElement): null {
const classNames = typeof this.options.classNames === 'string'
? this.options.classNames.split(/\s+/g).filter(s => !!s)
: (this.options.classNames ?? []);
if (classNames.length > 0) {
const icon = dom.append(element, dom.$('span'));
icon.classList.add('codicon', ...classNames);
}
const label = dom.append(element, dom.$('span.sessions-chat-dropdown-label'));
label.textContent = this._action.label;
dom.append(element, renderIcon(Codicon.chevronDown));
return null;
}
}
// #region --- Session Target Picker ---
/**
* A self-contained widget for selecting the session target (Local vs Cloud).
* Encapsulates state, events, and rendering. Can be placed anywhere in the view.
*/
export class SessionTargetPicker extends Disposable {
private _selectedTarget: AgentSessionProviders;
private _allowedTargets: AgentSessionProviders[];
private readonly _onDidChangeTarget = this._register(new Emitter<AgentSessionProviders>());
readonly onDidChangeTarget: Event<AgentSessionProviders> = this._onDidChangeTarget.event;
private readonly _renderDisposables = this._register(new DisposableStore());
private _container: HTMLElement | undefined;
get selectedTarget(): AgentSessionProviders {
return this._selectedTarget;
}
constructor(
allowedTargets: AgentSessionProviders[],
defaultTarget: AgentSessionProviders,
) {
super();
this._allowedTargets = allowedTargets;
this._selectedTarget = allowedTargets.includes(defaultTarget)
? defaultTarget
: allowedTargets[0];
}
/**
* Renders the target radio (Local / Cloud) into the given container.
*/
render(container: HTMLElement): void {
this._container = container;
this._renderRadio();
}
updateAllowedTargets(targets: AgentSessionProviders[]): void {
if (targets.length === 0) {
return;
}
this._allowedTargets = targets;
if (!targets.includes(this._selectedTarget)) {
this._selectedTarget = targets[0];
this._onDidChangeTarget.fire(this._selectedTarget);
}
if (this._container) {
this._renderRadio();
}
}
private _renderRadio(): void {
if (!this._container) {
return;
}
this._renderDisposables.clear();
dom.clearNode(this._container);
if (this._allowedTargets.length === 0) {
return;
}
const targets = [AgentSessionProviders.Background, AgentSessionProviders.Cloud].filter(t => this._allowedTargets.includes(t));
const activeIndex = targets.indexOf(this._selectedTarget);
const radio = new Radio({
items: targets.map(target => ({
text: getTargetLabel(target),
isActive: target === this._selectedTarget,
})),
});
this._renderDisposables.add(radio);
this._container.appendChild(radio.domNode);
if (activeIndex >= 0) {
radio.setActiveItem(activeIndex);
}
this._renderDisposables.add(radio.onDidSelect(index => {
const target = targets[index];
if (this._selectedTarget !== target) {
this._selectedTarget = target;
this._onDidChangeTarget.fire(target);
}
}));
}
}
function getTargetLabel(provider: AgentSessionProviders): string {
switch (provider) {
case AgentSessionProviders.Local:
case AgentSessionProviders.Background:
return localize('chat.session.providerLabel.local', "Local");
case AgentSessionProviders.Cloud:
return localize('chat.session.providerLabel.cloud', "Cloud");
case AgentSessionProviders.Claude:
return 'Claude';
case AgentSessionProviders.Codex:
return 'Codex';
case AgentSessionProviders.Growth:
return 'Growth';
}
}
// #endregion
// #region --- Isolation Mode Picker ---
export type IsolationMode = 'worktree' | 'workspace';
/**
* A self-contained widget for selecting the isolation mode (Worktree vs Folder).
* Encapsulates state, events, and rendering. Can be placed anywhere in the view.
*/
export class IsolationModePicker extends Disposable {
private _isolationMode: IsolationMode = 'worktree';
private _newSession: INewSession | undefined;
private _repository: IGitRepository | undefined;
private readonly _onDidChange = this._register(new Emitter<IsolationMode>());
readonly onDidChange: Event<IsolationMode> = this._onDidChange.event;
private readonly _renderDisposables = this._register(new DisposableStore());
private _container: HTMLElement | undefined;
private _dropdownContainer: HTMLElement | undefined;
get isolationMode(): IsolationMode {
return this._isolationMode;
}
constructor(
@IContextMenuService private readonly contextMenuService: IContextMenuService,
) {
super();
}
/**
* Sets the pending session that this picker writes to.
*/
setNewSession(session: INewSession | undefined): void {
this._newSession = session;
}
/**
* Sets the git repository. When undefined, worktree option is hidden
* and isolation mode falls back to 'workspace'.
*/
setRepository(repository: IGitRepository | undefined): void {
this._repository = repository;
if (repository) {
this._setMode('worktree');
} else if (this._isolationMode === 'worktree') {
this._setMode('workspace');
}
this._renderDropdown();
}
/**
* Renders the isolation mode dropdown into the given container.
*/
render(container: HTMLElement): void {
this._container = container;
this._dropdownContainer = dom.append(container, dom.$('.sessions-chat-local-mode-left'));
this._renderDropdown();
}
/**
* Shows or hides the picker.
*/
setVisible(visible: boolean): void {
if (this._container) {
this._container.style.visibility = visible ? '' : 'hidden';
}
}
private _renderDropdown(): void {
if (!this._dropdownContainer) {
return;
}
this._renderDisposables.clear();
dom.clearNode(this._dropdownContainer);
const modeLabel = this._isolationMode === 'worktree'
? localize('isolationMode.worktree', "Worktree")
: localize('isolationMode.folder', "Folder");
const modeIcon = this._isolationMode === 'worktree' ? Codicon.worktree : Codicon.folder;
const isDisabled = !this._repository;
const modeAction = toAction({ id: 'isolationMode', label: modeLabel, run: () => { } });
const modeDropdown = this._renderDisposables.add(new LabeledDropdownMenuActionViewItem(
modeAction,
{
getActions: () => isDisabled ? [] : [
toAction({
id: 'isolationMode.worktree',
label: localize('isolationMode.worktree', "Worktree"),
checked: this._isolationMode === 'worktree',
run: () => this._setMode('worktree'),
}),
toAction({
id: 'isolationMode.folder',
label: localize('isolationMode.folder', "Folder"),
checked: this._isolationMode === 'workspace',
run: () => this._setMode('workspace'),
}),
],
},
this.contextMenuService,
{ classNames: [...ThemeIcon.asClassNameArray(modeIcon)] }
));
const modeSlot = dom.append(this._dropdownContainer, dom.$('.sessions-chat-picker-slot'));
modeDropdown.render(modeSlot);
modeSlot.classList.toggle('disabled', isDisabled);
}
private _setMode(mode: IsolationMode): void {
if (this._isolationMode !== mode) {
this._isolationMode = mode;
this._newSession?.setIsolationMode(mode);
this._onDidChange.fire(mode);
this._renderDropdown();
}
}
}
// #endregion
@@ -16,15 +16,15 @@ import { ChatViewId, ChatViewPaneTarget, IChatWidgetService } from '../../../../
import { ChatViewPane } from '../../../../workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.js';
import { IChatSessionItem, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js';
import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js';
import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js';
import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
import { LocalChatSessionUri } from '../../../../workbench/contrib/chat/common/model/chatUri.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js';
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
import { INewSession, LocalNewSession, RemoteNewSession } from '../../chat/browser/newSession.js';
export const IsNewChatSessionContext = new RawContextKey<boolean>('isNewChatSession', true);
@@ -81,10 +81,16 @@ export interface ISessionsManagementService {
createNewPendingSession(pendingSessionResource: URI): Promise<IActiveSessionItem>;
/**
* Open a new session, apply options, and send the initial request.
* This is the main entry point for the new-chat welcome widget.
* Create a pending session object for the given target type.
* Local sessions collect options locally; remote sessions notify the extension.
*/
sendRequestForNewSession(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap<string, IChatSessionProviderOptionItem>, folderUri?: URI): Promise<void>;
createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise<INewSession>;
/**
* Open a new session, apply options, and send the initial request.
* Looks up the session by resource URI and builds send options from it.
*/
sendRequestForNewSession(sessionResource: URI): Promise<void>;
/**
* Commit files in a worktree and refresh the agent sessions model
@@ -102,6 +108,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa
private readonly _activeSession = observableValue<IActiveSessionItem | undefined>(this, undefined);
readonly activeSession: IObservable<IActiveSessionItem | undefined> = this._activeSession;
private readonly _newSessions = new Map<string, INewSession>();
private lastSelectedSession: URI | undefined;
private readonly isNewChatSessionContext: IContextKey<boolean>;
@@ -115,7 +122,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa
@ILogService private readonly logService: ILogService,
@IContextKeyService contextKeyService: IContextKeyService,
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
@IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService,
@IViewsService private readonly viewsService: IViewsService,
@ICommandService private readonly commandService: ICommandService,
) {
@@ -255,6 +261,19 @@ export class SessionsManagementService extends Disposable implements ISessionsMa
return activeSessionItem;
}
async createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise<INewSession> {
const activeSessionItem = await this.createNewPendingSession(sessionResource);
let newSession: INewSession;
if (target === AgentSessionProviders.Background || target === AgentSessionProviders.Local) {
newSession = new LocalNewSession(activeSessionItem, defaultRepoUri, this.chatSessionsService, this.logService);
} else {
newSession = new RemoteNewSession(activeSessionItem, target, this.chatSessionsService, this.logService);
}
this._newSessions.set(newSession.resource.toString(), newSession);
return newSession;
}
/**
* Open an existing agent session - set it as active and reveal it.
*/
@@ -307,39 +326,45 @@ export class SessionsManagementService extends Disposable implements ISessionsMa
this._activeSession.set(activeSessionItem, undefined);
}
async sendRequestForNewSession(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap<string, IChatSessionProviderOptionItem>, folderUri?: URI): Promise<void> {
if (LocalChatSessionUri.isLocalSession(sessionResource)) {
await this.sendLocalSession(sessionResource, query, sendOptions, folderUri);
} else {
await this.sendCustomSession(sessionResource, query, sendOptions, selectedOptions);
}
}
/**
* Local sessions run directly through the ChatWidget.
* Set the workspace folder, open a fresh chat view, and submit via acceptInput.
*/
private async sendLocalSession(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, folderUri?: URI): Promise<void> {
if (folderUri) {
await this.workspaceEditingService.updateFolders(0, this.workspaceContextService.getWorkspace().folders.length, [{ uri: folderUri }]);
async sendRequestForNewSession(sessionResource: URI): Promise<void> {
const session = this._newSessions.get(sessionResource.toString());
if (!session) {
this.logService.error(`[SessionsManagementService] No new session found for resource: ${sessionResource.toString()}`);
return;
}
await this.openSession(sessionResource);
const widget = this.chatWidgetService.lastFocusedWidget;
if (widget) {
if (sendOptions.attachedContext?.length) {
widget.attachmentModel.addContext(...sendOptions.attachedContext);
}
widget.setInput(query);
widget.acceptInput(query);
const query = session.query;
if (!query) {
this.logService.error('[SessionsManagementService] No query set on session');
return;
}
const contribution = this.chatSessionsService.getChatSessionContribution(session.target);
const sendOptions: IChatSendRequestOptions = {
location: ChatAgentLocation.Chat,
userSelectedModelId: session.modelId,
modeInfo: {
kind: ChatModeKind.Agent,
isBuiltin: true,
modeInstructions: undefined,
modeId: 'agent',
applyCodeBlockSuggestionId: undefined,
},
agentIdSilent: contribution?.type,
attachedContext: session.attachedContext,
};
await this.sendCustomSession(sessionResource, query, sendOptions, session.selectedOptions);
// Clean up the session after sending
this._newSessions.delete(sessionResource.toString());
session.dispose();
}
/**
* Custom sessions (worktree, cloud, etc.) go through the chat service.
* Apply selected options, send the request, then wait for the extension
* to create an agent session so it appears in the sidebar.
* Options have already been applied via setOption during session configuration.
* Send the request, then wait for the extension to create an agent session.
*/
private async sendCustomSession(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap<string, IChatSessionProviderOptionItem>): Promise<void> {
// 1. Open the session - loads the model and shows the ChatViewPane