diff --git a/src/vs/sessions/contrib/chat/browser/branchPicker.ts b/src/vs/sessions/contrib/chat/browser/branchPicker.ts new file mode 100644 index 00000000000..3865ca57750 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/branchPicker.ts @@ -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()); + readonly onDidChange: Event = 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 { + 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 = { + 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( + '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[] { + 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); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/folderPicker.ts b/src/vs/sessions/contrib/chat/browser/folderPicker.ts index 034ac24e467..dddc95aed2b 100644 --- a/src/vs/sessions/contrib/chat/browser/folderPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/folderPicker.ts @@ -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); } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index 75d90b5f97d..a6987292c9b 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -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; diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 10dc60a3800..431e7e953d2 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -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>; - readonly selectedTarget: IObservable; - readonly onDidChangeSelectedTarget: Event; - readonly onDidChangeAllowedTargets: Event>; - setSelectedTarget(target: AgentSessionProviders): void; -} - -export interface ITargetConfigOptions { - allowedTargets: AgentSessionProviders[]; - defaultTarget?: AgentSessionProviders; -} - -class TargetConfig extends Disposable implements ITargetConfig { - - private readonly _allowedTargets = observableValue>('allowedTargets', new Set()); - readonly allowedTargets: IObservable> = this._allowedTargets; - - private readonly _selectedTarget = observableValue('selectedTarget', undefined); - readonly selectedTarget: IObservable = this._selectedTarget; - - private readonly _onDidChangeSelectedTarget = this._register(new Emitter()); - readonly onDidChangeSelectedTarget = this._onDidChangeSelectedTarget.event; - - private readonly _onDidChangeAllowedTargets = this._register(new Emitter>()); - 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; - 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('currentLanguageModel', undefined); private readonly _modelPickerDisposable = this._register(new MutableDisposable()); - private _pendingSessionResource: URI | undefined; + + // Pending session + private readonly _newSession = this._register(new MutableDisposable()); + 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(); @@ -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(); - - 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 { + 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'; - } -} diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts new file mode 100644 index 00000000000..266a2c2e7f9 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -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; + readonly onDidChange: Event; + 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()); + readonly onDidChange: Event = this._onDidChange.event; + + readonly target = AgentSessionProviders.Background; + readonly selectedOptions = new Map(); + + 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()); + readonly onDidChange: Event = this._onDidChange.event; + + readonly selectedOptions = new Map(); + + 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)); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts new file mode 100644 index 00000000000..1d762632f9f --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts @@ -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()); + readonly onDidChangeTarget: Event = 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()); + readonly onDidChange: Event = 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 diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 8ac2ffd7a0c..d3a7e961119 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -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('isNewChatSession', true); @@ -81,10 +81,16 @@ export interface ISessionsManagementService { createNewPendingSession(pendingSessionResource: URI): Promise; /** - * 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, folderUri?: URI): Promise; + createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise; + + /** + * 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; /** * 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(this, undefined); readonly activeSession: IObservable = this._activeSession; + private readonly _newSessions = new Map(); private lastSelectedSession: URI | undefined; private readonly isNewChatSessionContext: IContextKey; @@ -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 { + 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, folderUri?: URI): Promise { - 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 { - if (folderUri) { - await this.workspaceEditingService.updateFolders(0, this.workspaceContextService.getWorkspace().folders.length, [{ uri: folderUri }]); + async sendRequestForNewSession(sessionResource: URI): Promise { + 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): Promise { // 1. Open the session - loads the model and shows the ChatViewPane