From 2a7e9c952c5a1357d8561b9478aa70e4f8305b57 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 18 Feb 2026 20:12:52 -0800 Subject: [PATCH 01/31] WIP --- .../browser/fileTreeView.contribution.ts | 73 +++ .../fileTreeView/browser/fileTreeView.ts | 589 ++++++++++++++++++ .../browser/media/fileTreeView.css | 36 ++ .../browser/sessionRepoFileSystemProvider.ts | 293 +++++++++ src/vs/sessions/sessions.desktop.main.ts | 1 + 5 files changed, 992 insertions(+) create mode 100644 src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts create mode 100644 src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts create mode 100644 src/vs/sessions/contrib/fileTreeView/browser/media/fileTreeView.css create mode 100644 src/vs/sessions/contrib/fileTreeView/browser/sessionRepoFileSystemProvider.ts diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts new file mode 100644 index 0000000000000..dbd2e730838cf --- /dev/null +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize2 } from '../../../../nls.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { FILE_TREE_VIEW_CONTAINER_ID, FILE_TREE_VIEW_ID, FileTreeViewPane, FileTreeViewPaneContainer } from './fileTreeView.js'; +import { SessionRepoFileSystemProvider, SESSION_REPO_SCHEME } from './sessionRepoFileSystemProvider.js'; + +// --- Icons + +const fileTreeViewIcon = registerIcon('file-tree-view-icon', Codicon.repoClone, localize2('fileTreeViewIcon', 'View icon for the Files view.').value); + +// --- View Container Registration + +const viewContainersRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); + +const fileTreeViewContainer = viewContainersRegistry.registerViewContainer({ + id: FILE_TREE_VIEW_CONTAINER_ID, + title: localize2('files', 'Files'), + ctorDescriptor: new SyncDescriptor(FileTreeViewPaneContainer), + icon: fileTreeViewIcon, + order: 20, + hideIfEmpty: false, + windowVisibility: WindowVisibility.Sessions +}, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true }); + +// --- View Registration + +const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); + +viewsRegistry.registerViews([{ + id: FILE_TREE_VIEW_ID, + name: localize2('files', 'Files'), + containerIcon: fileTreeViewIcon, + ctorDescriptor: new SyncDescriptor(FileTreeViewPane), + canToggleVisibility: true, + canMoveView: true, + weight: 100, + order: 2, + windowVisibility: WindowVisibility.Sessions +}], fileTreeViewContainer); + +// --- Session Repo FileSystem Provider Registration + +class SessionRepoFileSystemProviderContribution extends Disposable { + + static readonly ID = 'workbench.contrib.sessionRepoFileSystemProvider'; + + constructor( + @IFileService fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + const provider = this._register(instantiationService.createInstance(SessionRepoFileSystemProvider)); + this._register(fileService.registerProvider(SESSION_REPO_SCHEME, provider)); + } +} + +registerWorkbenchContribution2( + SessionRepoFileSystemProviderContribution.ID, + SessionRepoFileSystemProviderContribution, + WorkbenchPhase.BlockStartup +); diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts new file mode 100644 index 0000000000000..3225eddfaadce --- /dev/null +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts @@ -0,0 +1,589 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/fileTreeView.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { IAsyncDataSource, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; +import { ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; +import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; +import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { FileKind, IFileService, IFileStat } from '../../../../platform/files/common/files.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { WorkbenchCompressibleAsyncDataTree } from '../../../../platform/list/browser/listService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; +import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ISessionsManagementService, IActiveSessionItem } from '../../sessions/browser/sessionsManagementService.js'; +import { SESSION_REPO_SCHEME } from './sessionRepoFileSystemProvider.js'; +import { basename } from '../../../../base/common/path.js'; +import { isEqual } from '../../../../base/common/resources.js'; + +const $ = dom.$; + +// --- Constants + +export const FILE_TREE_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.fileTreeContainer'; +export const FILE_TREE_VIEW_ID = 'workbench.view.agentSessions.fileTree'; + +// --- Tree Item + +interface IFileTreeItem { + readonly uri: URI; + readonly name: string; + readonly isDirectory: boolean; +} + +// --- Data Source + +class FileTreeDataSource implements IAsyncDataSource { + + constructor( + private readonly fileService: IFileService, + private readonly logService: ILogService, + ) { } + + hasChildren(element: URI | IFileTreeItem): boolean { + if (URI.isUri(element)) { + return true; // root + } + return element.isDirectory; + } + + async getChildren(element: URI | IFileTreeItem): Promise { + const uri = URI.isUri(element) ? element : element.uri; + + try { + const stat = await this.fileService.resolve(uri); + if (!stat.children) { + return []; + } + + return stat.children + .map((child: IFileStat): IFileTreeItem => ({ + uri: child.resource, + name: child.name, + isDirectory: child.isDirectory, + })) + .sort((a, b) => { + // Directories first, then alphabetical + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + } catch (e) { + this.logService.warn(`[FileTreeView] Error fetching children for ${uri.toString()}:`, e); + return []; + } + } +} + +// --- Delegate + +class FileTreeDelegate implements IListVirtualDelegate { + getHeight(): number { + return 22; + } + + getTemplateId(): string { + return FileTreeRenderer.TEMPLATE_ID; + } +} + +// --- Renderer + +interface IFileTreeTemplate { + readonly label: IResourceLabel; + readonly templateDisposables: DisposableStore; +} + +class FileTreeRenderer implements ICompressibleTreeRenderer { + static readonly TEMPLATE_ID = 'fileTreeRenderer'; + readonly templateId = FileTreeRenderer.TEMPLATE_ID; + + constructor( + private readonly labels: ResourceLabels, + @ILabelService private readonly labelService: ILabelService, + ) { } + + renderTemplate(container: HTMLElement): IFileTreeTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + return { label, templateDisposables }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IFileTreeTemplate): void { + const element = node.element; + templateData.label.element.style.display = 'flex'; + templateData.label.setFile(element.uri, { + fileKind: element.isDirectory ? FileKind.FOLDER : FileKind.FILE, + hidePath: true, + }); + } + + renderCompressedElements(node: ITreeNode, void>, _index: number, templateData: IFileTreeTemplate): void { + const compressed = node.element; + const lastElement = compressed.elements[compressed.elements.length - 1]; + + templateData.label.element.style.display = 'flex'; + + const label = compressed.elements.map(e => e.name); + templateData.label.setResource({ resource: lastElement.uri, name: label }, { + fileKind: lastElement.isDirectory ? FileKind.FOLDER : FileKind.FILE, + separator: this.labelService.getSeparator(lastElement.uri.scheme), + }); + } + + disposeTemplate(templateData: IFileTreeTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +// --- Compression Delegate + +class FileTreeCompressionDelegate { + isIncompressible(element: IFileTreeItem): boolean { + return !element.isDirectory; + } +} + +// --- View Pane + +export class FileTreeViewPane extends ViewPane { + + private bodyContainer: HTMLElement | undefined; + private welcomeContainer: HTMLElement | undefined; + private treeContainer: HTMLElement | undefined; + + private tree: WorkbenchCompressibleAsyncDataTree | undefined; + + private readonly renderDisposables = this._register(new DisposableStore()); + private readonly treeInputDisposable = this._register(new MutableDisposable()); + + private currentBodyHeight = 0; + private currentBodyWidth = 0; + + /** + * Observable that tracks the root URI for the file tree. + * - For background sessions: the worktree or repository local path + * - For cloud sessions: a session-repo:// URI derived from the session's repository metadata + * - For local sessions: the workspace folder + */ + private readonly treeRootUri: IObservable; + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @IFileService private readonly fileService: IFileService, + @IEditorService private readonly editorService: IEditorService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ILogService private readonly logService: ILogService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // Track active session changes AND session model updates (metadata/changes can arrive later) + const sessionsChangedSignal = observableFromEvent( + this, + this.agentSessionsService.model.onDidChangeSessions, + () => ({}), + ); + + this.treeRootUri = derived(reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + sessionsChangedSignal.read(reader); // re-evaluate when sessions data updates + return this.resolveTreeRoot(activeSession); + }); + } + + /** + * Determines the root URI for the file tree based on the active session type. + * Tries multiple data sources: IActiveSessionItem fields, agent session model metadata, + * and file change URIs as a last resort. + */ + private resolveTreeRoot(activeSession: IActiveSessionItem | undefined): URI | undefined { + if (!activeSession) { + return undefined; + } + + const sessionType = getChatSessionType(activeSession.resource); + + // 1. Try the direct worktree/repository fields from IActiveSessionItem + if (activeSession.worktree) { + this.logService.info(`[FileTreeView] Using worktree: ${activeSession.worktree.toString()}`); + return activeSession.worktree; + } + if (activeSession.repository && activeSession.repository.scheme === 'file') { + this.logService.info(`[FileTreeView] Using repository: ${activeSession.repository.toString()}`); + return activeSession.repository; + } + + // 2. Query the agent session model directly for metadata + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (agentSession?.metadata) { + const metadata = agentSession.metadata; + + // Background sessions: local paths (try multiple known metadata keys) + const workingDir = metadata.workingDirectoryPath as string | undefined; + if (workingDir) { + this.logService.info(`[FileTreeView] Using metadata.workingDirectoryPath: ${workingDir}`); + return URI.file(workingDir); + } + const worktreePath = metadata.worktreePath as string | undefined; + if (worktreePath) { + this.logService.info(`[FileTreeView] Using metadata.worktreePath: ${worktreePath}`); + return URI.file(worktreePath); + } + const repositoryPath = metadata.repositoryPath as string | undefined; + if (repositoryPath) { + this.logService.info(`[FileTreeView] Using metadata.repositoryPath: ${repositoryPath}`); + return URI.file(repositoryPath); + } + + // Cloud sessions: GitHub repo info in metadata + const repoUri = this.extractRepoUriFromMetadata(metadata); + if (repoUri) { + return repoUri; + } + } + + // 3. For cloud/remote sessions: try to infer repo from file change URIs + if (sessionType === AgentSessionProviders.Cloud || sessionType === AgentSessionProviders.Codex) { + const repoUri = this.inferRepoFromChanges(activeSession.resource); + if (repoUri) { + this.logService.info(`[FileTreeView] Inferred repo from changes: ${repoUri.toString()}`); + return repoUri; + } + } + + // 4. Try to parse the repository URI as a GitHub URL + if (activeSession.repository) { + const repoStr = activeSession.repository.toString(); + const parsed = this.parseGitHubUrl(repoStr); + if (parsed) { + this.logService.info(`[FileTreeView] Parsed repository URI as GitHub: ${parsed.owner}/${parsed.repo}`); + return URI.from({ + scheme: SESSION_REPO_SCHEME, + authority: 'github', + path: `/${parsed.owner}/${parsed.repo}/HEAD`, + }); + } + } + + this.logService.trace(`[FileTreeView] No tree root resolved for session ${activeSession.resource.toString()} (type: ${sessionType})`); + return undefined; + } + + /** + * Extracts a session-repo:// URI from session metadata, trying various known fields. + */ + private extractRepoUriFromMetadata(metadata: { readonly [key: string]: unknown }): URI | undefined { + // repositoryNwo: "owner/repo" + const repositoryNwo = metadata.repositoryNwo as string | undefined; + if (repositoryNwo && repositoryNwo.includes('/')) { + this.logService.info(`[FileTreeView] Using metadata.repositoryNwo: ${repositoryNwo}`); + return URI.from({ + scheme: SESSION_REPO_SCHEME, + authority: 'github', + path: `/${repositoryNwo}/HEAD`, + }); + } + + // repositoryUrl: "https://github.com/owner/repo" + const repositoryUrl = metadata.repositoryUrl as string | undefined; + if (repositoryUrl) { + const parsed = this.parseGitHubUrl(repositoryUrl); + if (parsed) { + this.logService.info(`[FileTreeView] Using metadata.repositoryUrl: ${repositoryUrl}`); + return URI.from({ + scheme: SESSION_REPO_SCHEME, + authority: 'github', + path: `/${parsed.owner}/${parsed.repo}/HEAD`, + }); + } + } + + // repository: could be "owner/repo" or a URL + const repository = metadata.repository as string | undefined; + if (repository) { + if (repository.includes('/') && !repository.includes(':')) { + // Looks like "owner/repo" + this.logService.info(`[FileTreeView] Using metadata.repository as nwo: ${repository}`); + return URI.from({ + scheme: SESSION_REPO_SCHEME, + authority: 'github', + path: `/${repository}/HEAD`, + }); + } + const parsed = this.parseGitHubUrl(repository); + if (parsed) { + this.logService.info(`[FileTreeView] Using metadata.repository as URL: ${repository}`); + return URI.from({ + scheme: SESSION_REPO_SCHEME, + authority: 'github', + path: `/${parsed.owner}/${parsed.repo}/HEAD`, + }); + } + } + + return undefined; + } + + /** + * Attempts to infer the repository from the session's file change URIs. + * Cloud sessions have changes with URIs that reveal the repository. + */ + private inferRepoFromChanges(sessionResource: URI): URI | undefined { + const agentSession = this.agentSessionsService.getSession(sessionResource); + if (!agentSession?.changes || !(agentSession.changes instanceof Array)) { + return undefined; + } + + for (const change of agentSession.changes) { + const fileUri = isIChatSessionFileChange2(change) + ? (change.modifiedUri ?? change.uri) + : change.modifiedUri; + + if (!fileUri) { + continue; + } + + const parsed = this.parseRepoFromFileUri(fileUri); + if (parsed) { + return URI.from({ + scheme: SESSION_REPO_SCHEME, + authority: 'github', + path: `/${parsed.owner}/${parsed.repo}/${parsed.ref}`, + }); + } + } + + return undefined; + } + + /** + * Tries to extract GitHub owner/repo from a file change URI. + * Handles various URI formats used by cloud sessions. + */ + private parseRepoFromFileUri(uri: URI): { owner: string; repo: string; ref: string } | undefined { + // Pattern: copilot-pr:/path?{json with owner, repo, commitSha, ...} + if (uri.scheme === 'copilot-pr' && uri.query) { + try { + const data = JSON.parse(decodeURIComponent(uri.query)); + if (typeof data.owner === 'string' && typeof data.repo === 'string') { + return { owner: data.owner, repo: data.repo, ref: data.commitSha ?? 'HEAD' }; + } + } catch { + // malformed query + } + } + + // Pattern: vscode-vfs://github/{owner}/{repo}/... + if (uri.authority === 'github' || uri.authority?.startsWith('github')) { + const parts = uri.path.split('/').filter(Boolean); + if (parts.length >= 2) { + return { owner: parts[0], repo: parts[1], ref: 'HEAD' }; + } + } + + // Pattern: github://{owner}/{repo}/... or github1s://{owner}/{repo}/... + if (uri.scheme === 'github' || uri.scheme === 'github1s') { + const parts = uri.authority ? uri.authority.split('/') : uri.path.split('/').filter(Boolean); + if (parts.length >= 2) { + return { owner: parts[0], repo: parts[1], ref: 'HEAD' }; + } + } + + // Pattern: https://github.com/{owner}/{repo}/... + return this.parseGitHubUrl(uri.toString()); + } + + private parseGitHubUrl(url: string): { owner: string; repo: string; ref: string } | undefined { + const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url) + || /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url); + return match ? { owner: match[1], repo: match[2], ref: 'HEAD' } : undefined; + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + this.bodyContainer = dom.append(container, $('.file-tree-view-body')); + + // Welcome message for empty state + this.welcomeContainer = dom.append(this.bodyContainer, $('.file-tree-welcome')); + const welcomeIcon = dom.append(this.welcomeContainer, $('.file-tree-welcome-icon')); + welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.repoClone)); + const welcomeMessage = dom.append(this.welcomeContainer, $('.file-tree-welcome-message')); + welcomeMessage.textContent = localize('fileTreeView.noRepository', "No repository available for this session."); + + // Tree container + this.treeContainer = dom.append(this.bodyContainer, $('.file-tree-container.show-file-icons')); + this._register(createFileIconThemableTreeContainerScope(this.treeContainer, this.themeService)); + + this._register(this.onDidChangeBodyVisibility(visible => { + if (visible) { + this.onVisible(); + } else { + this.renderDisposables.clear(); + } + })); + + if (this.isBodyVisible()) { + this.onVisible(); + } + } + + private onVisible(): void { + this.renderDisposables.clear(); + this.logService.info('[FileTreeView] onVisible called'); + + // Create tree if needed + if (!this.tree && this.treeContainer) { + const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility })); + const dataSource = new FileTreeDataSource(this.fileService, this.logService); + + this.tree = this.instantiationService.createInstance( + WorkbenchCompressibleAsyncDataTree, + 'FileTreeView', + this.treeContainer, + new FileTreeDelegate(), + new FileTreeCompressionDelegate(), + [this.instantiationService.createInstance(FileTreeRenderer, resourceLabels)], + dataSource, + { + accessibilityProvider: { + getAriaLabel: (element: IFileTreeItem) => element.name, + getWidgetAriaLabel: () => localize('fileTreeView', "File Tree") + }, + identityProvider: { + getId: (element: IFileTreeItem) => element.uri.toString() + }, + compressionEnabled: true, + collapseByDefault: (_e: IFileTreeItem) => true, + } + ); + } + + // Handle tree open events (open files in editor) + if (this.tree) { + this.renderDisposables.add(this.tree.onDidOpen(async (e) => { + if (!e.element || e.element.isDirectory) { + return; + } + + await this.editorService.openEditor({ + resource: e.element.uri, + options: e.editorOptions, + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + })); + } + + // React to active session changes + let lastRootUri: URI | undefined; + this.renderDisposables.add(autorun(reader => { + const rootUri = this.treeRootUri.read(reader); + const hasRoot = !!rootUri; + + dom.setVisibility(hasRoot, this.treeContainer!); + dom.setVisibility(!hasRoot, this.welcomeContainer!); + + if (this.tree && rootUri && !isEqual(rootUri, lastRootUri)) { + lastRootUri = rootUri; + this.updateTitle(basename(rootUri.path) || rootUri.toString()); + this.treeInputDisposable.clear(); + this.tree.setInput(rootUri).then(() => { + this.layoutTree(); + }); + } else if (!rootUri && lastRootUri) { + lastRootUri = undefined; + } + })); + } + + private layoutTree(): void { + if (!this.tree) { + return; + } + this.tree.layout(this.currentBodyHeight, this.currentBodyWidth); + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.currentBodyHeight = height; + this.currentBodyWidth = width; + this.layoutTree(); + } + + override focus(): void { + super.focus(); + this.tree?.domFocus(); + } + + override dispose(): void { + this.tree?.dispose(); + this.tree = undefined; + super.dispose(); + } +} + +// --- View Pane Container + +export class FileTreeViewPaneContainer extends ViewPaneContainer { + constructor( + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @ITelemetryService telemetryService: ITelemetryService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextMenuService contextMenuService: IContextMenuService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IConfigurationService configurationService: IConfigurationService, + @IExtensionService extensionService: IExtensionService, + @IWorkspaceContextService contextService: IWorkspaceContextService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @ILogService logService: ILogService, + ) { + super(FILE_TREE_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }, instantiationService, configurationService, layoutService, contextMenuService, telemetryService, extensionService, themeService, storageService, contextService, viewDescriptorService, logService); + } + + override create(parent: HTMLElement): void { + super.create(parent); + parent.classList.add('file-tree-viewlet'); + } +} diff --git a/src/vs/sessions/contrib/fileTreeView/browser/media/fileTreeView.css b/src/vs/sessions/contrib/fileTreeView/browser/media/fileTreeView.css new file mode 100644 index 0000000000000..3affb3068f9ff --- /dev/null +++ b/src/vs/sessions/contrib/fileTreeView/browser/media/fileTreeView.css @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.file-tree-view-body { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.file-tree-view-body .file-tree-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px; + gap: 8px; + color: var(--vscode-descriptionForeground); +} + +.file-tree-view-body .file-tree-welcome-icon { + font-size: 24px; + opacity: 0.6; +} + +.file-tree-view-body .file-tree-welcome-message { + font-size: 12px; + text-align: center; +} + +.file-tree-view-body .file-tree-container { + flex: 1; + overflow: hidden; +} diff --git a/src/vs/sessions/contrib/fileTreeView/browser/sessionRepoFileSystemProvider.ts b/src/vs/sessions/contrib/fileTreeView/browser/sessionRepoFileSystemProvider.ts new file mode 100644 index 0000000000000..e45e5e5c5f929 --- /dev/null +++ b/src/vs/sessions/contrib/fileTreeView/browser/sessionRepoFileSystemProvider.ts @@ -0,0 +1,293 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileDeleteOptions, IFileOverwriteOptions, IFileSystemProviderWithFileReadWriteCapability, IFileWriteOptions, IStat, createFileSystemProviderError, IFileChange } from '../../../../platform/files/common/files.js'; +import { IRequestService, asJson } from '../../../../platform/request/common/request.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; + +export const SESSION_REPO_SCHEME = 'session-repo'; + +/** + * GitHub REST API response for the Trees endpoint. + * GET /repos/{owner}/{repo}/git/trees/{tree_sha}?recursive=1 + */ +interface IGitHubTreeResponse { + readonly sha: string; + readonly url: string; + readonly truncated: boolean; + readonly tree: readonly IGitHubTreeEntry[]; +} + +interface IGitHubTreeEntry { + readonly path: string; + readonly mode: string; + readonly type: 'blob' | 'tree'; + readonly sha: string; + readonly size?: number; + readonly url: string; +} + +interface ITreeCacheEntry { + /** Map from path → entry metadata */ + readonly entries: Map; + readonly fetchedAt: number; +} + +/** + * A readonly virtual filesystem provider backed by the GitHub REST API. + * + * URI format: session-repo://github/{owner}/{repo}/{ref}/{path...} + * + * For example: session-repo://github/microsoft/vscode/main/src/vs/base/common/uri.ts + * + * This provider fetches the full recursive tree from the GitHub Trees API on first + * access and caches it. Individual file contents are fetched on demand via the + * Blobs API. + */ +export class SessionRepoFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { + + private readonly _onDidChangeCapabilities = this._register(new Emitter()); + readonly onDidChangeCapabilities: Event = this._onDidChangeCapabilities.event; + + readonly capabilities: FileSystemProviderCapabilities = + FileSystemProviderCapabilities.Readonly | + FileSystemProviderCapabilities.FileReadWrite | + FileSystemProviderCapabilities.PathCaseSensitive; + + private readonly _onDidChangeFile = this._register(new Emitter()); + readonly onDidChangeFile: Event = this._onDidChangeFile.event; + + /** Cache keyed by "owner/repo/ref" */ + private readonly treeCache = new Map(); + + /** Cache TTL - 5 minutes */ + private static readonly CACHE_TTL_MS = 5 * 60 * 1000; + + constructor( + @IRequestService private readonly requestService: IRequestService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @ILogService private readonly logService: ILogService, + ) { + super(); + } + + // --- URI parsing + + /** + * Parse a session-repo URI into its components. + * Format: session-repo://github/{owner}/{repo}/{ref}/{path...} + */ + private parseUri(resource: URI): { owner: string; repo: string; ref: string; path: string } { + // authority = "github" + // path = /{owner}/{repo}/{ref}/{rest...} + const parts = resource.path.split('/').filter(Boolean); + if (parts.length < 3) { + throw createFileSystemProviderError('Invalid session-repo URI: expected /{owner}/{repo}/{ref}/...', FileSystemProviderErrorCode.FileNotFound); + } + + const owner = parts[0]; + const repo = parts[1]; + const ref = parts[2]; + const path = parts.slice(3).join('/'); + + return { owner, repo, ref, path }; + } + + private getCacheKey(owner: string, repo: string, ref: string): string { + return `${owner}/${repo}/${ref}`; + } + + // --- GitHub API + + private async getAuthToken(): Promise { + const sessions = await this.authenticationService.getSessions('github', ['repo']); + if (sessions.length > 0) { + return sessions[0].accessToken; + } + + // Try to create a session if none exists + const session = await this.authenticationService.createSession('github', ['repo']); + return session.accessToken; + } + + private async fetchTree(owner: string, repo: string, ref: string): Promise { + const cacheKey = this.getCacheKey(owner, repo, ref); + const cached = this.treeCache.get(cacheKey); + if (cached && (Date.now() - cached.fetchedAt) < SessionRepoFileSystemProvider.CACHE_TTL_MS) { + return cached; + } + + this.logService.info(`[SessionRepoFS] Fetching tree for ${owner}/${repo}@${ref}`); + const token = await this.getAuthToken(); + + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`; + const response = await this.requestService.request({ + type: 'GET', + url, + headers: { + 'Authorization': `token ${token}`, + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'VSCode-SessionRepoFS', + }, + }, CancellationToken.None); + + const data = await asJson(response); + if (!data) { + throw createFileSystemProviderError(`Failed to fetch tree for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.Unavailable); + } + + const entries = new Map(); + + // Add root directory entry + entries.set('', { type: FileType.Directory, size: 0, sha: data.sha }); + + // Track directories implicitly from paths + const dirs = new Set(); + + for (const entry of data.tree) { + const fileType = entry.type === 'tree' ? FileType.Directory : FileType.File; + entries.set(entry.path, { type: fileType, size: entry.size ?? 0, sha: entry.sha }); + + if (fileType === FileType.Directory) { + dirs.add(entry.path); + } + + // Ensure parent directories are tracked + const pathParts = entry.path.split('/'); + for (let i = 1; i < pathParts.length; i++) { + const parentPath = pathParts.slice(0, i).join('/'); + if (!dirs.has(parentPath)) { + dirs.add(parentPath); + if (!entries.has(parentPath)) { + entries.set(parentPath, { type: FileType.Directory, size: 0, sha: '' }); + } + } + } + } + + const cacheEntry: ITreeCacheEntry = { entries, fetchedAt: Date.now() }; + this.treeCache.set(cacheKey, cacheEntry); + return cacheEntry; + } + + // --- IFileSystemProvider + + async stat(resource: URI): Promise { + const { owner, repo, ref, path } = this.parseUri(resource); + const tree = await this.fetchTree(owner, repo, ref); + const entry = tree.entries.get(path); + + if (!entry) { + throw createFileSystemProviderError('File not found', FileSystemProviderErrorCode.FileNotFound); + } + + return { + type: entry.type, + ctime: 0, + mtime: 0, + size: entry.size, + }; + } + + async readdir(resource: URI): Promise<[string, FileType][]> { + const { owner, repo, ref, path } = this.parseUri(resource); + const tree = await this.fetchTree(owner, repo, ref); + + const prefix = path ? path + '/' : ''; + const result: [string, FileType][] = []; + + for (const [entryPath, entry] of tree.entries) { + if (!entryPath.startsWith(prefix)) { + continue; + } + + const relativePath = entryPath.slice(prefix.length); + // Only include direct children (no nested paths) + if (relativePath && !relativePath.includes('/')) { + result.push([relativePath, entry.type]); + } + } + + return result; + } + + async readFile(resource: URI): Promise { + const { owner, repo, ref, path } = this.parseUri(resource); + const tree = await this.fetchTree(owner, repo, ref); + const entry = tree.entries.get(path); + + if (!entry || entry.type === FileType.Directory) { + throw createFileSystemProviderError('File not found', FileSystemProviderErrorCode.FileNotFound); + } + + const token = await this.getAuthToken(); + + // Fetch file content via the Blobs API + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/blobs/${encodeURIComponent(entry.sha)}`; + const response = await this.requestService.request({ + type: 'GET', + url, + headers: { + 'Authorization': `token ${token}`, + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'VSCode-SessionRepoFS', + }, + }, CancellationToken.None); + + const data = await asJson<{ content: string; encoding: string }>(response); + if (!data) { + throw createFileSystemProviderError(`Failed to read file ${path}`, FileSystemProviderErrorCode.Unavailable); + } + + if (data.encoding === 'base64') { + const binaryString = atob(data.content.replace(/\n/g, '')); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + + return new TextEncoder().encode(data.content); + } + + // --- Readonly stubs + + watch(): IDisposable { + return Disposable.None; + } + + async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise { + throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async mkdir(_resource: URI): Promise { + throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { + throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { + throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions); + } + + // --- Cache management + + invalidateCache(owner: string, repo: string, ref: string): void { + this.treeCache.delete(this.getCacheKey(owner, repo, ref)); + } + + override dispose(): void { + this.treeCache.clear(); + super.dispose(); + } +} diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index b81b0de984e9c..87aca9f052e25 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -193,6 +193,7 @@ import './contrib/chat/browser/chat.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changesView/browser/changesView.contribution.js'; +import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; import './contrib/configuration/browser/configuration.contribution.js'; //#endregion From 41993913700db172dc3778541097edc0867c9cbf Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 11:03:34 -0800 Subject: [PATCH 02/31] Enhance chat context attachments and file picker functionality - Updated NewChatContextAttachments to include file listing from the workspace folder. - Modified showPicker method to accept folderUri and handle file picks accordingly. - Adjusted NewChatViewPane to pass selected folder URI to the context attachments. - Disabled file tree view registration in favor of the new "Add Context" picker. --- .../chat/browser/newChatContextAttachments.ts | 76 +++++++++++++++++-- .../contrib/chat/browser/newChatViewPane.ts | 5 +- .../browser/fileTreeView.contribution.ts | 42 +--------- src/vs/sessions/sessions.desktop.main.ts | 2 +- 4 files changed, 77 insertions(+), 48 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 3b502a1a93980..5b91ca637f40a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -14,12 +14,13 @@ import { localize } from '../../../../nls.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputService, IQuickPickItem, IQuickPickItemWithResource } from '../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickItemWithResource, IQuickPickSeparator, QuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { AnythingQuickAccessProviderRunOptions } from '../../../../platform/quickinput/common/quickAccess.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; +import { IFileService, IFileStat } from '../../../../platform/files/common/files.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; import { basename } from '../../../../base/common/resources.js'; import { AnythingQuickAccessProvider } from '../../../../workbench/contrib/search/browser/anythingQuickAccess.js'; @@ -56,6 +57,7 @@ export class NewChatContextAttachments extends Disposable { @IFileService private readonly fileService: IFileService, @IClipboardService private readonly clipboardService: IClipboardService, @IFileDialogService private readonly fileDialogService: IFileDialogService, + @ILabelService private readonly labelService: ILabelService, ) { super(); } @@ -168,9 +170,9 @@ export class NewChatContextAttachments extends Disposable { // --- Picker --- - showPicker(): void { + async showPicker(folderUri?: URI): Promise { // Build addition picks for the quick access - const additionPicks: IQuickPickItem[] = []; + const additionPicks: QuickPickItem[] = []; // "Files and Open Folders..." pick - opens a file dialog additionPicks.push({ @@ -186,6 +188,15 @@ export class NewChatContextAttachments extends Disposable { id: 'sessions.imageFromClipboard', }); + // List files from the workspace folder + if (folderUri) { + const filePicks = await this._collectFilePicks(folderUri); + if (filePicks.length > 0) { + additionPicks.push({ type: 'separator', label: basename(folderUri) } satisfies IQuickPickSeparator); + additionPicks.push(...filePicks); + } + } + const providerOptions: AnythingQuickAccessProviderRunOptions = { filter: (pick) => { if (_isQuickPickItemWithResource(pick) && pick.resource) { @@ -199,8 +210,11 @@ export class NewChatContextAttachments extends Disposable { await this._handleFileDialog(); } else if (item.id === 'sessions.imageFromClipboard') { await this._handleClipboardImage(); - } else { - await this._handleFilePick(item as IQuickPickItemWithResource); + } else if (_isQuickPickItemWithResource(item) && item.resource) { + await this._handleFilePick(item); + } else if (item.id) { + // Workspace file picks store the URI string as id + await this._attachFileUri(URI.parse(item.id), item.label); } } }; @@ -212,6 +226,56 @@ export class NewChatContextAttachments extends Disposable { }); } + private async _collectFilePicks(rootUri: URI): Promise { + const picks: IQuickPickItem[] = []; + const maxFiles = 200; + + const collect = async (uri: URI): Promise => { + if (picks.length >= maxFiles) { + return; + } + + let stat: IFileStat; + try { + stat = await this.fileService.resolve(uri); + } catch { + return; + } + + if (!stat.children) { + return; + } + + const children = stat.children + .slice() + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + for (const child of children) { + if (picks.length >= maxFiles) { + break; + } + if (child.isDirectory) { + await collect(child.resource); + } else { + picks.push({ + label: child.name, + description: this.labelService.getUriLabel(child.resource, { relative: true }), + iconClass: ThemeIcon.asClassName(Codicon.file), + id: child.resource.toString(), + } satisfies IQuickPickItem); + } + } + }; + + await collect(rootUri); + return picks; + } + private async _handleFileDialog(): Promise { const selected = await this.fileDialogService.showOpenDialog({ canSelectFiles: true, diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index a8c69daa78eb9..edd4626767d71 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -441,7 +441,10 @@ class NewChatWidget extends Disposable { attachButton.title = localize('addContext', "Add Context..."); attachButton.ariaLabel = localize('addContext', "Add Context..."); dom.append(attachButton, renderIcon(Codicon.add)); - this._register(dom.addDisposableListener(attachButton, dom.EventType.CLICK, () => this._contextAttachments.showPicker())); + this._register(dom.addDisposableListener(attachButton, dom.EventType.CLICK, () => { + const folderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + this._contextAttachments.showPicker(folderUri); + })); } private _createBottomToolbar(container: HTMLElement): void { diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts index dbd2e730838cf..f1fd166db8e46 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts @@ -3,52 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { localize2 } from '../../../../nls.js'; -import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; -import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { FILE_TREE_VIEW_CONTAINER_ID, FILE_TREE_VIEW_ID, FileTreeViewPane, FileTreeViewPaneContainer } from './fileTreeView.js'; import { SessionRepoFileSystemProvider, SESSION_REPO_SCHEME } from './sessionRepoFileSystemProvider.js'; -// --- Icons - -const fileTreeViewIcon = registerIcon('file-tree-view-icon', Codicon.repoClone, localize2('fileTreeViewIcon', 'View icon for the Files view.').value); - -// --- View Container Registration - -const viewContainersRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); - -const fileTreeViewContainer = viewContainersRegistry.registerViewContainer({ - id: FILE_TREE_VIEW_CONTAINER_ID, - title: localize2('files', 'Files'), - ctorDescriptor: new SyncDescriptor(FileTreeViewPaneContainer), - icon: fileTreeViewIcon, - order: 20, - hideIfEmpty: false, - windowVisibility: WindowVisibility.Sessions -}, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true }); - -// --- View Registration - -const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); - -viewsRegistry.registerViews([{ - id: FILE_TREE_VIEW_ID, - name: localize2('files', 'Files'), - containerIcon: fileTreeViewIcon, - ctorDescriptor: new SyncDescriptor(FileTreeViewPane), - canToggleVisibility: true, - canMoveView: true, - weight: 100, - order: 2, - windowVisibility: WindowVisibility.Sessions -}], fileTreeViewContainer); +// --- View registration is currently disabled in favor of the "Add Context" picker. +// The Files view will be re-enabled once we finalize the sessions auxiliary bar layout. // --- Session Repo FileSystem Provider Registration diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 0c1a9cb987287..14ea745fd17be 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -196,7 +196,7 @@ import './contrib/chat/browser/chat.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changesView/browser/changesView.contribution.js'; -import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; +import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed import './contrib/configuration/browser/configuration.contribution.js'; //#endregion From 9db208a604e72eea0c4b161ea3091d405a9bcd3c Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 11:58:48 -0800 Subject: [PATCH 03/31] Refactor file picking logic to utilize search service for improved performance and exclude patterns --- .../chat/browser/newChatContextAttachments.ts | 70 +++++++------------ 1 file changed, 24 insertions(+), 46 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 5b91ca637f40a..1a3ab30c9fa30 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -17,10 +17,11 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { IQuickInputService, IQuickPickItem, IQuickPickItemWithResource, IQuickPickSeparator, QuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { AnythingQuickAccessProviderRunOptions } from '../../../../platform/quickinput/common/quickAccess.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; -import { IFileService, IFileStat } from '../../../../platform/files/common/files.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { basename } from '../../../../base/common/resources.js'; import { AnythingQuickAccessProvider } from '../../../../workbench/contrib/search/browser/anythingQuickAccess.js'; @@ -29,6 +30,7 @@ import { isSupportedChatFileScheme } from '../../../../workbench/contrib/chat/co import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImageUtils.js'; import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; import { getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; +import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js'; /** * Manages context attachments for the sessions new-chat widget. @@ -58,6 +60,8 @@ export class NewChatContextAttachments extends Disposable { @IClipboardService private readonly clipboardService: IClipboardService, @IFileDialogService private readonly fileDialogService: IFileDialogService, @ILabelService private readonly labelService: ILabelService, + @ISearchService private readonly searchService: ISearchService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); } @@ -227,53 +231,27 @@ export class NewChatContextAttachments extends Disposable { } private async _collectFilePicks(rootUri: URI): Promise { - const picks: IQuickPickItem[] = []; const maxFiles = 200; + const searchExcludePattern = getExcludes(this.configurationService.getValue({ resource: rootUri })) || {}; + + try { + const searchResult = await this.searchService.fileSearch({ + folderQueries: [{ folder: rootUri }], + type: QueryType.File, + excludePattern: searchExcludePattern, + sortByScore: true, + maxResults: maxFiles, + }); - const collect = async (uri: URI): Promise => { - if (picks.length >= maxFiles) { - return; - } - - let stat: IFileStat; - try { - stat = await this.fileService.resolve(uri); - } catch { - return; - } - - if (!stat.children) { - return; - } - - const children = stat.children - .slice() - .sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - - for (const child of children) { - if (picks.length >= maxFiles) { - break; - } - if (child.isDirectory) { - await collect(child.resource); - } else { - picks.push({ - label: child.name, - description: this.labelService.getUriLabel(child.resource, { relative: true }), - iconClass: ThemeIcon.asClassName(Codicon.file), - id: child.resource.toString(), - } satisfies IQuickPickItem); - } - } - }; - - await collect(rootUri); - return picks; + return searchResult.results.map(result => ({ + label: basename(result.resource), + description: this.labelService.getUriLabel(result.resource, { relative: true }), + iconClass: ThemeIcon.asClassName(Codicon.file), + id: result.resource.toString(), + } satisfies IQuickPickItem)); + } catch { + return []; + } } private async _handleFileDialog(): Promise { From e4d0bdb690307ef144e84d1c51a111e848bbe865 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 12:02:11 -0800 Subject: [PATCH 04/31] Improve file picker performance by initiating file search early and handling results asynchronously --- .../chat/browser/newChatContextAttachments.ts | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 1a3ab30c9fa30..718605602d47a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -174,32 +174,35 @@ export class NewChatContextAttachments extends Disposable { // --- Picker --- - async showPicker(folderUri?: URI): Promise { - // Build addition picks for the quick access - const additionPicks: QuickPickItem[] = []; - - // "Files and Open Folders..." pick - opens a file dialog - additionPicks.push({ - label: localize('filesAndFolders', "Files and Open Folders..."), - iconClass: ThemeIcon.asClassName(Codicon.file), - id: 'sessions.filesAndFolders', - }); - - // "Image from Clipboard" pick - additionPicks.push({ - label: localize('imageFromClipboard', "Image from Clipboard"), - iconClass: ThemeIcon.asClassName(Codicon.fileMedia), - id: 'sessions.imageFromClipboard', - }); - - // List files from the workspace folder - if (folderUri) { - const filePicks = await this._collectFilePicks(folderUri); - if (filePicks.length > 0) { - additionPicks.push({ type: 'separator', label: basename(folderUri) } satisfies IQuickPickSeparator); - additionPicks.push(...filePicks); + showPicker(folderUri?: URI): void { + // Start file search early (non-blocking) + const filePicksPromise = folderUri ? this._collectFilePicks(folderUri) : Promise.resolve([]); + let filePicks: IQuickPickItem[] | undefined; + const folderLabel = folderUri ? basename(folderUri) : undefined; + + // Static addition picks shown immediately + const additionPicks: QuickPickItem[] = [ + { + label: localize('filesAndFolders', "Files and Open Folders..."), + iconClass: ThemeIcon.asClassName(Codicon.file), + id: 'sessions.filesAndFolders', + }, + { + label: localize('imageFromClipboard', "Image from Clipboard"), + iconClass: ThemeIcon.asClassName(Codicon.fileMedia), + id: 'sessions.imageFromClipboard', + }, + ]; + + // Async file picks are appended once resolved; the AnythingQuickAccessProvider + // re-reads `additionPicks` on every keystroke so they appear on the next filter. + filePicksPromise.then(results => { + if (results.length > 0) { + filePicks = results; + additionPicks.push({ type: 'separator', label: folderLabel! } satisfies IQuickPickSeparator); + additionPicks.push(...results); } - } + }); const providerOptions: AnythingQuickAccessProviderRunOptions = { filter: (pick) => { @@ -217,7 +220,10 @@ export class NewChatContextAttachments extends Disposable { } else if (_isQuickPickItemWithResource(item) && item.resource) { await this._handleFilePick(item); } else if (item.id) { - // Workspace file picks store the URI string as id + // Wait for file picks if still loading, then attach + if (!filePicks) { + await filePicksPromise; + } await this._attachFileUri(URI.parse(item.id), item.label); } } From 0b670ce5b4e2418ccce61b890505fcab7d207aba Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 12:05:53 -0800 Subject: [PATCH 05/31] Refactor file picker to improve async loading and simplify item handling --- .../chat/browser/newChatContextAttachments.ts | 100 +++++++----------- 1 file changed, 38 insertions(+), 62 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 718605602d47a..fbf9e8550c562 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -9,13 +9,10 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { Emitter } from '../../../../base/common/event.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { isObject } from '../../../../base/common/types.js'; import { localize } from '../../../../nls.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputService, IQuickPickItem, IQuickPickItemWithResource, IQuickPickSeparator, QuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; -import { AnythingQuickAccessProviderRunOptions } from '../../../../platform/quickinput/common/quickAccess.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; @@ -24,9 +21,7 @@ import { ILabelService } from '../../../../platform/label/common/label.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { basename } from '../../../../base/common/resources.js'; -import { AnythingQuickAccessProvider } from '../../../../workbench/contrib/search/browser/anythingQuickAccess.js'; import { IChatRequestVariableEntry, OmittedState } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { isSupportedChatFileScheme } from '../../../../workbench/contrib/chat/common/constants.js'; import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImageUtils.js'; import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; import { getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; @@ -53,7 +48,6 @@ export class NewChatContextAttachments extends Disposable { } constructor( - @IInstantiationService private readonly instantiationService: IInstantiationService, @IQuickInputService private readonly quickInputService: IQuickInputService, @ITextModelService private readonly textModelService: ITextModelService, @IFileService private readonly fileService: IFileService, @@ -175,13 +169,11 @@ export class NewChatContextAttachments extends Disposable { // --- Picker --- showPicker(folderUri?: URI): void { - // Start file search early (non-blocking) - const filePicksPromise = folderUri ? this._collectFilePicks(folderUri) : Promise.resolve([]); - let filePicks: IQuickPickItem[] | undefined; - const folderLabel = folderUri ? basename(folderUri) : undefined; + const picker = this.quickInputService.createQuickPick({ useSeparators: true }); + picker.placeholder = localize('chatContext.attach.placeholder', "Attach as context..."); + picker.matchOnDescription = true; - // Static addition picks shown immediately - const additionPicks: QuickPickItem[] = [ + const staticPicks: (IQuickPickItem | IQuickPickSeparator)[] = [ { label: localize('filesAndFolders', "Files and Open Folders..."), iconClass: ThemeIcon.asClassName(Codicon.file), @@ -194,46 +186,43 @@ export class NewChatContextAttachments extends Disposable { }, ]; - // Async file picks are appended once resolved; the AnythingQuickAccessProvider - // re-reads `additionPicks` on every keystroke so they appear on the next filter. - filePicksPromise.then(results => { - if (results.length > 0) { - filePicks = results; - additionPicks.push({ type: 'separator', label: folderLabel! } satisfies IQuickPickSeparator); - additionPicks.push(...results); - } - }); - - const providerOptions: AnythingQuickAccessProviderRunOptions = { - filter: (pick) => { - if (_isQuickPickItemWithResource(pick) && pick.resource) { - return this.instantiationService.invokeFunction(accessor => isSupportedChatFileScheme(accessor, pick.resource!.scheme)); - } - return true; - }, - additionPicks, - handleAccept: async (item: IQuickPickItem) => { - if (item.id === 'sessions.filesAndFolders') { - await this._handleFileDialog(); - } else if (item.id === 'sessions.imageFromClipboard') { - await this._handleClipboardImage(); - } else if (_isQuickPickItemWithResource(item) && item.resource) { - await this._handleFilePick(item); - } else if (item.id) { - // Wait for file picks if still loading, then attach - if (!filePicks) { - await filePicksPromise; - } - await this._attachFileUri(URI.parse(item.id), item.label); + picker.items = staticPicks; + picker.show(); + + // Load workspace files async — the picker is already visible + if (folderUri) { + picker.busy = true; + this._collectFilePicks(folderUri).then(filePicks => { + picker.busy = false; + if (filePicks.length > 0) { + picker.items = [ + ...staticPicks, + { type: 'separator', label: basename(folderUri) }, + ...filePicks, + ]; } + }); + } + + picker.onDidAccept(async () => { + const [selected] = picker.selectedItems; + if (!selected) { + picker.hide(); + return; } - }; - this.quickInputService.quickAccess.show('', { - enabledProviderPrefixes: [AnythingQuickAccessProvider.PREFIX], - placeholder: localize('chatContext.attach.placeholder', "Attach as context..."), - providerOptions, + picker.hide(); + + if (selected.id === 'sessions.filesAndFolders') { + await this._handleFileDialog(); + } else if (selected.id === 'sessions.imageFromClipboard') { + await this._handleClipboardImage(); + } else if (selected.id) { + await this._attachFileUri(URI.parse(selected.id), selected.label); + } }); + + picker.onDidHide(() => picker.dispose()); } private async _collectFilePicks(rootUri: URI): Promise { @@ -276,13 +265,6 @@ export class NewChatContextAttachments extends Disposable { } } - private async _handleFilePick(pick: IQuickPickItemWithResource): Promise { - if (!pick.resource) { - return; - } - await this._attachFileUri(pick.resource, pick.label); - } - private async _attachFileUri(uri: URI, name: string): Promise { if (/\.(png|jpg|jpeg|bmp|gif|tiff)$/i.test(uri.path)) { const readFile = await this.fileService.readFile(uri); @@ -356,9 +338,3 @@ export class NewChatContextAttachments extends Disposable { this._onDidChangeContext.fire(); } } - -function _isQuickPickItemWithResource(obj: unknown): obj is IQuickPickItemWithResource { - return ( - isObject(obj) - && URI.isUri((obj as IQuickPickItemWithResource).resource)); -} From 797a36a91132bc4192241112981275f03934b947 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 12:13:57 -0800 Subject: [PATCH 06/31] Enhance file picker functionality with debounce search and cancellation support --- .../chat/browser/newChatContextAttachments.ts | 67 ++++++++++++++----- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index fbf9e8550c562..dbe35e2ddd93a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -5,8 +5,9 @@ import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; @@ -170,8 +171,10 @@ export class NewChatContextAttachments extends Disposable { showPicker(folderUri?: URI): void { const picker = this.quickInputService.createQuickPick({ useSeparators: true }); + const disposables = new DisposableStore(); picker.placeholder = localize('chatContext.attach.placeholder', "Attach as context..."); picker.matchOnDescription = true; + picker.sortByLabel = false; const staticPicks: (IQuickPickItem | IQuickPickSeparator)[] = [ { @@ -189,22 +192,48 @@ export class NewChatContextAttachments extends Disposable { picker.items = staticPicks; picker.show(); - // Load workspace files async — the picker is already visible if (folderUri) { - picker.busy = true; - this._collectFilePicks(folderUri).then(filePicks => { - picker.busy = false; - if (filePicks.length > 0) { - picker.items = [ - ...staticPicks, - { type: 'separator', label: basename(folderUri) }, - ...filePicks, - ]; + let searchCts: CancellationTokenSource | undefined; + let debounceTimer: ReturnType | undefined; + + const runSearch = (filePattern?: string) => { + searchCts?.dispose(true); + searchCts = new CancellationTokenSource(); + const token = searchCts.token; + + picker.busy = true; + this._collectFilePicks(folderUri, filePattern, token).then(filePicks => { + if (token.isCancellationRequested) { + return; + } + picker.busy = false; + if (filePicks.length > 0) { + picker.items = [ + ...staticPicks, + { type: 'separator', label: basename(folderUri) }, + ...filePicks, + ]; + } else { + picker.items = staticPicks; + } + }); + }; + + // Initial search (no filter) + runSearch(); + + // Re-search on user input with debounce + disposables.add(picker.onDidChangeValue(value => { + if (debounceTimer) { + clearTimeout(debounceTimer); } - }); + debounceTimer = setTimeout(() => runSearch(value || undefined), 200); + })); + + disposables.add({ dispose: () => { searchCts?.dispose(true); if (debounceTimer) { clearTimeout(debounceTimer); } } }); } - picker.onDidAccept(async () => { + disposables.add(picker.onDidAccept(async () => { const [selected] = picker.selectedItems; if (!selected) { picker.hide(); @@ -220,12 +249,15 @@ export class NewChatContextAttachments extends Disposable { } else if (selected.id) { await this._attachFileUri(URI.parse(selected.id), selected.label); } - }); + })); - picker.onDidHide(() => picker.dispose()); + disposables.add(picker.onDidHide(() => { + picker.dispose(); + disposables.dispose(); + })); } - private async _collectFilePicks(rootUri: URI): Promise { + private async _collectFilePicks(rootUri: URI, filePattern?: string, token?: CancellationToken): Promise { const maxFiles = 200; const searchExcludePattern = getExcludes(this.configurationService.getValue({ resource: rootUri })) || {}; @@ -233,10 +265,11 @@ export class NewChatContextAttachments extends Disposable { const searchResult = await this.searchService.fileSearch({ folderQueries: [{ folder: rootUri }], type: QueryType.File, + filePattern: filePattern || '', excludePattern: searchExcludePattern, sortByScore: true, maxResults: maxFiles, - }); + }, token); return searchResult.results.map(result => ({ label: basename(result.resource), From 9b0ce2aa4fae26b178efb8c4414c4d69d29061e8 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 12:18:36 -0800 Subject: [PATCH 07/31] Enhance file picking logic by adding common build output folders to exclusion patterns --- .../chat/browser/newChatContextAttachments.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index dbe35e2ddd93a..b2f814b69bf60 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -259,14 +259,26 @@ export class NewChatContextAttachments extends Disposable { private async _collectFilePicks(rootUri: URI, filePattern?: string, token?: CancellationToken): Promise { const maxFiles = 200; - const searchExcludePattern = getExcludes(this.configurationService.getValue({ resource: rootUri })) || {}; + const configExcludes = getExcludes(this.configurationService.getValue({ resource: rootUri })); + // Ensure common build output folders are always excluded + const excludePattern = { + ...configExcludes, + '**/node_modules': true, + 'out/**': true, + 'out-build/**': true, + 'out-vscode/**': true, + '.build/**': true, + }; try { const searchResult = await this.searchService.fileSearch({ - folderQueries: [{ folder: rootUri }], + folderQueries: [{ + folder: rootUri, + disregardIgnoreFiles: false, + }], type: QueryType.File, filePattern: filePattern || '', - excludePattern: searchExcludePattern, + excludePattern, sortByScore: true, maxResults: maxFiles, }, token); From c5f95db6b3c53ad082f2ad442f0cef0647c13f6b Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 12:20:44 -0800 Subject: [PATCH 08/31] Refactor file picker to streamline exclusion patterns for file search --- .../contrib/chat/browser/newChatContextAttachments.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index b2f814b69bf60..89f4c2025ef2c 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -259,16 +259,7 @@ export class NewChatContextAttachments extends Disposable { private async _collectFilePicks(rootUri: URI, filePattern?: string, token?: CancellationToken): Promise { const maxFiles = 200; - const configExcludes = getExcludes(this.configurationService.getValue({ resource: rootUri })); - // Ensure common build output folders are always excluded - const excludePattern = { - ...configExcludes, - '**/node_modules': true, - 'out/**': true, - 'out-build/**': true, - 'out-vscode/**': true, - '.build/**': true, - }; + const excludePattern = getExcludes(this.configurationService.getValue({ resource: rootUri })); try { const searchResult = await this.searchService.fileSearch({ From 96cac5ecc844a88cfe4ff57b9c0dd5f8dc24f47a Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 12:31:39 -0800 Subject: [PATCH 09/31] Add support for context folder URI selection based on target type in NewChatWidget --- .../chat/browser/newChatContextAttachments.ts | 61 +++++++++++++++++++ .../contrib/chat/browser/newChatViewPane.ts | 32 +++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 89f4c2025ef2c..0cdfcb1f31629 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -21,6 +21,7 @@ import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs. import { ILabelService } from '../../../../platform/label/common/label.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { basename } from '../../../../base/common/resources.js'; +import { Schemas } from '../../../../base/common/network.js'; import { IChatRequestVariableEntry, OmittedState } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImageUtils.js'; @@ -259,6 +260,17 @@ export class NewChatContextAttachments extends Disposable { private async _collectFilePicks(rootUri: URI, filePattern?: string, token?: CancellationToken): Promise { const maxFiles = 200; + + // For local file:// URIs, use the search service which respects .gitignore and excludes + if (rootUri.scheme === Schemas.file || rootUri.scheme === Schemas.vscodeRemote) { + return this._collectFilePicksViaSearch(rootUri, maxFiles, filePattern, token); + } + + // For virtual filesystems (e.g. session-repo://), walk the tree via IFileService + return this._collectFilePicksViaFileService(rootUri, maxFiles, filePattern); + } + + private async _collectFilePicksViaSearch(rootUri: URI, maxFiles: number, filePattern?: string, token?: CancellationToken): Promise { const excludePattern = getExcludes(this.configurationService.getValue({ resource: rootUri })); try { @@ -285,6 +297,55 @@ export class NewChatContextAttachments extends Disposable { } } + private async _collectFilePicksViaFileService(rootUri: URI, maxFiles: number, filePattern?: string): Promise { + const picks: IQuickPickItem[] = []; + const patternLower = filePattern?.toLowerCase(); + + const collect = async (uri: URI): Promise => { + if (picks.length >= maxFiles) { + return; + } + + try { + const stat = await this.fileService.resolve(uri); + if (!stat.children) { + return; + } + + const children = stat.children.slice().sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + for (const child of children) { + if (picks.length >= maxFiles) { + break; + } + if (child.isDirectory) { + await collect(child.resource); + } else { + if (patternLower && !child.name.toLowerCase().includes(patternLower)) { + continue; + } + picks.push({ + label: child.name, + description: this.labelService.getUriLabel(child.resource, { relative: true }), + iconClass: ThemeIcon.asClassName(Codicon.file), + id: child.resource.toString(), + }); + } + } + } catch { + // ignore errors for individual directories + } + }; + + await collect(rootUri); + return picks; + } + private async _handleFileDialog(): Promise { const selected = await this.fileDialogService.showOpenDialog({ canSelectFiles: true, diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index edd4626767d71..267049c143ade 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -58,6 +58,7 @@ import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { isString } from '../../../../base/common/types.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; +import { SESSION_REPO_SCHEME } from '../../fileTreeView/browser/sessionRepoFileSystemProvider.js'; // #region --- Target Config --- @@ -442,11 +443,38 @@ class NewChatWidget extends Disposable { attachButton.ariaLabel = localize('addContext', "Add Context..."); dom.append(attachButton, renderIcon(Codicon.add)); this._register(dom.addDisposableListener(attachButton, dom.EventType.CLICK, () => { - const folderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; - this._contextAttachments.showPicker(folderUri); + this._contextAttachments.showPicker(this._getContextFolderUri()); })); } + /** + * Returns the folder URI for the context picker based on the current target. + * Local targets use the workspace folder; cloud targets construct a session-repo:// URI. + */ + private _getContextFolderUri(): URI | undefined { + const target = this._getEffectiveTarget(); + + if (!target || target === AgentSessionProviders.Local || target === AgentSessionProviders.Background) { + return this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + } + + // For cloud targets, look for a repository option in the selected options + for (const [groupId, option] of this._selectedOptions) { + if (isRepoOrFolderGroup({ id: groupId, name: groupId, items: [] })) { + const nwo = option.id; // e.g. "owner/repo" + if (nwo && nwo.includes('/')) { + return URI.from({ + scheme: SESSION_REPO_SCHEME, + authority: 'github', + path: `/${nwo}/HEAD`, + }); + } + } + } + + return undefined; + } + private _createBottomToolbar(container: HTMLElement): void { const toolbar = dom.append(container, dom.$('.sessions-chat-toolbar')); From a3272a6472c352dc2d159524a8077b712e1af1cb Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 13:01:59 -0800 Subject: [PATCH 10/31] Limit file collection depth in _collectFilePicksViaFileService to improve performance --- .../contrib/chat/browser/newChatContextAttachments.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 0cdfcb1f31629..0750162dd2825 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -300,9 +300,10 @@ export class NewChatContextAttachments extends Disposable { private async _collectFilePicksViaFileService(rootUri: URI, maxFiles: number, filePattern?: string): Promise { const picks: IQuickPickItem[] = []; const patternLower = filePattern?.toLowerCase(); + const maxDepth = 10; - const collect = async (uri: URI): Promise => { - if (picks.length >= maxFiles) { + const collect = async (uri: URI, depth: number): Promise => { + if (picks.length >= maxFiles || depth > maxDepth) { return; } @@ -324,7 +325,7 @@ export class NewChatContextAttachments extends Disposable { break; } if (child.isDirectory) { - await collect(child.resource); + await collect(child.resource, depth + 1); } else { if (patternLower && !child.name.toLowerCase().includes(patternLower)) { continue; @@ -342,7 +343,7 @@ export class NewChatContextAttachments extends Disposable { } }; - await collect(rootUri); + await collect(rootUri, 0); return picks; } From df035d32e3c3fc9e4b9949d3ebf2f1542e3f3669 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 13:11:40 -0800 Subject: [PATCH 11/31] Change workbench contribution phase to AfterRestored for SessionRepoFileSystemProvider --- .../contrib/fileTreeView/browser/fileTreeView.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts index f1fd166db8e46..044e5563b6e9b 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts @@ -31,5 +31,5 @@ class SessionRepoFileSystemProviderContribution extends Disposable { registerWorkbenchContribution2( SessionRepoFileSystemProviderContribution.ID, SessionRepoFileSystemProviderContribution, - WorkbenchPhase.BlockStartup + WorkbenchPhase.AfterRestored ); From 29efafccc95373b1bf3decb7bca1454da070fa15 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 13:22:58 -0800 Subject: [PATCH 12/31] Remove deprecated URI parsing logic for copilot-pr scheme in FileTreeViewPane --- .../contrib/fileTreeView/browser/fileTreeView.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts index 3225eddfaadce..f628196992b7c 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts @@ -403,18 +403,6 @@ export class FileTreeViewPane extends ViewPane { * Handles various URI formats used by cloud sessions. */ private parseRepoFromFileUri(uri: URI): { owner: string; repo: string; ref: string } | undefined { - // Pattern: copilot-pr:/path?{json with owner, repo, commitSha, ...} - if (uri.scheme === 'copilot-pr' && uri.query) { - try { - const data = JSON.parse(decodeURIComponent(uri.query)); - if (typeof data.owner === 'string' && typeof data.repo === 'string') { - return { owner: data.owner, repo: data.repo, ref: data.commitSha ?? 'HEAD' }; - } - } catch { - // malformed query - } - } - // Pattern: vscode-vfs://github/{owner}/{repo}/... if (uri.authority === 'github' || uri.authority?.startsWith('github')) { const parts = uri.path.split('/').filter(Boolean); From cfec98ed2aacb3bd7f4f4c963d1df90a060fea1c Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 14:04:13 -0800 Subject: [PATCH 13/31] Add SessionRepoFileSystemProvider for GitHub REST API integration --- .../chat/browser/newChatContextAttachments.ts | 2 +- .../contrib/chat/browser/newChatViewPane.ts | 6 +++--- .../browser/fileTreeView.contribution.ts | 14 +++++++------- .../fileTreeView/browser/fileTreeView.ts | 18 +++++++++--------- ...Provider.ts => githubFileSystemProvider.ts} | 16 ++++++++-------- 5 files changed, 28 insertions(+), 28 deletions(-) rename src/vs/sessions/contrib/fileTreeView/browser/{sessionRepoFileSystemProvider.ts => githubFileSystemProvider.ts} (93%) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 0750162dd2825..001967b2bbc9e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -266,7 +266,7 @@ export class NewChatContextAttachments extends Disposable { return this._collectFilePicksViaSearch(rootUri, maxFiles, filePattern, token); } - // For virtual filesystems (e.g. session-repo://), walk the tree via IFileService + // For virtual filesystems (e.g. github-remote-file://), walk the tree via IFileService return this._collectFilePicksViaFileService(rootUri, maxFiles, filePattern); } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 267049c143ade..ff5f4328d4d1a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -58,7 +58,7 @@ import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { isString } from '../../../../base/common/types.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; -import { SESSION_REPO_SCHEME } from '../../fileTreeView/browser/sessionRepoFileSystemProvider.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; // #region --- Target Config --- @@ -449,7 +449,7 @@ class NewChatWidget extends Disposable { /** * Returns the folder URI for the context picker based on the current target. - * Local targets use the workspace folder; cloud targets construct a session-repo:// URI. + * Local targets use the workspace folder; cloud targets construct a github-remote-file:// URI. */ private _getContextFolderUri(): URI | undefined { const target = this._getEffectiveTarget(); @@ -464,7 +464,7 @@ class NewChatWidget extends Disposable { const nwo = option.id; // e.g. "owner/repo" if (nwo && nwo.includes('/')) { return URI.from({ - scheme: SESSION_REPO_SCHEME, + scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${nwo}/HEAD`, }); diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts index 044e5563b6e9b..1d67b91d1b4b3 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts @@ -7,29 +7,29 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { SessionRepoFileSystemProvider, SESSION_REPO_SCHEME } from './sessionRepoFileSystemProvider.js'; +import { GitHubFileSystemProvider, GITHUB_REMOTE_FILE_SCHEME } from './githubFileSystemProvider.js'; // --- View registration is currently disabled in favor of the "Add Context" picker. // The Files view will be re-enabled once we finalize the sessions auxiliary bar layout. // --- Session Repo FileSystem Provider Registration -class SessionRepoFileSystemProviderContribution extends Disposable { +class GitHubFileSystemProviderContribution extends Disposable { - static readonly ID = 'workbench.contrib.sessionRepoFileSystemProvider'; + static readonly ID = 'workbench.contrib.githubFileSystemProvider'; constructor( @IFileService fileService: IFileService, @IInstantiationService instantiationService: IInstantiationService, ) { super(); - const provider = this._register(instantiationService.createInstance(SessionRepoFileSystemProvider)); - this._register(fileService.registerProvider(SESSION_REPO_SCHEME, provider)); + const provider = this._register(instantiationService.createInstance(GitHubFileSystemProvider)); + this._register(fileService.registerProvider(GITHUB_REMOTE_FILE_SCHEME, provider)); } } registerWorkbenchContribution2( - SessionRepoFileSystemProviderContribution.ID, - SessionRepoFileSystemProviderContribution, + GitHubFileSystemProviderContribution.ID, + GitHubFileSystemProviderContribution, WorkbenchPhase.AfterRestored ); diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts index f628196992b7c..0ef758704325e 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts @@ -43,7 +43,7 @@ import { IStorageService } from '../../../../platform/storage/common/storage.js' import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ISessionsManagementService, IActiveSessionItem } from '../../sessions/browser/sessionsManagementService.js'; -import { SESSION_REPO_SCHEME } from './sessionRepoFileSystemProvider.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from './githubFileSystemProvider.js'; import { basename } from '../../../../base/common/path.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -195,7 +195,7 @@ export class FileTreeViewPane extends ViewPane { /** * Observable that tracks the root URI for the file tree. * - For background sessions: the worktree or repository local path - * - For cloud sessions: a session-repo:// URI derived from the session's repository metadata + * - For cloud sessions: a github-remote-file:// URI derived from the session's repository metadata * - For local sessions: the workspace folder */ private readonly treeRootUri: IObservable; @@ -300,7 +300,7 @@ export class FileTreeViewPane extends ViewPane { if (parsed) { this.logService.info(`[FileTreeView] Parsed repository URI as GitHub: ${parsed.owner}/${parsed.repo}`); return URI.from({ - scheme: SESSION_REPO_SCHEME, + scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${parsed.owner}/${parsed.repo}/HEAD`, }); @@ -312,7 +312,7 @@ export class FileTreeViewPane extends ViewPane { } /** - * Extracts a session-repo:// URI from session metadata, trying various known fields. + * Extracts a github-remote-file:// URI from session metadata, trying various known fields. */ private extractRepoUriFromMetadata(metadata: { readonly [key: string]: unknown }): URI | undefined { // repositoryNwo: "owner/repo" @@ -320,7 +320,7 @@ export class FileTreeViewPane extends ViewPane { if (repositoryNwo && repositoryNwo.includes('/')) { this.logService.info(`[FileTreeView] Using metadata.repositoryNwo: ${repositoryNwo}`); return URI.from({ - scheme: SESSION_REPO_SCHEME, + scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repositoryNwo}/HEAD`, }); @@ -333,7 +333,7 @@ export class FileTreeViewPane extends ViewPane { if (parsed) { this.logService.info(`[FileTreeView] Using metadata.repositoryUrl: ${repositoryUrl}`); return URI.from({ - scheme: SESSION_REPO_SCHEME, + scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${parsed.owner}/${parsed.repo}/HEAD`, }); @@ -347,7 +347,7 @@ export class FileTreeViewPane extends ViewPane { // Looks like "owner/repo" this.logService.info(`[FileTreeView] Using metadata.repository as nwo: ${repository}`); return URI.from({ - scheme: SESSION_REPO_SCHEME, + scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repository}/HEAD`, }); @@ -356,7 +356,7 @@ export class FileTreeViewPane extends ViewPane { if (parsed) { this.logService.info(`[FileTreeView] Using metadata.repository as URL: ${repository}`); return URI.from({ - scheme: SESSION_REPO_SCHEME, + scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${parsed.owner}/${parsed.repo}/HEAD`, }); @@ -388,7 +388,7 @@ export class FileTreeViewPane extends ViewPane { const parsed = this.parseRepoFromFileUri(fileUri); if (parsed) { return URI.from({ - scheme: SESSION_REPO_SCHEME, + scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${parsed.owner}/${parsed.repo}/${parsed.ref}`, }); diff --git a/src/vs/sessions/contrib/fileTreeView/browser/sessionRepoFileSystemProvider.ts b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts similarity index 93% rename from src/vs/sessions/contrib/fileTreeView/browser/sessionRepoFileSystemProvider.ts rename to src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts index e45e5e5c5f929..289911e399578 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/sessionRepoFileSystemProvider.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts @@ -12,7 +12,7 @@ import { IAuthenticationService } from '../../../../workbench/services/authentic import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -export const SESSION_REPO_SCHEME = 'session-repo'; +export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; /** * GitHub REST API response for the Trees endpoint. @@ -43,15 +43,15 @@ interface ITreeCacheEntry { /** * A readonly virtual filesystem provider backed by the GitHub REST API. * - * URI format: session-repo://github/{owner}/{repo}/{ref}/{path...} + * URI format: github-remote-file://github/{owner}/{repo}/{ref}/{path...} * - * For example: session-repo://github/microsoft/vscode/main/src/vs/base/common/uri.ts + * For example: github-remote-file://github/microsoft/vscode/main/src/vs/base/common/uri.ts * * This provider fetches the full recursive tree from the GitHub Trees API on first * access and caches it. Individual file contents are fetched on demand via the * Blobs API. */ -export class SessionRepoFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { +export class GitHubFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { private readonly _onDidChangeCapabilities = this._register(new Emitter()); readonly onDidChangeCapabilities: Event = this._onDidChangeCapabilities.event; @@ -81,15 +81,15 @@ export class SessionRepoFileSystemProvider extends Disposable implements IFileSy // --- URI parsing /** - * Parse a session-repo URI into its components. - * Format: session-repo://github/{owner}/{repo}/{ref}/{path...} + * Parse a github-remote-file URI into its components. + * Format: github-remote-file://github/{owner}/{repo}/{ref}/{path...} */ private parseUri(resource: URI): { owner: string; repo: string; ref: string; path: string } { // authority = "github" // path = /{owner}/{repo}/{ref}/{rest...} const parts = resource.path.split('/').filter(Boolean); if (parts.length < 3) { - throw createFileSystemProviderError('Invalid session-repo URI: expected /{owner}/{repo}/{ref}/...', FileSystemProviderErrorCode.FileNotFound); + throw createFileSystemProviderError('Invalid github-remote-file URI: expected /{owner}/{repo}/{ref}/...', FileSystemProviderErrorCode.FileNotFound); } const owner = parts[0]; @@ -120,7 +120,7 @@ export class SessionRepoFileSystemProvider extends Disposable implements IFileSy private async fetchTree(owner: string, repo: string, ref: string): Promise { const cacheKey = this.getCacheKey(owner, repo, ref); const cached = this.treeCache.get(cacheKey); - if (cached && (Date.now() - cached.fetchedAt) < SessionRepoFileSystemProvider.CACHE_TTL_MS) { + if (cached && (Date.now() - cached.fetchedAt) < GitHubFileSystemProvider.CACHE_TTL_MS) { return cached; } From 6a6ba4d911ef3f0a8b3144c33f50899c7a6832a3 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Feb 2026 16:33:42 -0600 Subject: [PATCH 14/31] Implement soft and hard limits for question headers and text in AskQuestionsTool (#296384) --- .../tools/builtinTools/askQuestionsTool.ts | 85 +++++++++++++++---- .../builtinTools/askQuestionsTool.test.ts | 8 +- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts index 1655b0dac454e..8c503de71cf21 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -25,6 +25,34 @@ import { URI } from '../../../../../../base/common/uri.js'; // Use a distinct id to avoid clashing with extension-provided tools export const AskQuestionsToolId = 'vscode_askQuestions'; +// Soft limits are used in the schema to guide the model +// Hard limits are more lenient and used to truncate if the model overshoots +// +// Example text at each limit: +// - header soft (50 chars): "Which database engine do you want to use for this?" +// - header hard (75 chars): "Which database engine and connection pooling strategy do you want to use here?" +// - question soft (200 chars): "What testing framework would you like to use for this project? Consider factors like your team's familiarity, community support, and integration with your existing CI/CD pipeline when making a choice." +// - question hard (300 chars): "What testing framework would you like to use for this project? Consider factors like your team's familiarity with the framework, community support and documentation quality, integration with your existing CI/CD pipeline, and the specific testing needs of your application architecture when deciding." +const SoftLimits = { + header: 50, + question: 200 +} as const; + +const HardLimits = { + header: 75, + question: 300 +} as const; + +function truncateToLimit(value: string | undefined, limit: number): string | undefined { + if (value === undefined) { + return undefined; + } + if (value.length > limit) { + return value.slice(0, limit - 3) + '...'; + } + return value; +} + export interface IQuestionOption { readonly label: string; readonly description?: string; @@ -60,12 +88,12 @@ export function createAskQuestionsToolData(): IToolData { header: { type: 'string', description: 'Short identifier for the question. Must be unique so answers can be mapped back to the question.', - maxLength: 50 + maxLength: SoftLimits.header }, question: { type: 'string', description: 'The question text to display to the user. Keep it concise, ideally one sentence.', - maxLength: 200 + maxLength: SoftLimits.question }, multiSelect: { type: 'boolean', @@ -83,13 +111,11 @@ export function createAskQuestionsToolData(): IToolData { properties: { label: { type: 'string', - description: 'Display label and value for the option.', - maxLength: 100 + description: 'Display label and value for the option.' }, description: { type: 'string', - description: 'Optional secondary text shown with the option.', - maxLength: 200 + description: 'Optional secondary text shown with the option.' }, recommended: { type: 'boolean', @@ -160,7 +186,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { return this.createSkippedResult(questions); } - const carousel = this.toQuestionCarousel(questions); + const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions); this.chatService.appendProgress(request, carousel); const answerResult = await raceCancellation(carousel.completion.p, token); @@ -170,7 +196,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { progress.report({ message: localize('askQuestionsTool.progress', 'Analyzing your answers...') }); - const converted = this.convertCarouselAnswers(questions, answerResult?.answers); + const converted = this.convertCarouselAnswers(questions, answerResult?.answers, idToHeaderMap); const { answeredCount, skippedCount, freeTextCount, recommendedAvailableCount, recommendedSelectedCount } = this.collectMetrics(questions, converted); this.sendTelemetry(invocation.chatRequestId, questions.length, answeredCount, skippedCount, freeTextCount, recommendedAvailableCount, recommendedSelectedCount, stopWatch.elapsed()); @@ -194,6 +220,11 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { if (question.options && question.options.length === 1) { throw new Error(localize('askQuestionsTool.invalidOptions', 'Question "{0}" must have at least two options, or none for free text input.', question.header)); } + + // Apply hard limits to truncate display values that exceed the more lenient hard limit + // Note: The original header is preserved and used as the answer key in convertCarouselAnswers + // to avoid collisions when distinct headers become identical after truncation + (question as { question: string }).question = truncateToLimit(question.question, HardLimits.question) ?? question.question; } const questionCount = questions.length; @@ -236,12 +267,16 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { return { request, sessionResource: chatSessionResource }; } - private toQuestionCarousel(questions: IQuestion[]): ChatQuestionCarouselData { - const mappedQuestions = questions.map(question => this.toChatQuestion(question)); - return new ChatQuestionCarouselData(mappedQuestions, true, generateUuid()); + private toQuestionCarousel(questions: IQuestion[]): { carousel: ChatQuestionCarouselData; idToHeaderMap: Map } { + const idToHeaderMap = new Map(); + const mappedQuestions = questions.map(question => this.toChatQuestion(question, idToHeaderMap)); + return { + carousel: new ChatQuestionCarouselData(mappedQuestions, true, generateUuid()), + idToHeaderMap + }; } - private toChatQuestion(question: IQuestion): IChatQuestion { + private toChatQuestion(question: IQuestion, idToHeaderMap: Map): IChatQuestion { let type: IChatQuestion['type']; if (!question.options || question.options.length === 0) { type = 'text'; @@ -259,10 +294,18 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { } } + // Use a stable UUID as the internal ID to avoid collisions when truncating headers + // The original header is preserved in idToHeaderMap for answer correlation + const internalId = generateUuid(); + idToHeaderMap.set(internalId, question.header); + + // Truncate header for display only + const displayTitle = truncateToLimit(question.header, HardLimits.header) ?? question.header; + return { - id: question.header, + id: internalId, type, - title: question.header, + title: displayTitle, message: question.question, options: question.options?.map(opt => ({ id: opt.label, @@ -274,7 +317,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { }; } - protected convertCarouselAnswers(questions: IQuestion[], carouselAnswers: Record | undefined): IAnswerResult { + protected convertCarouselAnswers(questions: IQuestion[], carouselAnswers: Record | undefined, idToHeaderMap: Map): IAnswerResult { const result: IAnswerResult = { answers: {} }; if (carouselAnswers) { @@ -282,6 +325,12 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { this.logService.trace(`[AskQuestionsTool] Question headers: ${questions.map(q => q.header).join(', ')}`); } + // Build a reverse map: original header -> internal ID + const headerToIdMap = new Map(); + for (const [internalId, originalHeader] of idToHeaderMap) { + headerToIdMap.set(originalHeader, internalId); + } + for (const question of questions) { if (!carouselAnswers) { result.answers[question.header] = { @@ -292,8 +341,10 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { continue; } - const answer = carouselAnswers[question.header]; - this.logService.trace(`[AskQuestionsTool] Processing question "${question.header}", raw answer: ${JSON.stringify(answer)}, type: ${typeof answer}`); + // Look up the answer using the internal ID that was used in the carousel + const internalId = headerToIdMap.get(question.header); + const answer = internalId ? carouselAnswers[internalId] : undefined; + this.logService.trace(`[AskQuestionsTool] Processing question "${question.header}" (internal ID: ${internalId}), raw answer: ${JSON.stringify(answer)}, type: ${typeof answer}`); if (answer === undefined) { result.answers[question.header] = { diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts index cf380b921ba3d..f82b6bbe55dbd 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts @@ -12,7 +12,13 @@ import { IChatService } from '../../../../common/chatService/chatService.js'; class TestableAskQuestionsTool extends AskQuestionsTool { public testConvertCarouselAnswers(questions: IQuestion[], carouselAnswers: Record | undefined): IAnswerResult { - return this.convertCarouselAnswers(questions, carouselAnswers); + // Create an identity map where each header is also the internal ID + // This simulates the simple case for testing the answer conversion logic + const idToHeaderMap = new Map(); + for (const q of questions) { + idToHeaderMap.set(q.header, q.header); + } + return this.convertCarouselAnswers(questions, carouselAnswers, idToHeaderMap); } } From a987cc4e3ff3aabd35e4beef063afce650074018 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:52:43 -0800 Subject: [PATCH 15/31] Skill: Tool renames (#296383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add tool-rename-deprecation skill for legacy name backward compat * Fix deprecated name resolution for namespaced tool references in toolsets When a tool belongs to a toolset and has legacyToolReferenceFullNames, the deprecated names map now includes the namespaced form (e.g. vscode/openSimpleBrowser → vscode/openIntegratedBrowser). Previously only the bare name was mapped, so agent files using the full toolSet/toolName path got 'Unknown tool' instead of the rename hint. --- .../skills/tool-rename-deprecation/SKILL.md | 149 ++++++++++++++++++ .../tools/languageModelToolsService.ts | 8 + .../languageProviders/promptValidator.test.ts | 42 ++++- .../tools/languageModelToolsService.test.ts | 24 +++ 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 .github/skills/tool-rename-deprecation/SKILL.md diff --git a/.github/skills/tool-rename-deprecation/SKILL.md b/.github/skills/tool-rename-deprecation/SKILL.md new file mode 100644 index 0000000000000..0d1fa0c663fb2 --- /dev/null +++ b/.github/skills/tool-rename-deprecation/SKILL.md @@ -0,0 +1,149 @@ +--- +name: tool-rename-deprecation +description: 'Ensure renamed built-in tool references preserve backward compatibility. Use when renaming a toolReferenceName, tool set referenceName, or any tool identifier. Run on ANY change to tool registration code. Covers legacyToolReferenceFullNames for tools and legacyFullNames for tool sets.' +--- + +# Tool Rename Deprecation + +When a tool or tool set reference name is changed, the **old name must always be added to the deprecated/legacy array** so that existing prompt files, tool configurations, and saved references continue to resolve correctly. + +## When to Use + +Run this skill on **any change to built-in tool or tool set registration code** to catch regressions: + +- Renaming a tool's `toolReferenceName` +- Renaming a tool set's `referenceName` +- Moving a tool from one tool set to another (the old `toolSet/toolName` path becomes a legacy name) +- Reviewing a PR that modifies tool registration — verify no legacy names were dropped + +## Procedure + +### Step 1 — Identify What Changed + +Determine whether you are renaming a **tool** or a **tool set**, and where it is registered: + +| Entity | Registration | Name field to rename | Legacy array | Stable ID (NEVER change) | +|--------|-------------|---------------------|-------------|-------------------------| +| Tool (`IToolData`) | TypeScript | `toolReferenceName` | `legacyToolReferenceFullNames` | `id` | +| Tool (extension) | `package.json` `languageModelTools` | `toolReferenceName` | `legacyToolReferenceFullNames` | `name` (becomes `id`) | +| Tool set (`IToolSet`) | TypeScript | `referenceName` | `legacyFullNames` | `id` | +| Tool set (extension) | `package.json` `languageModelToolSets` | `name` or `referenceName` | `legacyFullNames` | — | + +**Critical:** For extension-contributed tools, the `name` field in `package.json` is mapped to `id` on `IToolData` (see `languageModelToolsContribution.ts` line `id: rawTool.name`). It is also used for activation events (`onLanguageModelTool:`). **Never rename the `name` field** — only rename `toolReferenceName`. + +### Step 2 — Add the Old Name to the Legacy Array + +**Verify the old `toolReferenceName` value appears in `legacyToolReferenceFullNames`.** Don't assume it's already there — check the actual array contents. If the old name is already listed (e.g., from a previous rename), confirm it wasn't removed. If it's not there, add it. + +**For internal/built-in tools** (TypeScript `IToolData`): + +```typescript +// Before rename +export const MyToolData: IToolData = { + id: 'myExtension.myTool', + toolReferenceName: 'oldName', + // ... +}; + +// After rename — old name preserved +export const MyToolData: IToolData = { + id: 'myExtension.myTool', + toolReferenceName: 'newName', + legacyToolReferenceFullNames: ['oldName'], + // ... +}; +``` + +If the tool previously lived inside a tool set, use the full `toolSet/toolName` form: + +```typescript +legacyToolReferenceFullNames: ['oldToolSet/oldToolName'], +``` + +If renaming multiple times, **accumulate** all prior names — never remove existing entries: + +```typescript +legacyToolReferenceFullNames: ['firstOldName', 'secondOldName'], +``` + +**For tool sets**, add the old name to the `legacyFullNames` option when calling `createToolSet`: + +```typescript +toolsService.createToolSet(source, id, 'newSetName', { + legacyFullNames: ['oldSetName'], +}); +``` + +**For extension-contributed tools** (`package.json`), rename only `toolReferenceName` and add the old value to `legacyToolReferenceFullNames`. **Do NOT rename the `name` field:** + +```jsonc +// CORRECT — only toolReferenceName changes, name stays stable +{ + "name": "copilot_myTool", // ← KEEP this unchanged + "toolReferenceName": "newName", // ← renamed + "legacyToolReferenceFullNames": [ + "oldName" // ← old toolReferenceName preserved + ] +} +``` + +### Step 3 — Check All Consumers of Tool Names + +Legacy names must be respected **everywhere** a tool is looked up by reference name, not just in prompt resolution. Key consumers: + +- **Prompt files** — `getDeprecatedFullReferenceNames()` maps old → current names for `.prompt.md` validation and code actions +- **Tool enablement** — `getToolAliases()` / `getToolSetAliases()` yield legacy names so tool picker and enablement maps resolve them +- **Auto-approval config** — `isToolEligibleForAutoApproval()` checks `legacyToolReferenceFullNames` (including the segment after `/` for namespaced legacy names) against `chat.tools.eligibleForAutoApproval` settings +- **RunInTerminalTool** — has its own local auto-approval check that also iterates `LEGACY_TOOL_REFERENCE_FULL_NAMES` + +After renaming, confirm: +1. `#oldName` in a `.prompt.md` file still resolves (shows no validation error) +2. Tool configurations referencing the old name still activate the tool +3. A user who had `"chat.tools.eligibleForAutoApproval": { "oldName": false }` still has that restriction honored + +### Step 4 — Update References (Optional) + +While legacy names ensure backward compatibility, update first-party references to use the new name: +- System prompts and built-in `.prompt.md` files +- Documentation and model descriptions that mention the tool by reference name +- Test files that reference the old name directly + +## Key Files + +| File | What it contains | +|------|-----------------| +| `src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts` | `IToolData` and `IToolSet` interfaces with legacy name fields | +| `src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts` | Resolution logic: `getToolAliases`, `getToolSetAliases`, `getDeprecatedFullReferenceNames`, `isToolEligibleForAutoApproval` | +| `src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts` | Extension point schema, validation, and the critical `id: rawTool.name` mapping (line ~274) | +| `src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts` | Example of a tool with its own local auto-approval check against legacy names | + +## Real Examples + +- `runInTerminal` tool: renamed from `runCommands/runInTerminal` → `legacyToolReferenceFullNames: ['runCommands/runInTerminal']` +- `todo` tool: renamed from `todos` → `legacyToolReferenceFullNames: ['todos']` +- `getTaskOutput` tool: renamed from `runTasks/getTaskOutput` → `legacyToolReferenceFullNames: ['runTasks/getTaskOutput']` + +## Reference PRs + +- [#277047](https://github.com/microsoft/vscode/pull/277047) — **Design PR**: Introduced `legacyToolReferenceFullNames` and `legacyFullNames`, built the resolution infrastructure, and performed the first batch of tool renames. Use as a template for how to properly rename with legacy names. +- [#278506](https://github.com/microsoft/vscode/pull/278506) — **Consumer-side fix**: After the renames in #277047, the `eligibleForAutoApproval` setting wasn't checking legacy names — users who had restricted the old name lost that restriction. Shows why all consumers of tool reference names must account for legacy names. +- [vscode-copilot-chat#3810](https://github.com/microsoft/vscode-copilot-chat/pull/3810) — **Example of a miss**: Renamed `openSimpleBrowser` → `openIntegratedBrowser` but also changed the `name` field (stable id) from `copilot_openSimpleBrowser` → `copilot_openIntegratedBrowser`. The `toolReferenceName` backward compat only worked by coincidence (the old name happened to already be in the legacy array from a prior change — it was not intentionally added as part of this rename). + +## Regression Check + +Run this check on any PR that touches tool registration (TypeScript `IToolData`, `createToolSet`, or `package.json` `languageModelTools`/`languageModelToolSets`): + +1. **Search the diff for changed `toolReferenceName` or `referenceName` values.** For each change, confirm the **previous value** now appears in `legacyToolReferenceFullNames` or `legacyFullNames`. Don't assume it was already there — read the actual array. +2. **Search the diff for changed `name` fields** on extension-contributed tools. The `name` field is the tool's stable `id` — it must **never** change. If it changed, flag it as a bug. (This breaks activation events, tool invocations by id, and any code referencing the tool by its `name`.) +3. **Verify no entries were removed** from existing legacy arrays. +4. **If a tool moved between tool sets**, confirm the old `toolSet/toolName` full path is in the legacy array. +5. **Check tool set membership lists** (the `tools` array in `languageModelToolSets` contributions). If a tool's `toolReferenceName` changed, any tool set `tools` array referencing the old name should be updated — but the legacy resolution system handles this, so the old name still works. + +## Anti-patterns + +- **Changing the `name` field on extension-contributed tools** — the `name` in `package.json` becomes the `id` on `IToolData` (via `id: rawTool.name` in `languageModelToolsContribution.ts`). Changing it breaks activation events (`onLanguageModelTool:`), any code referencing the tool by id, and tool invocations. Only rename `toolReferenceName`, never `name`. (See [vscode-copilot-chat#3810](https://github.com/microsoft/vscode-copilot-chat/pull/3810) where both `name` and `toolReferenceName` were changed.) +- **Changing the `id` field on TypeScript-registered tools** — same principle as above. The `id` is a stable internal identifier and must never change. +- **Assuming the old name is already in the legacy array** — always verify by reading the actual `legacyToolReferenceFullNames` contents, not just checking that the field exists. A legacy array might list names from an even older rename but not the current one being changed. +- **Removing an old name from the legacy array** — breaks existing saved prompts and user configurations. +- **Forgetting to add the legacy name entirely** — prompt files and tool configs silently stop resolving. +- **Only updating prompt resolution but not other consumers** — auto-approval settings, tool enablement maps, and individual tool checks (like `RunInTerminalTool`) all need to respect legacy names (see [#278506](https://github.com/microsoft/vscode/pull/278506)). diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 1f676140061fc..020f07ec8a215 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -1438,7 +1438,15 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo add(alias, fullReferenceName); } if (tool.legacyToolReferenceFullNames) { + // If the tool is in a toolset (fullReferenceName has a '/'), also add the + // namespaced form of legacy names (e.g. 'vscode/oldName' → 'vscode/newName') + const slashIndex = fullReferenceName.lastIndexOf('/'); + const toolSetPrefix = slashIndex !== -1 ? fullReferenceName.substring(0, slashIndex + 1) : undefined; + for (const legacyName of tool.legacyToolReferenceFullNames) { + if (toolSetPrefix && !legacyName.includes('/')) { + add(toolSetPrefix + legacyName, fullReferenceName); + } // for any 'orphaned' toolsets (toolsets that no longer exist and // do not have an explicit legacy mapping), we should // just point them to the list of tools directly diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 775ad671cec74..a552eb7855633 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -110,6 +110,11 @@ suite('PromptValidator', () => { disposables.add(toolService.registerToolData(conflictTool2)); disposables.add(conflictToolSet2.addTool(conflictTool2)); + // Tool in the vscode toolset with a legacy name — for testing namespaced deprecated name resolution + const toolInVscodeSet = { id: 'browserTool', toolReferenceName: 'openIntegratedBrowser', legacyToolReferenceFullNames: ['openSimpleBrowser'], displayName: 'Open Integrated Browser', canBeReferencedInPrompt: true, modelDescription: 'Open browser', source: ToolDataSource.Internal, inputSchema: {} } satisfies IToolData; + disposables.add(toolService.registerToolData(toolInVscodeSet)); + disposables.add(toolService.vscodeToolSet.addTool(toolInVscodeSet)); + instaService.set(ILanguageModelToolsService, toolService); const testModels: ILanguageModelChatMetadata[] = [ @@ -500,6 +505,41 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, expectedMessage); }); + test('namespaced deprecated tool name in tools header shows rename hint', async () => { + // When a tool is in a toolset (e.g. vscode/openIntegratedBrowser) and has a legacy name, + // using the namespaced old name (vscode/openSimpleBrowser) should show the rename hint + const content = [ + '---', + 'description: "Test"', + `tools: ['vscode/openSimpleBrowser']`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Info, message: `Tool or toolset 'vscode/openSimpleBrowser' has been renamed, use 'vscode/openIntegratedBrowser' instead.` }, + ] + ); + }); + + test('bare deprecated tool name in tools header also shows rename hint', async () => { + // The bare (non-namespaced) legacy name should also resolve + const content = [ + '---', + 'description: "Test"', + `tools: ['openSimpleBrowser']`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Info, message: `Tool or toolset 'openSimpleBrowser' has been renamed, use 'vscode/openIntegratedBrowser' instead.` }, + ] + ); + }); + test('unknown attribute in agent file', async () => { const content = [ '---', @@ -1531,7 +1571,7 @@ suite('PromptValidator', () => { assert.deepEqual(actual, [ { message: `Unknown tool or toolset 'ms-azuretools.vscode-azure-github-copilot/azure_recommend_custom_modes'.`, startColumn: 7, endColumn: 77 }, { message: `Tool or toolset 'github.vscode-pull-request-github/suggest-fix' also needs to be enabled in the header.`, startColumn: 7, endColumn: 52 }, - { message: `Unknown tool or toolset 'openSimpleBrowser'.`, startColumn: 7, endColumn: 24 }, + { message: `Tool or toolset 'openSimpleBrowser' has been renamed, use 'vscode/openIntegratedBrowser' instead.`, startColumn: 7, endColumn: 24 }, ]); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index a2dcac90b3fbb..f642745892e92 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -2244,6 +2244,30 @@ suite('LanguageModelToolsService', () => { assert.strictEqual(deprecatedNames.get('userToolSetRefName'), undefined); }); + test('getDeprecatedFullReferenceNames includes namespaced legacy names for tools in toolsets', () => { + // When a tool is in a toolset and has legacy names, the deprecated names map + // should also include the namespaced form (e.g. 'vscode/oldName' → 'vscode/newName') + const toolWithLegacy: IToolData = { + id: 'myNewBrowser', + toolReferenceName: 'openIntegratedBrowser', + legacyToolReferenceFullNames: ['openSimpleBrowser'], + modelDescription: 'Open browser', + displayName: 'Open Integrated Browser', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: true, + }; + store.add(service.registerToolData(toolWithLegacy)); + store.add(service.vscodeToolSet.addTool(toolWithLegacy)); + + const deprecated = service.getDeprecatedFullReferenceNames(); + + // The simple legacy name should map to the full reference name + assert.deepStrictEqual(deprecated.get('openSimpleBrowser'), new Set(['vscode/openIntegratedBrowser'])); + + // The namespaced legacy name should also map to the full reference name + assert.deepStrictEqual(deprecated.get('vscode/openSimpleBrowser'), new Set(['vscode/openIntegratedBrowser'])); + }); + test('getToolByFullReferenceName', () => { setupToolsForTest(service, store); From 82899e3797a1e5179c231d6048f3790dbf2638f2 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Feb 2026 16:57:30 -0600 Subject: [PATCH 16/31] Add handling for carousel completion (#296391) Add handling for carousel completion in ChatInputPart and introduce tests for ChatQuestionCarouselData --- .../browser/widget/input/chatInputPart.ts | 9 ++ .../model/chatQuestionCarouselData.test.ts | 133 ++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 9be84c2e65b50..ed750c0c5820e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -91,6 +91,7 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind, validateChatMode } import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; +import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IChatResponseViewModel, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -2614,6 +2615,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (existingResolveId && carousel.resolveId && existingResolveId === carousel.resolveId) { return existingCarousel; } + + // Complete the old carousel's completion promise as skipped before clearing + // This prevents the askQuestions tool from hanging when parallel subagents invoke it + const oldCarousel = existingCarousel.carousel; + if (oldCarousel instanceof ChatQuestionCarouselData && !oldCarousel.completion.isSettled) { + oldCarousel.completion.complete({ answers: undefined }); + } + this.clearQuestionCarousel(); } diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts new file mode 100644 index 0000000000000..1c022960b237a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../../base/common/async.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; +import { IChatQuestion } from '../../../common/chatService/chatService.js'; + +suite('ChatQuestionCarouselData', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function createQuestions(): IChatQuestion[] { + return [ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'singleSelect', title: 'Question 2', options: [{ id: 'a', label: 'A', value: 'a' }] } + ]; + } + + test('creates a carousel with DeferredPromise completion', () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id'); + + assert.strictEqual(carousel.kind, 'questionCarousel'); + assert.strictEqual(carousel.resolveId, 'test-resolve-id'); + assert.ok(carousel.completion, 'Should have completion promise'); + assert.strictEqual(carousel.completion.isSettled, false, 'Completion should not be settled initially'); + }); + + test('completion promise can be resolved with answers', async () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id'); + + const answers = { q1: 'answer1', q2: 'a' }; + carousel.completion.complete({ answers }); + + const result = await carousel.completion.p; + assert.strictEqual(carousel.completion.isSettled, true, 'Completion should be settled'); + assert.deepStrictEqual(result.answers, answers); + }); + + test('completion promise can be resolved with undefined (skipped)', async () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id'); + + carousel.completion.complete({ answers: undefined }); + + const result = await carousel.completion.p; + assert.strictEqual(carousel.completion.isSettled, true, 'Completion should be settled'); + assert.strictEqual(result.answers, undefined, 'Skipped carousel should have undefined answers'); + }); + + test('toJSON strips the completion promise', () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id', { q1: 'saved' }, true); + + const json = carousel.toJSON(); + + assert.strictEqual(json.kind, 'questionCarousel'); + assert.strictEqual(json.resolveId, 'test-resolve-id'); + assert.deepStrictEqual(json.data, { q1: 'saved' }); + assert.strictEqual(json.isUsed, true); + assert.strictEqual((json as { completion?: unknown }).completion, undefined, 'toJSON should not include completion'); + }); + + test('multiple carousels can have independent completion promises', async () => { + const carousel1 = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-1'); + const carousel2 = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-2'); + + // Complete carousel2 first + carousel2.completion.complete({ answers: { q1: 'answer2' } }); + + assert.strictEqual(carousel1.completion.isSettled, false, 'Carousel 1 should not be settled'); + assert.strictEqual(carousel2.completion.isSettled, true, 'Carousel 2 should be settled'); + + // Now complete carousel1 + carousel1.completion.complete({ answers: { q1: 'answer1' } }); + + const result1 = await carousel1.completion.p; + const result2 = await carousel2.completion.p; + + assert.deepStrictEqual(result1.answers, { q1: 'answer1' }); + assert.deepStrictEqual(result2.answers, { q1: 'answer2' }); + }); + + suite('Parallel Carousel Handling', () => { + test('when carousel is superseded, completing with undefined does not block', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + // This simulates the scenario where parallel subagents call askQuestions + // and the first carousel is superseded by the second + const carousel1 = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-1'); + const carousel2 = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-2'); + + // Simulate carousel1 being superseded - we complete it with undefined (skipped) + carousel1.completion.complete({ answers: undefined }); + + // Now complete carousel2 normally + carousel2.completion.complete({ answers: { q1: 'answer2' } }); + + const timeoutPromise1 = timeout(100); + const timeoutPromise2 = timeout(100); + + try { + // Both should complete without blocking + const [result1, result2] = await Promise.all([ + Promise.race([carousel1.completion.p, timeoutPromise1.then(() => 'timeout')]), + Promise.race([carousel2.completion.p, timeoutPromise2.then(() => 'timeout')]) + ]); + + assert.notStrictEqual(result1, 'timeout', 'Carousel 1 should not timeout'); + assert.notStrictEqual(result2, 'timeout', 'Carousel 2 should not timeout'); + assert.deepStrictEqual((result1 as { answers: unknown }).answers, undefined); + assert.deepStrictEqual((result2 as { answers: unknown }).answers, { q1: 'answer2' }); + } finally { + timeoutPromise1.cancel(); + timeoutPromise2.cancel(); + } + }); + }); + + test('completing an already settled carousel is safe', () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-1'); + + // Complete once + carousel.completion.complete({ answers: { q1: 'first' } }); + assert.strictEqual(carousel.completion.isSettled, true); + + // Completing again should not throw + assert.doesNotThrow(() => { + carousel.completion.complete({ answers: { q1: 'second' } }); + }); + }); + }); +}); From 10fbccc24479fd67287ec5be67a2699181d80605 Mon Sep 17 00:00:00 2001 From: Elijah King Date: Thu, 19 Feb 2026 15:03:54 -0800 Subject: [PATCH 17/31] Improve chat feedback animations (#294686) --- .../base/browser/ui/animations/animations.ts | 475 ++++++++++++++++++ .../browser/menuEntryActionViewItem.ts | 7 + .../chat/browser/actions/chatTitleActions.ts | 1 + .../contrib/chat/browser/chat.contribution.ts | 14 +- .../chat/browser/widget/chatConfetti.ts | 85 ---- .../chat/browser/widget/chatListRenderer.ts | 19 +- 6 files changed, 513 insertions(+), 88 deletions(-) create mode 100644 src/vs/base/browser/ui/animations/animations.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatConfetti.ts diff --git a/src/vs/base/browser/ui/animations/animations.ts b/src/vs/base/browser/ui/animations/animations.ts new file mode 100644 index 0000000000000..eedf74e514d40 --- /dev/null +++ b/src/vs/base/browser/ui/animations/animations.ts @@ -0,0 +1,475 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ThemeIcon } from '../../../common/themables.js'; +import * as dom from '../../dom.js'; + +export const enum ClickAnimation { + Confetti = 1, + FloatingIcons = 2, + PulseWave = 3, + RadiantLines = 4, +} + +const confettiColors = [ + '#007acc', + '#005a9e', + '#0098ff', + '#4fc3f7', + '#64b5f6', + '#42a5f5', +]; + +let activeOverlay: HTMLElement | undefined; + +/** + * Creates a fixed-positioned overlay centered on the given element. + */ +function createOverlay(element: HTMLElement): { overlay: HTMLElement; cx: number; cy: number } | undefined { + if (activeOverlay) { + return undefined; + } + + const rect = element.getBoundingClientRect(); + const ownerDocument = dom.getWindow(element).document; + + const overlay = dom.$('.animation-overlay'); + overlay.style.position = 'fixed'; + overlay.style.left = `${rect.left}px`; + overlay.style.top = `${rect.top}px`; + overlay.style.width = `${rect.width}px`; + overlay.style.height = `${rect.height}px`; + overlay.style.pointerEvents = 'none'; + overlay.style.overflow = 'visible'; + overlay.style.zIndex = '10000'; + + ownerDocument.body.appendChild(overlay); + activeOverlay = overlay; + + return { overlay, cx: rect.width / 2, cy: rect.height / 2 }; +} + +/** + * Cleans up the overlay after specified period. + */ +function cleanupOverlay(duration: number) { + setTimeout(() => { + if (activeOverlay) { + activeOverlay.remove(); + activeOverlay = undefined; + } + }, duration); +} + +/** + * Bounce the element with a given scale and optional rotation. + */ +export function bounceElement(element: HTMLElement, opts: { scale?: number[]; rotate?: number[]; translateY?: number[]; duration?: number }) { + const frames: Keyframe[] = []; + + const steps = Math.max(opts.scale?.length ?? 0, opts.rotate?.length ?? 0, opts.translateY?.length ?? 0); + if (steps === 0) { + return; + } + + for (let i = 0; i < steps; i++) { + const frame: Keyframe = { offset: steps === 1 ? 1 : i / (steps - 1) }; + let transformParts = ''; + + const scale = opts.scale?.[i]; + if (scale !== undefined) { + transformParts += `scale(${scale})`; + } + + const rotate = opts.rotate?.[i]; + if (rotate !== undefined) { + transformParts += ` rotate(${rotate}deg)`; + } + + const translateY = opts.translateY?.[i]; + if (translateY !== undefined) { + transformParts += ` translateY(${translateY}px)`; + } + + if (transformParts) { + frame.transform = transformParts.trim(); + } + frames.push(frame); + } + + element.animate(frames, { + duration: opts.duration ?? 350, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); +} + +/** + * Confetti: small particles burst outward in a circle from the element center, + * with an expanding ring. + */ +export function triggerConfettiAnimation(element: HTMLElement) { + const result = createOverlay(element); + if (!result) { + return; + } + + const { overlay, cx, cy } = result; + const rect = element.getBoundingClientRect(); + + // Element bounce + bounceElement(element, { + scale: [1, 1.3, 1], + rotate: [0, -10, 10, 0], + duration: 350, + }); + + // Confetti particles + const particleCount = 10; + for (let i = 0; i < particleCount; i++) { + const size = 3 + (i % 3) * 1.5; + const angle = (i * 36 * Math.PI) / 180; + const distance = 35; + const particleOpacity = 0.6 + (i % 4) * 0.1; + + const part = dom.$('.animation-particle'); + part.style.position = 'absolute'; + part.style.width = `${size}px`; + part.style.height = `${size}px`; + part.style.borderRadius = '50%'; + part.style.backgroundColor = confettiColors[i % confettiColors.length]; + part.style.left = `${cx - size / 2}px`; + part.style.top = `${cy - size / 2}px`; + overlay.appendChild(part); + + const tx = Math.cos(angle) * distance; + const ty = Math.sin(angle) * distance; + + part.animate([ + { opacity: 0, transform: 'scale(0) translate(0, 0)' }, + { opacity: particleOpacity, transform: `scale(1) translate(${tx * 0.5}px, ${ty * 0.5}px)`, offset: 0.3 }, + { opacity: particleOpacity, transform: `scale(1) translate(${tx}px, ${ty}px)`, offset: 0.7 }, + { opacity: 0, transform: `scale(0) translate(${tx}px, ${ty}px)` }, + ], { + duration: 1100, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + // Expanding ring + const ring = dom.$('.animation-particle'); + ring.style.position = 'absolute'; + ring.style.left = '0'; + ring.style.top = '0'; + ring.style.width = `${rect.width}px`; + ring.style.height = `${rect.height}px`; + ring.style.borderRadius = '50%'; + ring.style.border = '2px solid var(--vscode-focusBorder, #007acc)'; + ring.style.boxSizing = 'border-box'; + overlay.appendChild(ring); + + ring.animate([ + { transform: 'scale(1)', opacity: 1 }, + { transform: 'scale(2)', opacity: 0 }, + ], { + duration: 800, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + + cleanupOverlay(2000); +} + +/** + * Floating Icons: small icons float upward from the element. + */ +export function triggerFloatingIconsAnimation(element: HTMLElement, icon: ThemeIcon) { + const result = createOverlay(element); + if (!result) { + return; + } + + const { overlay, cx, cy } = result; + const rect = element.getBoundingClientRect(); + + // Element bounce upward + bounceElement(element, { + translateY: [0, -6, 0], + duration: 350, + }); + + // Floating icons + const iconCount = 6; + for (let i = 0; i < iconCount; i++) { + const size = 12 + (i % 3) * 2; + const iconEl = dom.$('.animation-particle'); + iconEl.style.position = 'absolute'; + iconEl.style.left = `${cx}px`; + iconEl.style.top = `${cy}px`; + iconEl.style.fontSize = `${size}px`; + iconEl.style.lineHeight = '1'; + iconEl.style.color = 'var(--vscode-focusBorder, #007acc)'; + iconEl.classList.add(...ThemeIcon.asClassNameArray(icon)); + overlay.appendChild(iconEl); + + const driftX = (Math.random() - 0.5) * 50; + const floatY = -50 - (i % 3) * 10; + const rotate1 = (Math.random() - 0.5) * 20; + const rotate2 = (Math.random() - 0.5) * 40; + + iconEl.animate([ + { opacity: 0, transform: `translate(-50%, -50%) scale(0) rotate(${rotate1}deg)` }, + { opacity: 1, transform: `translate(calc(-50% + ${driftX * 0.3}px), calc(-50% + ${floatY * 0.3}px)) scale(1) rotate(${(rotate1 + rotate2) * 0.3}deg)`, offset: 0.3 }, + { opacity: 1, transform: `translate(calc(-50% + ${driftX * 0.7}px), calc(-50% + ${floatY * 0.7}px)) scale(1) rotate(${(rotate1 + rotate2) * 0.7}deg)`, offset: 0.7 }, + { opacity: 0, transform: `translate(calc(-50% + ${driftX}px), calc(-50% + ${floatY}px)) scale(0.8) rotate(${rotate2}deg)` }, + ], { + duration: 800 + (i % 3) * 200, + delay: i * 80, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + // Expanding ring + const ring = dom.$('.animation-particle'); + ring.style.position = 'absolute'; + ring.style.left = '0'; + ring.style.top = '0'; + ring.style.width = `${rect.width}px`; + ring.style.height = `${rect.height}px`; + ring.style.borderRadius = '50%'; + ring.style.border = '2px solid var(--vscode-focusBorder, #007acc)'; + ring.style.boxSizing = 'border-box'; + overlay.appendChild(ring); + + ring.animate([ + { transform: 'scale(1)', opacity: 1 }, + { transform: 'scale(2)', opacity: 0 }, + ], { + duration: 500, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + + cleanupOverlay(2000); +} + +/** + * Pulse Wave: expanding rings and sparkle dots radiate from the element center. + */ +export function triggerPulseWaveAnimation(element: HTMLElement) { + const result = createOverlay(element); + if (!result) { + return; + } + + const { overlay, cx, cy } = result; + const rect = element.getBoundingClientRect(); + + // Element bounce with slight rotation + bounceElement(element, { + scale: [1, 1.1, 1], + rotate: [0, -12, 0], + duration: 400, + }); + + // Expanding rings + for (let i = 0; i < 2; i++) { + const ring = dom.$('.animation-particle'); + ring.style.position = 'absolute'; + ring.style.left = '0'; + ring.style.top = '0'; + ring.style.width = `${rect.width}px`; + ring.style.height = `${rect.height}px`; + ring.style.borderRadius = '50%'; + ring.style.border = '2px solid var(--vscode-focusBorder, #007acc)'; + ring.style.boxSizing = 'border-box'; + overlay.appendChild(ring); + + ring.animate([ + { transform: 'scale(0.8)', opacity: 0 }, + { transform: 'scale(0.8)', opacity: 0.6, offset: 0.01 }, + { transform: 'scale(2.5)', opacity: 0 }, + ], { + duration: 800, + delay: i * 150, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + // Sparkle dots + for (let i = 0; i < 6; i++) { + const angle = (i * 60 * Math.PI) / 180; + const distance = 30 + (i % 2) * 10; + const size = 3.5; + + const dot = dom.$('.animation-particle'); + dot.style.position = 'absolute'; + dot.style.width = `${size}px`; + dot.style.height = `${size}px`; + dot.style.borderRadius = '50%'; + dot.style.backgroundColor = '#0098ff'; + dot.style.left = `${cx - size / 2}px`; + dot.style.top = `${cy - size / 2}px`; + overlay.appendChild(dot); + + const tx = Math.cos(angle) * distance; + const ty = Math.sin(angle) * distance; + + dot.animate([ + { opacity: 0, transform: 'scale(0) translate(0, 0)' }, + { opacity: 1, transform: `scale(1) translate(${tx}px, ${ty}px)`, offset: 0.5 }, + { opacity: 0, transform: `scale(0) translate(${tx}px, ${ty}px)` }, + ], { + duration: 600, + delay: 100 + i * 50, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + // Background glow + const glow = dom.$('.animation-particle'); + glow.style.position = 'absolute'; + glow.style.left = '0'; + glow.style.top = '0'; + glow.style.width = `${rect.width}px`; + glow.style.height = `${rect.height}px`; + glow.style.borderRadius = '50%'; + glow.style.backgroundColor = 'var(--vscode-focusBorder, #007acc)'; + overlay.appendChild(glow); + + glow.animate([ + { transform: 'scale(0.9)', opacity: 0 }, + { transform: 'scale(0.9)', opacity: 0.5, offset: 0.01 }, + { transform: 'scale(1.5)', opacity: 0 }, + ], { + duration: 500, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + + cleanupOverlay(2000); +} + +/** + * Radiant Lines: lines and dots emanate outward from the element center. + */ +export function triggerRadiantLinesAnimation(element: HTMLElement) { + const result = createOverlay(element); + if (!result) { + return; + } + + const { overlay, cx, cy } = result; + + // Element scale bounce + bounceElement(element, { + scale: [1, 1.15, 1], + duration: 350, + }); + + // Dots at offset angles + for (let i = 0; i < 8; i++) { + const size = 3; + const dotOpacity = 0.7; + const angle = ((i * 45 + 22.5) * Math.PI) / 180; + const startDistance = 14; + const endDistance = 30; + + const dot = dom.$('.animation-particle'); + dot.style.position = 'absolute'; + dot.style.width = `${size}px`; + dot.style.height = `${size}px`; + dot.style.borderRadius = '50%'; + dot.style.backgroundColor = 'var(--vscode-editor-foreground, #ffffff)'; + dot.style.left = `${cx - size / 2}px`; + dot.style.top = `${cy - size / 2}px`; + overlay.appendChild(dot); + + const startX = Math.cos(angle) * startDistance; + const startY = Math.sin(angle) * startDistance; + const endX = Math.cos(angle) * endDistance; + const endY = Math.sin(angle) * endDistance; + + dot.animate([ + { opacity: 0, transform: `scale(0) translate(${startX}px, ${startY}px)` }, + { opacity: dotOpacity, transform: `scale(1.2) translate(${(startX + endX) / 2}px, ${(startY + endY) / 2}px)`, offset: 0.25 }, + { opacity: dotOpacity, transform: `scale(1) translate(${endX * 0.8}px, ${endY * 0.8}px)`, offset: 0.5 }, + { opacity: dotOpacity * 0.5, transform: `scale(1) translate(${endX}px, ${endY}px)`, offset: 0.75 }, + { opacity: 0, transform: `scale(0.5) translate(${endX}px, ${endY}px)` }, + ], { + duration: 1100, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + // Radiant lines + for (let i = 0; i < 8; i++) { + const angleDeg = i * 45; + + const lineWrapper = dom.$('.animation-particle'); + lineWrapper.style.position = 'absolute'; + lineWrapper.style.left = `${cx}px`; + lineWrapper.style.top = `${cy}px`; + lineWrapper.style.width = '0'; + lineWrapper.style.height = '0'; + lineWrapper.style.transform = `rotate(${angleDeg}deg)`; + overlay.appendChild(lineWrapper); + + const line = dom.$('.animation-particle'); + line.style.position = 'absolute'; + line.style.width = '2px'; + line.style.height = '10px'; + line.style.backgroundColor = 'var(--vscode-focusBorder, #007acc)'; + line.style.left = '-1px'; + line.style.top = '-22px'; + line.style.transformOrigin = 'bottom center'; + lineWrapper.appendChild(line); + + line.animate([ + { transform: 'scale(1, 0)', opacity: 0.6 }, + { transform: 'scale(1, 1)', opacity: 0.6, offset: 0.2 }, + { transform: 'scale(1, 1)', opacity: 0.6, offset: 0.6 }, + { transform: 'scale(1, 1)', opacity: 0.6, offset: 0.8 }, + { transform: 'scale(0, 0.3)', opacity: 0 }, + ], { + duration: 1200, + delay: 150, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + cleanupOverlay(2000); +} + +/** + * Triggers the specified click animation on the element. + * @param element The target element to animate. + * @param animation The type of click animation to trigger. + * @param icon Optional icon for animations that require it (e.g., FloatingIcons). + */ +export function triggerClickAnimation(element: HTMLElement, animation: ClickAnimation, icon?: ThemeIcon) { + switch (animation) { + case ClickAnimation.Confetti: + triggerConfettiAnimation(element); + break; + case ClickAnimation.FloatingIcons: + if (icon) { + triggerFloatingIconsAnimation(element, icon); + } + break; + case ClickAnimation.PulseWave: + triggerPulseWaveAnimation(element); + break; + case ClickAnimation.RadiantLines: + triggerRadiantLinesAnimation(element); + break; + } +} diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index feb53c1efcb5e..8f61cd7050578 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -31,6 +31,7 @@ import { INotificationService } from '../../notification/common/notification.js' import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js'; import { defaultSelectBoxStyles } from '../../theme/browser/defaultStyles.js'; import { asCssVariable, selectBorder } from '../../theme/common/colorRegistry.js'; +import { ClickAnimation, triggerClickAnimation } from '../../../base/browser/ui/animations/animations.js'; import { isDark } from '../../theme/common/theme.js'; import { IThemeService } from '../../theme/common/themeService.js'; import { hasNativeContextMenu } from '../../window/common/window.js'; @@ -173,6 +174,7 @@ export interface IMenuEntryActionViewItemOptions { readonly keybinding?: string | null; readonly hoverDelegate?: IHoverDelegate; readonly keybindingNotRenderedWithLabel?: boolean; + readonly onClickAnimation?: ClickAnimation; } export class MenuEntryActionViewItem extends ActionViewItem { @@ -207,6 +209,11 @@ export class MenuEntryActionViewItem 0.5 ? '50%' : '0'; - part.style.left = `${Math.random() * width}px`; - part.style.top = '-10px'; - part.style.opacity = '1'; - - overlay.appendChild(part); - - const targetX = (Math.random() - 0.5) * width * 0.8; - const targetY = Math.random() * height * 0.8 + height * 0.1; - const rotation = Math.random() * 720 - 360; - const duration = Math.random() * 1000 + 1500; - const delay = Math.random() * 400; - - part.animate([ - { - transform: 'translate(0, 0) rotate(0deg)', - opacity: 1 - }, - { - transform: `translate(${targetX * 0.5}px, ${targetY * 0.5}px) rotate(${rotation * 0.5}deg)`, - opacity: 1, - offset: 0.3 - }, - { - transform: `translate(${targetX}px, ${targetY}px) rotate(${rotation}deg)`, - opacity: 1, - offset: 0.75 - }, - { - transform: `translate(${targetX * 1.1}px, ${targetY + 40}px) rotate(${rotation + 30}deg)`, - opacity: 0 - } - ], { - duration, - delay, - easing: 'linear', - fill: 'forwards' - }); - } - - setTimeout(() => { - overlay.remove(); - activeOverlay = undefined; - }, 3000); -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 3b22171302e8d..aac466afd0536 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -32,7 +32,7 @@ import { clamp } from '../../../../../base/common/numbers.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; -import { IMenuEntryActionViewItemOptions, createActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem, createActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; import { MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -65,7 +65,8 @@ import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, ICh import { getNWords } from '../../common/model/chatWordCounter.js'; import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js'; -import { MarkUnhelpfulActionId } from '../actions/chatTitleActions.js'; +import { ClickAnimation } from '../../../../../base/browser/ui/animations/animations.js'; +import { MarkHelpfulActionId, MarkUnhelpfulActionId } from '../actions/chatTitleActions.js'; import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidgetService } from '../chat.js'; import { ChatAgentHover, getChatAgentHoverOptions } from './chatAgentHover.js'; import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js'; @@ -169,6 +170,16 @@ export interface IChatRendererDelegate { const mostRecentResponseClassName = 'chat-most-recent-response'; +function upvoteAnimationSettingToEnum(value: string | undefined): ClickAnimation | undefined { + switch (value) { + case 'confetti': return ClickAnimation.Confetti; + case 'floatingThumbs': return ClickAnimation.FloatingIcons; + case 'pulseWave': return ClickAnimation.PulseWave; + case 'radiantLines': return ClickAnimation.RadiantLines; + default: return undefined; + } +} + export class ChatListItemRenderer extends Disposable implements ITreeRenderer { static readonly ID = 'item'; @@ -504,6 +515,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.upvoteAnimation')); + return scopedInstantiationService.createInstance(MenuEntryActionViewItem, action, { ...options, onClickAnimation: animation }); + } return createActionViewItem(scopedInstantiationService, action, options); } })); From 8ff7aae7ab18012adb665dee6d0d4e2b68c59bfc Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Fri, 20 Feb 2026 00:12:51 +0100 Subject: [PATCH 18/31] sessions polish --- .../browser/account.contribution.ts | 1 - .../browser/agentFeedback.contribution.ts | 16 +- .../browser/agentFeedbackAttachment.ts | 2 +- .../agentFeedbackGlyphMarginContribution.ts | 21 +- ...agentFeedbackLineDecorationContribution.ts | 188 ++++++++++++++++++ .../browser/agentFeedbackService.ts | 36 ---- .../media/agentFeedbackGlyphMargin.css | 13 +- .../media/agentFeedbackLineDecoration.css | 25 +++ .../browser/media/customizationsToolbar.css | 3 +- .../chatAttachmentWidgetRegistry.ts | 82 ++++++++ .../contrib/chat/browser/chat.contribution.ts | 2 + .../browser/widget/input/chatInputPart.ts | 5 +- 12 files changed, 334 insertions(+), 60 deletions(-) create mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts create mode 100644 src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css create mode 100644 src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.ts diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 9b4701fe419f3..cbff23edc4c94 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -307,7 +307,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu when: ContextKeyExpr.or( CONTEXT_UPDATE_STATE.isEqualTo(StateType.Ready), CONTEXT_UPDATE_STATE.isEqualTo(StateType.AvailableForDownload), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.CheckingForUpdates), CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloading), CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloaded), CONTEXT_UPDATE_STATE.isEqualTo(StateType.Updating), diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 82acbdb1c5be3..6bb6704f60278 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import './agentFeedbackEditorInputContribution.js'; -import './agentFeedbackGlyphMarginContribution.js'; +import './agentFeedbackLineDecorationContribution.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { AgentFeedbackService, IAgentFeedbackService } from './agentFeedbackService.js'; import { AgentFeedbackAttachmentContribution } from './agentFeedbackAttachment.js'; +import { AgentFeedbackAttachmentWidget } from './agentFeedbackAttachmentWidget.js'; import { AgentFeedbackEditorOverlay } from './agentFeedbackEditorOverlay.js'; import { registerAgentFeedbackEditorActions } from './agentFeedbackEditorActions.js'; +import { IChatAttachmentWidgetRegistry } from '../../../../workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.js'; +import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; registerWorkbenchContribution2(AgentFeedbackEditorOverlay.ID, AgentFeedbackEditorOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeedbackAttachmentContribution, WorkbenchPhase.AfterRestored); @@ -18,3 +21,14 @@ registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeed registerAgentFeedbackEditorActions(); registerSingleton(IAgentFeedbackService, AgentFeedbackService, InstantiationType.Delayed); + +// Register the custom attachment widget for agentFeedback attachments +class AgentFeedbackAttachmentWidgetContribution { + static readonly ID = 'workbench.contrib.agentFeedbackAttachmentWidgetFactory'; + constructor(@IChatAttachmentWidgetRegistry registry: IChatAttachmentWidgetRegistry) { + registry.registerFactory('agentFeedback', (instantiationService, attachment, options, container) => { + return instantiationService.createInstance(AgentFeedbackAttachmentWidget, attachment as IAgentFeedbackVariableEntry, options, container); + }); + } +} +registerWorkbenchContribution2(AgentFeedbackAttachmentWidgetContribution.ID, AgentFeedbackAttachmentWidgetContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts index 720eb60b9e2ca..e45f96e488d06 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts @@ -14,7 +14,7 @@ import { IAgentFeedbackService, IAgentFeedback } from './agentFeedbackService.js import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -const ATTACHMENT_ID_PREFIX = 'agentFeedback:'; +export const ATTACHMENT_ID_PREFIX = 'agentFeedback:'; /** * Keeps the "N feedback items" attachment in the chat input in sync with the diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackGlyphMarginContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackGlyphMarginContribution.ts index 6436e692fc2a8..bc7805d4b1720 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackGlyphMarginContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackGlyphMarginContribution.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from '../../../../editor/browser/editorBrowser.js'; import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; -import { GlyphMarginLane, IModelDeltaDecoration, TrackedRangeStickiness } from '../../../../editor/common/model.js'; +import { IModelDeltaDecoration, TrackedRangeStickiness } from '../../../../editor/common/model.js'; import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; import { Range } from '../../../../editor/common/core/range.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -20,19 +20,15 @@ import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browse import { getSessionForResource } from './agentFeedbackEditorUtils.js'; import { Selection } from '../../../../editor/common/core/selection.js'; -const GLYPH_MARGIN_LANE = GlyphMarginLane.Left; - const feedbackGlyphDecoration = ModelDecorationOptions.register({ description: 'agent-feedback-glyph', - glyphMarginClassName: `${ThemeIcon.asClassName(Codicon.comment)} agent-feedback-glyph`, - glyphMargin: { position: GLYPH_MARGIN_LANE }, + linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.comment)} agent-feedback-glyph`, stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, }); const addFeedbackHintDecoration = ModelDecorationOptions.register({ description: 'agent-feedback-add-hint', - glyphMarginClassName: `${ThemeIcon.asClassName(Codicon.add)} agent-feedback-add-hint`, - glyphMargin: { position: GLYPH_MARGIN_LANE }, + linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.add)} agent-feedback-add-hint`, stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, }); @@ -117,9 +113,10 @@ export class AgentFeedbackGlyphMarginContribution extends Disposable implements return; } + const isLineDecoration = e.target.type === MouseTargetType.GUTTER_LINE_DECORATIONS && !e.target.detail.isAfterLines; + const isContentArea = e.target.type === MouseTargetType.CONTENT_TEXT || e.target.type === MouseTargetType.CONTENT_EMPTY; if (e.target.position - && e.target.type === MouseTargetType.GUTTER_GLYPH_MARGIN - && !e.target.detail.isAfterLines + && (isLineDecoration || isContentArea) && !this._feedbackLines.has(e.target.position.lineNumber) ) { this._updateHintDecoration(e.target.position.lineNumber); @@ -150,7 +147,7 @@ export class AgentFeedbackGlyphMarginContribution extends Disposable implements private _onMouseDown(e: IEditorMouseEvent): void { if (!e.target.position - || e.target.type !== MouseTargetType.GUTTER_GLYPH_MARGIN + || e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS || e.target.detail.isAfterLines || !this._sessionResource ) { @@ -174,9 +171,9 @@ export class AgentFeedbackGlyphMarginContribution extends Disposable implements const endColumn = model.getLineLastNonWhitespaceColumn(lineNumber); if (startColumn === 0 || endColumn === 0) { // Empty line - select the whole line range - this._editor.setSelection(new Selection(lineNumber, 1, lineNumber, model.getLineMaxColumn(lineNumber))); + this._editor.setSelection(new Selection(lineNumber, model.getLineMaxColumn(lineNumber), lineNumber, 1)); } else { - this._editor.setSelection(new Selection(lineNumber, startColumn, lineNumber, endColumn)); + this._editor.setSelection(new Selection(lineNumber, endColumn, lineNumber, startColumn)); } this._editor.focus(); } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts new file mode 100644 index 0000000000000..63f29606303a3 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/agentFeedbackLineDecoration.css'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from '../../../../editor/browser/editorBrowser.js'; +import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; +import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; +import { IModelDeltaDecoration, TrackedRangeStickiness } from '../../../../editor/common/model.js'; +import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; +import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { getSessionForResource } from './agentFeedbackEditorUtils.js'; +import { Selection } from '../../../../editor/common/core/selection.js'; + +const feedbackLineDecoration = ModelDecorationOptions.register({ + description: 'agent-feedback-line-decoration', + linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.comment)} agent-feedback-line-decoration`, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, +}); + +const addFeedbackHintDecoration = ModelDecorationOptions.register({ + description: 'agent-feedback-add-hint', + linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.add)} agent-feedback-add-hint`, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, +}); + +export class AgentFeedbackLineDecorationContribution extends Disposable implements IEditorContribution { + + static readonly ID = 'agentFeedback.lineDecorationContribution'; + + private readonly _feedbackDecorations; + + private _hintDecorationId: string | null = null; + private _hintLine = -1; + private _sessionResource: URI | undefined; + private _feedbackLines = new Set(); + + constructor( + private readonly _editor: ICodeEditor, + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + ) { + super(); + + this._feedbackDecorations = this._editor.createDecorationsCollection(); + + this._store.add(this._agentFeedbackService.onDidChangeFeedback(() => this._updateFeedbackDecorations())); + this._store.add(this._editor.onDidChangeModel(() => this._onModelChanged())); + this._store.add(this._editor.onMouseMove((e: IEditorMouseEvent) => this._onMouseMove(e))); + this._store.add(this._editor.onMouseLeave(() => this._updateHintDecoration(-1))); + this._store.add(this._editor.onMouseDown((e: IEditorMouseEvent) => this._onMouseDown(e))); + + this._resolveSession(); + this._updateFeedbackDecorations(); + } + + private _onModelChanged(): void { + this._updateHintDecoration(-1); + this._resolveSession(); + this._updateFeedbackDecorations(); + } + + private _resolveSession(): void { + const model = this._editor.getModel(); + if (!model) { + this._sessionResource = undefined; + return; + } + this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); + } + + private _updateFeedbackDecorations(): void { + if (!this._sessionResource) { + this._feedbackDecorations.clear(); + this._feedbackLines.clear(); + return; + } + + const feedbackItems = this._agentFeedbackService.getFeedback(this._sessionResource); + const decorations: IModelDeltaDecoration[] = []; + const lines = new Set(); + + for (const item of feedbackItems) { + const model = this._editor.getModel(); + if (!model || item.resourceUri.toString() !== model.uri.toString()) { + continue; + } + + const line = item.range.startLineNumber; + lines.add(line); + decorations.push({ + range: new Range(line, 1, line, 1), + options: feedbackLineDecoration, + }); + } + + this._feedbackLines = lines; + this._feedbackDecorations.set(decorations); + } + + private _onMouseMove(e: IEditorMouseEvent): void { + if (!this._sessionResource) { + this._updateHintDecoration(-1); + return; + } + + const isLineDecoration = e.target.type === MouseTargetType.GUTTER_LINE_DECORATIONS && !e.target.detail.isAfterLines; + const isContentArea = e.target.type === MouseTargetType.CONTENT_TEXT || e.target.type === MouseTargetType.CONTENT_EMPTY; + if (e.target.position + && (isLineDecoration || isContentArea) + && !this._feedbackLines.has(e.target.position.lineNumber) + ) { + this._updateHintDecoration(e.target.position.lineNumber); + } else { + this._updateHintDecoration(-1); + } + } + + private _updateHintDecoration(line: number): void { + if (line === this._hintLine) { + return; + } + + this._hintLine = line; + this._editor.changeDecorations(accessor => { + if (this._hintDecorationId) { + accessor.removeDecoration(this._hintDecorationId); + this._hintDecorationId = null; + } + if (line !== -1) { + this._hintDecorationId = accessor.addDecoration( + new Range(line, 1, line, 1), + addFeedbackHintDecoration, + ); + } + }); + } + + private _onMouseDown(e: IEditorMouseEvent): void { + if (!e.target.position + || e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS + || e.target.detail.isAfterLines + || !this._sessionResource + ) { + return; + } + + const lineNumber = e.target.position.lineNumber; + + // Lines with existing feedback - do nothing + if (this._feedbackLines.has(lineNumber)) { + return; + } + + // Select the line content and focus the editor + const model = this._editor.getModel(); + if (!model) { + return; + } + + const startColumn = model.getLineFirstNonWhitespaceColumn(lineNumber); + const endColumn = model.getLineLastNonWhitespaceColumn(lineNumber); + if (startColumn === 0 || endColumn === 0) { + // Empty line - select the whole line range + this._editor.setSelection(new Selection(lineNumber, model.getLineMaxColumn(lineNumber), lineNumber, 1)); + } else { + this._editor.setSelection(new Selection(lineNumber, endColumn, lineNumber, startColumn)); + } + this._editor.focus(); + } + + override dispose(): void { + this._feedbackDecorations.clear(); + this._updateHintDecoration(-1); + super.dispose(); + } +} + +registerEditorContribution(AgentFeedbackLineDecorationContribution.ID, AgentFeedbackLineDecorationContribution, EditorContributionInstantiation.Eventually); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 701495fc188b9..4813a045bafec 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -13,7 +13,6 @@ import { isEqual } from '../../../../base/common/resources.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { agentSessionContainsResource, editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; -import { IChatWidget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; // --- Types -------------------------------------------------------------------- @@ -83,8 +82,6 @@ export interface IAgentFeedbackService { // --- Implementation ----------------------------------------------------------- -const AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX = 'agentFeedback:'; - export class AgentFeedbackService extends Disposable implements IAgentFeedbackService { declare readonly _serviceBrand: undefined; @@ -103,41 +100,8 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe constructor( @IChatEditingService private readonly _chatEditingService: IChatEditingService, @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, - @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, ) { super(); - - this._registerChatWidgetListeners(); - } - - private _registerChatWidgetListeners(): void { - for (const widget of this._chatWidgetService.getAllWidgets()) { - this._registerWidgetListeners(widget); - } - - this._store.add(this._chatWidgetService.onDidAddWidget(widget => { - this._registerWidgetListeners(widget); - })); - } - - private _registerWidgetListeners(widget: IChatWidget): void { - this._store.add(widget.attachmentModel.onDidChange(e => { - for (const deletedId of e.deleted) { - if (!deletedId.startsWith(AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX)) { - continue; - } - - const sessionResourceString = deletedId.slice(AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX.length); - if (!sessionResourceString) { - continue; - } - - const sessionResource = URI.parse(sessionResourceString); - if (this.getFeedback(sessionResource).length > 0) { - this.clearFeedback(sessionResource); - } - } - })); } addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackGlyphMargin.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackGlyphMargin.css index 16aac443e4802..33bf495578f92 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackGlyphMargin.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackGlyphMargin.css @@ -3,24 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.monaco-editor .glyph-margin-widgets .cgmr.agent-feedback-glyph, -.monaco-editor .glyph-margin-widgets .cgmr.agent-feedback-add-hint { +.monaco-editor .agent-feedback-glyph, +.monaco-editor .agent-feedback-add-hint { border-radius: 3px; display: flex !important; align-items: center; justify-content: center; } -.monaco-editor .glyph-margin-widgets .cgmr.agent-feedback-glyph { - background-color: var(--vscode-editorGutter-commentGlyphForeground, var(--vscode-icon-foreground)); - color: var(--vscode-editor-background); +.monaco-editor .agent-feedback-glyph { + background-color: var(--vscode-toolbar-hoverBackground); } -.monaco-editor .glyph-margin-widgets .cgmr.agent-feedback-add-hint { +.monaco-editor .agent-feedback-add-hint { background-color: var(--vscode-toolbar-hoverBackground); opacity: 0.7; } -.monaco-editor .glyph-margin-widgets .cgmr.agent-feedback-add-hint:hover { +.monaco-editor .agent-feedback-add-hint:hover { opacity: 1; } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css new file mode 100644 index 0000000000000..890791c597367 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-editor .agent-feedback-line-decoration, +.monaco-editor .agent-feedback-add-hint { + border-radius: 3px; + display: flex !important; + align-items: center; + justify-content: center; +} + +.monaco-editor .agent-feedback-line-decoration { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.monaco-editor .agent-feedback-add-hint { + background-color: var(--vscode-toolbar-hoverBackground); + opacity: 0.7; +} + +.monaco-editor .agent-feedback-add-hint:hover { + opacity: 1; +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index d671775dbd57c..8a4298cf90953 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -124,10 +124,11 @@ .ai-customization-toolbar .ai-customization-toolbar-content { max-height: 500px; overflow: hidden; - transition: max-height 0.2s ease-out; + transition: max-height 0.2s ease-out, display 0s linear 0.2s; } .ai-customization-toolbar.collapsed .ai-customization-toolbar-content { max-height: 0; + display: none; } } diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.ts new file mode 100644 index 0000000000000..9cf993aa7bb07 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as event from '../../../../../base/common/event.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +/** + * Interface for a contributed attachment widget instance. + */ +export interface IChatAttachmentWidgetInstance extends IDisposable { + readonly element: HTMLElement; + readonly onDidDelete: event.Event; + readonly onDidOpen: event.Event; +} + +/** + * Factory function type for creating attachment widgets. + * Receives the instantiation service so it can create DI-injected widget instances. + */ +export type ChatAttachmentWidgetFactory = ( + instantiationService: IInstantiationService, + attachment: IChatRequestVariableEntry, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, + container: HTMLElement, +) => IChatAttachmentWidgetInstance; + +export const IChatAttachmentWidgetRegistry = createDecorator('chatAttachmentWidgetRegistry'); + +export interface IChatAttachmentWidgetRegistry { + readonly _serviceBrand: undefined; + + /** + * Register a widget factory for a specific attachment kind. + */ + registerFactory(kind: string, factory: ChatAttachmentWidgetFactory): IDisposable; + + /** + * Try to create a widget for the given attachment using a registered factory. + * Returns undefined if no factory is registered for the attachment's kind. + */ + createWidget( + instantiationService: IInstantiationService, + attachment: IChatRequestVariableEntry, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, + container: HTMLElement, + ): IChatAttachmentWidgetInstance | undefined; +} + +export class ChatAttachmentWidgetRegistry implements IChatAttachmentWidgetRegistry { + + declare readonly _serviceBrand: undefined; + + private readonly _factories = new Map(); + + registerFactory(kind: string, factory: ChatAttachmentWidgetFactory): IDisposable { + this._factories.set(kind, factory); + return { + dispose: () => { + if (this._factories.get(kind) === factory) { + this._factories.delete(kind); + } + } + }; + } + + createWidget( + instantiationService: IInstantiationService, + attachment: IChatRequestVariableEntry, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, + container: HTMLElement, + ): IChatAttachmentWidgetInstance | undefined { + const factory = this._factories.get(attachment.kind); + if (!factory) { + return undefined; + } + return factory(instantiationService, attachment, options, container); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index e882231c7e131..356a7b23b48b7 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -96,6 +96,7 @@ import { ChatAccessibilityService } from './accessibility/chatAccessibilityServi import './attachments/chatAttachmentModel.js'; import './widget/input/chatStatusWidget.js'; import { ChatAttachmentResolveService, IChatAttachmentResolveService } from './attachments/chatAttachmentResolveService.js'; +import { ChatAttachmentWidgetRegistry, IChatAttachmentWidgetRegistry } from './attachments/chatAttachmentWidgetRegistry.js'; import { ChatMarkdownAnchorService, IChatMarkdownAnchorService } from './widget/chatContentParts/chatMarkdownAnchorService.js'; import { ChatContextPickService, IChatContextPickService } from './attachments/chatContextPickService.js'; import { ChatInputBoxContentProvider } from './widget/input/editor/chatEditorInputContentProvider.js'; @@ -1542,6 +1543,7 @@ registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); +registerSingleton(IChatAttachmentWidgetRegistry, ChatAttachmentWidgetRegistry, InstantiationType.Delayed); registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.Delayed); registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 3c47743a275b8..68bf52c73775f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -100,6 +100,7 @@ import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionCon import { AgentSessionProviders, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; +import { IChatAttachmentWidgetRegistry } from '../../attachments/chatAttachmentWidgetRegistry.js'; import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; @@ -512,6 +513,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, + @IChatAttachmentWidgetRegistry private readonly _chatAttachmentWidgetRegistry: IChatAttachmentWidgetRegistry, ) { super(); @@ -2439,7 +2441,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } else if (isSCMHistoryItemChangeRangeVariableEntry(attachment)) { attachmentWidget = this.instantiationService.createInstance(SCMHistoryItemChangeRangeAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels); } else { - attachmentWidget = this.instantiationService.createInstance(DefaultChatAttachmentWidget, resource, range, attachment, undefined, lm, options, container, this._contextResourceLabels); + attachmentWidget = this._chatAttachmentWidgetRegistry.createWidget(this.instantiationService, attachment, options, container) + ?? this.instantiationService.createInstance(DefaultChatAttachmentWidget, resource, range, attachment, undefined, lm, options, container, this._contextResourceLabels); } if (shouldFocusClearButton) { From 12b124d4bb2649de89f6a4f2381567c564ececee Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 15:15:07 -0800 Subject: [PATCH 19/31] Update label for file picker in NewChatContextAttachments to improve clarity --- .../sessions/contrib/chat/browser/newChatContextAttachments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 001967b2bbc9e..60bbeccb57a2e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -179,7 +179,7 @@ export class NewChatContextAttachments extends Disposable { const staticPicks: (IQuickPickItem | IQuickPickSeparator)[] = [ { - label: localize('filesAndFolders', "Files and Open Folders..."), + label: localize('files', "Files..."), iconClass: ThemeIcon.asClassName(Codicon.file), id: 'sessions.filesAndFolders', }, From 7b45411d7851b274401150ac080ac446b7669ec1 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 20 Feb 2026 10:27:29 +1100 Subject: [PATCH 20/31] Slash command fixes for background agents (#296389) --- .../contrib/chat/browser/widget/chatWidget.ts | 2 +- .../input/editor/chatInputEditorContrib.ts | 3 +- .../common/chatService/chatServiceImpl.ts | 34 +++++++++++-------- .../common/requestParser/chatRequestParser.ts | 3 +- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 74cea0f3871ed..4134d04ad8231 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2403,7 +2403,7 @@ export class ChatWidget extends Disposable implements IChatWidget { userSelectedModelId: this.input.currentLanguageModel, location: this.location, locationData: this._location.resolveData?.(), - parserContext: { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind }, + parserContext: { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, attachmentCapabilities: this._lastSelectedAgent?.capabilities ?? this.attachmentCapabilities }, attachedContext: requestInputs.attachedContext.asArray(), resolvedVariables: resolvedImageVariables, noCommandDetection: options?.noCommandDetection, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts index 477895be331c5..ce838a3e0a146 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts @@ -410,7 +410,8 @@ class ChatTokenDeleter extends Disposable { // If this was a simple delete, try to find out whether it was inside a token if (!change.text && this.widget.viewModel) { - const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionResource, previousInputValue, widget.location, { selectedAgent: previousSelectedAgent, mode: this.widget.input.currentModeKind }); + const attachmentCapabilities = previousSelectedAgent?.capabilities ?? this.widget.attachmentCapabilities; + const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionResource, previousInputValue, widget.location, { selectedAgent: previousSelectedAgent, mode: this.widget.input.currentModeKind, attachmentCapabilities }); // For dynamic variables, this has to happen in ChatDynamicVariableModel with the other bookkeeping const deletableTokens = previousParsedValue.parts.filter(p => p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashCommandPart || p instanceof ChatRequestSlashPromptPart || p instanceof ChatRequestToolPart); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index eba0df56ba90c..e2b5b29c409fe 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -869,7 +869,7 @@ export class ChatService extends Disposable implements IChatService { private _sendRequestAsync(model: ChatModel, sessionResource: URI, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, defaultAgent: IChatAgentData, location: ChatAgentLocation, options?: IChatSendRequestOptions): IChatSendRequestResponseState { const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionResource); - let request: ChatRequestModel; + let request: ChatRequestModel | undefined; const agentPart = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); const commandPart = parsedRequest.parts.find((r): r is ChatRequestSlashCommandPart => r instanceof ChatRequestSlashCommandPart); @@ -917,7 +917,9 @@ export class ChatService extends Disposable implements IChatService { this.trace('sendRequest', `Provider returned progress: ${JSON.stringify(progressItem)}`); } - model.acceptResponseProgress(request, progressItem, !isLast); + if (request) { + model.acceptResponseProgress(request, progressItem, !isLast); + } } completeResponseCreated(); }; @@ -1017,7 +1019,7 @@ export class ChatService extends Disposable implements IChatService { return; } - if (tools) { + if (tools && request) { this.chatAgentService.setRequestTools(agent.id, request.id, tools); // in case the request has not been sent out yet: agentRequest.userSelectedTools = tools; @@ -1048,7 +1050,7 @@ export class ChatService extends Disposable implements IChatService { const result = await this.chatAgentService.detectAgentOrCommand(chatAgentRequest, defaultAgentHistory, { location }, token); if (result && this.chatAgentService.getAgent(result.agent.id)?.locations?.includes(location)) { // Update the response in the ChatModel to reflect the detected agent and command - request.response?.setAgent(result.agent, result.command); + request?.response?.setAgent(result.agent, result.command); detectedAgent = result.agent; detectedCommand = result.command; } @@ -1066,7 +1068,7 @@ export class ChatService extends Disposable implements IChatService { const pendingRequest = this._pendingRequests.get(sessionResource); if (pendingRequest) { store.add(autorun(reader => { - if (pendingRequest.yieldRequested.read(reader)) { + if (pendingRequest.yieldRequested.read(reader) && request) { this.chatAgentService.setYieldRequested(agent.id, request.id); } })); @@ -1121,7 +1123,9 @@ export class ChatService extends Disposable implements IChatService { throw new Error(`Cannot handle request`); } - if (token.isCancellationRequested && !rawResult) { + if ((token.isCancellationRequested && !rawResult)) { + return; + } else if (!request) { return; } else { if (!rawResult) { @@ -1155,7 +1159,7 @@ export class ChatService extends Disposable implements IChatService { request.response?.complete(); if (agentOrCommandFollowups) { agentOrCommandFollowups.then(followups => { - model.setFollowups(request, followups); + model.setFollowups(request!, followups); const commandForTelemetry = agentSlashCommandPart ? agentSlashCommandPart.command.name : commandPart?.slashCommand.command; this._chatServiceTelemetry.retrievedFollowups(agentPart?.agent.id ?? '', commandForTelemetry, followups?.length ?? 0); }); @@ -1163,15 +1167,15 @@ export class ChatService extends Disposable implements IChatService { } } catch (err) { this.logService.error(`Error while handling chat request: ${toErrorMessage(err, true)}`); - requestTelemetry.complete({ - timeToFirstProgress: undefined, - totalTime: undefined, - result: 'error', - requestType, - detectedAgent, - request, - }); if (request) { + requestTelemetry.complete({ + timeToFirstProgress: undefined, + totalTime: undefined, + result: 'error', + requestType, + detectedAgent, + request, + }); const rawResult: IChatAgentResult = { errorDetails: { message: err.message } }; model.setResponse(request, rawResult); completeResponseCreated(); diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts index 9994bcffbcb8a..9ec0508a489f2 100644 --- a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts @@ -218,7 +218,8 @@ export class ChatRequestParser { } } - if (!usedAgent || context?.attachmentCapabilities?.supportsPromptAttachments) { + const capabilities = context?.attachmentCapabilities ?? usedAgent?.capabilities ?? context?.attachmentCapabilities; + if (!usedAgent || capabilities?.supportsPromptAttachments) { const slashCommands = this.slashCommandService.getCommands(location, context?.mode ?? ChatModeKind.Ask); const slashCommand = slashCommands.find(c => c.command === command); if (slashCommand) { From 25bf440776fbbdd0befdcc3a0d1844af0c830581 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 20 Feb 2026 00:30:51 +0100 Subject: [PATCH 21/31] fix layout (#296397) * fix layout * fix: prevent adding unnecessary separators in model picker items --- .../actionWidget/browser/actionList.ts | 69 ++++++++++--------- .../actionWidget/browser/actionWidget.ts | 27 ++++++++ .../browser/widget/input/chatModelPicker.ts | 8 ++- 3 files changed, 70 insertions(+), 34 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index b3d1ed909f94c..c362972f1d770 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -488,33 +488,6 @@ export class ActionList extends Disposable { this._filterText = this._filterInput!.value; this._applyFilter(); })); - - // Keyboard navigation from filter input - this._register(dom.addDisposableListener(this._filterInput, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'ArrowUp') { - e.preventDefault(); - this._list.domFocus(); - const lastIndex = this._list.length - 1; - if (lastIndex >= 0) { - this._list.focusLast(undefined, this.focusCondition); - } - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - this._list.domFocus(); - this.focusNext(); - } else if (e.key === 'Enter') { - e.preventDefault(); - this.acceptSelected(); - } else if (e.key === 'Escape') { - if (this._filterText) { - e.preventDefault(); - e.stopPropagation(); - this._filterInput!.value = ''; - this._filterText = ''; - this._applyFilter(); - } - } - })); } this._applyFilter(); @@ -546,10 +519,10 @@ export class ActionList extends Disposable { } else { this._collapsedSections.add(section); } - this._applyFilter(true); + this._applyFilter(); } - private _applyFilter(reposition?: boolean): void { + private _applyFilter(): void { const filterLower = this._filterText.toLowerCase(); const isFiltering = filterLower.length > 0; const visible: IActionListItem[] = []; @@ -647,9 +620,7 @@ export class ActionList extends Disposable { } } // Reposition the context view so the widget grows in the correct direction - if (reposition) { - this._contextViewService.layout(); - } + this._contextViewService.layout(); } } @@ -708,6 +679,16 @@ export class ActionList extends Disposable { this._contextViewService.hideContextView(); } + clearFilter(): boolean { + if (this._filterInput && this._filterText) { + this._filterInput.value = ''; + this._filterText = ''; + this._applyFilter(); + return true; + } + return false; + } + private hasDynamicHeight(): boolean { if (this._options?.showFilter) { return true; @@ -867,17 +848,41 @@ export class ActionList extends Disposable { } focusPrevious() { + if (this._filterInput && dom.isActiveElement(this._filterInput)) { + this._list.domFocus(); + this._list.focusLast(undefined, this.focusCondition); + return; + } + const previousFocus = this._list.getFocus(); this._list.focusPrevious(1, true, undefined, this.focusCondition); const focused = this._list.getFocus(); if (focused.length > 0) { + // If focus wrapped (was at first focusable, now at last), move to filter instead + if (this._filterInput && previousFocus.length > 0 && focused[0] > previousFocus[0]) { + this._list.setFocus([]); + this._filterInput.focus(); + return; + } this._list.reveal(focused[0]); } } focusNext() { + if (this._filterInput && dom.isActiveElement(this._filterInput)) { + this._list.domFocus(); + this._list.focusFirst(undefined, this.focusCondition); + return; + } + const previousFocus = this._list.getFocus(); this._list.focusNext(1, true, undefined, this.focusCondition); const focused = this._list.getFocus(); if (focused.length > 0) { + // If focus wrapped (was at last focusable, now at first), move to filter instead + if (this._filterInput && previousFocus.length > 0 && focused[0] < previousFocus[0]) { + this._list.setFocus([]); + this._filterInput.focus(); + return; + } this._list.reveal(focused[0]); } } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 3e43f12b1957c..58792a384ff5a 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -103,6 +103,10 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { return this._list?.value?.toggleFocusedSection() ?? false; } + clearFilter(): boolean { + return this._list?.value?.clearFilter() ?? false; + } + hide(didCancel?: boolean) { this._list.value?.hide(didCancel); this._list.clear(); @@ -220,6 +224,29 @@ registerAction2(class extends Action2 { } }); +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'clearFilterCodeActionWidget', + title: localize2('clearFilterCodeActionWidget.title', "Clear action widget filter"), + precondition: ContextKeyExpr.and(ActionWidgetContextKeys.Visible, ActionWidgetContextKeys.FilterFocused), + keybinding: { + weight: weight + 1, + primary: KeyCode.Escape, + } + }); + } + + run(accessor: ServicesAccessor): void { + const widgetService = accessor.get(IActionWidgetService); + if (widgetService instanceof ActionWidgetService) { + if (!widgetService.clearFilter()) { + widgetService.hide(true); + } + } + } +}); + registerAction2(class extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 71fcfc6db1ac2..c72acf6757ee1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -252,7 +252,9 @@ export function buildModelPickerItems( return aName.localeCompare(bName); }); - items.push({ kind: ActionListItemKind.Separator }); + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator }); + } for (const item of promotedItems) { if (item.kind === 'available') { items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model)); @@ -276,7 +278,9 @@ export function buildModelPickerItems( }); if (otherModels.length > 0) { - items.push({ kind: ActionListItemKind.Separator }); + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator }); + } items.push({ item: { id: 'otherModels', From 688d4bc7dc64da3b190eba64488e4bd61766a700 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:31:02 -0800 Subject: [PATCH 22/31] make session allow the default (#296399) * make session allow the default * only apply to tools for now --- .../languageModelToolsConfirmationService.ts | 12 +++++++ .../abstractToolConfirmationSubPart.ts | 36 +++++++++++++++---- .../chatTerminalToolConfirmationSubPart.ts | 1 + .../chatToolConfirmationSubPart.ts | 1 + .../chatToolPostExecuteConfirmationPart.ts | 1 + .../languageModelToolsConfirmationService.ts | 2 ++ 6 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts index bb23081defd6c..50a3495c4f845 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts @@ -267,6 +267,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements label: localize('allowSession', 'Allow in this Session'), detail: localize('allowSessionTooltip', 'Allow this tool to run in this session without confirmation.'), divider: !!actions.length, + scope: 'session', select: async () => { this._preExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'session'); return true; @@ -275,6 +276,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowWorkspace', 'Allow in this Workspace'), detail: localize('allowWorkspaceTooltip', 'Allow this tool to run in this workspace without confirmation.'), + scope: 'workspace', select: async () => { this._preExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'workspace'); return true; @@ -283,6 +285,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowGlobally', 'Always Allow'), detail: localize('allowGloballyTooltip', 'Always allow this tool to run without confirmation.'), + scope: 'profile', select: async () => { this._preExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'profile'); return true; @@ -298,6 +301,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements label: localize('allowServerSession', 'Allow Tools from {0} in this Session', serverLabel), detail: localize('allowServerSessionTooltip', 'Allow all tools from this server to run in this session without confirmation.'), divider: true, + scope: 'session', select: async () => { this._preExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'session'); return true; @@ -306,6 +310,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowServerWorkspace', 'Allow Tools from {0} in this Workspace', serverLabel), detail: localize('allowServerWorkspaceTooltip', 'Allow all tools from this server to run in this workspace without confirmation.'), + scope: 'workspace', select: async () => { this._preExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'workspace'); return true; @@ -314,6 +319,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowServerGlobally', 'Always Allow Tools from {0}', serverLabel), detail: localize('allowServerGloballyTooltip', 'Always allow all tools from this server to run without confirmation.'), + scope: 'profile', select: async () => { this._preExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'profile'); return true; @@ -345,6 +351,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements label: localize('allowSessionPost', 'Allow Without Review in this Session'), detail: localize('allowSessionPostTooltip', 'Allow results from this tool to be sent without confirmation in this session.'), divider: !!actions.length, + scope: 'session', select: async () => { this._postExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'session'); return true; @@ -353,6 +360,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowWorkspacePost', 'Allow Without Review in this Workspace'), detail: localize('allowWorkspacePostTooltip', 'Allow results from this tool to be sent without confirmation in this workspace.'), + scope: 'workspace', select: async () => { this._postExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'workspace'); return true; @@ -361,6 +369,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowGloballyPost', 'Always Allow Without Review'), detail: localize('allowGloballyPostTooltip', 'Always allow results from this tool to be sent without confirmation.'), + scope: 'profile', select: async () => { this._postExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'profile'); return true; @@ -376,6 +385,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements label: localize('allowServerSessionPost', 'Allow Tools from {0} Without Review in this Session', serverLabel), detail: localize('allowServerSessionPostTooltip', 'Allow results from all tools from this server to be sent without confirmation in this session.'), divider: true, + scope: 'session', select: async () => { this._postExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'session'); return true; @@ -384,6 +394,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowServerWorkspacePost', 'Allow Tools from {0} Without Review in this Workspace', serverLabel), detail: localize('allowServerWorkspacePostTooltip', 'Allow results from all tools from this server to be sent without confirmation in this workspace.'), + scope: 'workspace', select: async () => { this._postExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'workspace'); return true; @@ -392,6 +403,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowServerGloballyPost', 'Always Allow Tools from {0} Without Review', serverLabel), detail: localize('allowServerGloballyPostTooltip', 'Always allow results from all tools from this server to be sent without confirmation.'), + scope: 'profile', select: async () => { this._postExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'profile'); return true; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts index b54e72a3215fa..82b983d3483aa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts @@ -27,7 +27,11 @@ export interface IToolConfirmationConfig { subtitle?: string; } -type AbstractToolPrimaryAction = IChatConfirmationButton<(() => void)> | Separator; +interface IAbstractToolPrimaryAction extends IChatConfirmationButton<(() => void)> { + scope?: 'session' | 'workspace' | 'profile'; +} + +type AbstractToolPrimaryAction = IAbstractToolPrimaryAction | Separator; /** * Base class for a tool confirmation. @@ -75,14 +79,32 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca const skipTooltip = keybindingService.appendKeybinding(config.skipLabel, config.skipActionId); const additionalActions = this.additionalPrimaryActions(); + + // find session scoped action + const sessionAction = additionalActions.find( + (action): action is IAbstractToolPrimaryAction => 'scope' in action && action.scope === 'session' + ); + + // regular allow action + const allowAction: IAbstractToolPrimaryAction = { + label: config.allowLabel, + tooltip: allowTooltip, + data: () => { this.confirmWith(toolInvocation, { type: ToolConfirmKind.UserAction }); }, + }; + + const primaryAction = sessionAction ?? allowAction; + + // rebuild additional list with allow action + const moreActions = sessionAction + ? [allowAction, ...additionalActions.filter(a => a !== sessionAction)] + : additionalActions; + buttons = [ { - label: config.allowLabel, - tooltip: allowTooltip, - data: () => { - this.confirmWith(toolInvocation, { type: ToolConfirmKind.UserAction }); - }, - moreActions: additionalActions.length > 0 ? additionalActions : undefined, + label: primaryAction.label, + tooltip: primaryAction.tooltip, + data: primaryAction.data, + moreActions: moreActions.length > 0 ? moreActions : undefined, }, { label: localize('skip', "Skip"), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index f30c20e399b1f..2386b799b6c20 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -392,6 +392,7 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS const tooltip = this.keybindingService.appendKeybinding(tooltipDetail, actionId); return { label, tooltip }; }; + return [ { ...getLabelAndTooltip(localize('tool.allow', "Allow"), AcceptToolConfirmationActionId), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index ffecd3be04596..9ebf247ad9432 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -104,6 +104,7 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { actions.push({ label: action.label, tooltip: action.detail, + scope: action.scope, data: async () => { const shouldConfirm = await action.select(); if (shouldConfirm) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts index dfe354e2514dc..4805392c6db60 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts @@ -87,6 +87,7 @@ export class ChatToolPostExecuteConfirmationPart extends AbstractToolConfirmatio actions.push({ label: action.label, tooltip: action.detail, + scope: action.scope, data: async () => { const shouldConfirm = await action.select(); if (shouldConfirm) { diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts index 96ea253b75497..d77bd07c0c0f5 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts @@ -17,6 +17,8 @@ export interface ILanguageModelToolConfirmationActions { detail?: string; /** Show a separator before this action */ divider?: boolean; + /** The scope of this action, if applicable */ + scope?: 'session' | 'workspace' | 'profile'; /** Selects this action. Resolves true if the action should be confirmed after selection */ select(): Promise; } From 28f636e2b6c9b6081c024008d78ffc30e4fd8aaf Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:31:55 -0800 Subject: [PATCH 23/31] chore: adjust Windows BinSkim filter (#296394) --- build/azure-pipelines/product-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index d8fddac7c3daf..420fcee4958d5 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -172,7 +172,7 @@ extends: enabled: true configFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/tsaoptions.json binskim: - analyzeTargetGlob: '+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.exe;+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.dll;+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.node;-:file|$(Agent.BuildDirectory)/VSCode-*/**/resources/**/*.node;-:file|$(Build.SourcesDirectory)/.build/**/system-setup/VSCodeSetup*.exe;-:file|$(Build.SourcesDirectory)/.build/**/user-setup/VSCodeUserSetup*.exe' + analyzeTargetGlob: '+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.exe;+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.dll;+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.node;-:file|$(Agent.BuildDirectory)/VSCode-*/**/resources/**/*.exe;-:file|$(Agent.BuildDirectory)/VSCode-*/**/resources/**/*.node;-:file|$(Build.SourcesDirectory)/.build/**/system-setup/VSCodeSetup*.exe;-:file|$(Build.SourcesDirectory)/.build/**/user-setup/VSCodeUserSetup*.exe' codeql: runSourceLanguagesInSourceAnalysis: true compiled: From d59af2277099bc06947659496ed176aaac40b88e Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 15:46:36 -0800 Subject: [PATCH 24/31] Sessions window: image copy/paste handler --- .../chat/browser/newChatContextAttachments.ts | 63 +++++++++++++++++++ .../contrib/chat/browser/newChatViewPane.ts | 1 + 2 files changed, 64 insertions(+) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index ca42f1b451575..60588f161579e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -36,6 +36,7 @@ import { getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; * - File picker via quick access ("Files and Open Folders...") * - Image from Clipboard * - Drag and drop files + * - Paste images from clipboard (Ctrl/Cmd+V) */ export class NewChatContextAttachments extends Disposable { @@ -168,6 +169,68 @@ export class NewChatContextAttachments extends Disposable { })); } + // --- Paste --- + + registerPasteHandler(element: HTMLElement): void { + const supportedMimeTypes = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/bmp', + 'image/gif', + 'image/tiff' + ]; + + this._register(dom.addDisposableListener(element, dom.EventType.PASTE, async (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) { + return; + } + + // Check synchronously for image data before any async work + // so preventDefault stops the editor from inserting text. + let imageFile: File | undefined; + for (const item of Array.from(items)) { + if (!item.type.startsWith('image/') || !supportedMimeTypes.includes(item.type)) { + continue; + } + const file = item.getAsFile(); + if (file) { + imageFile = file; + break; + } + } + + if (!imageFile) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const arrayBuffer = await imageFile.arrayBuffer(); + const data = new Uint8Array(arrayBuffer); + if (!isImage(data)) { + return; + } + + const resizedData = await resizeImage(data); + const displayName = localize('pastedImage', "Pasted Image"); + let tempDisplayName = displayName; + for (let appendValue = 2; this._attachedContext.some(a => a.name === tempDisplayName); appendValue++) { + tempDisplayName = `${displayName} ${appendValue}`; + } + + this._addAttachments({ + id: await imageToHash(resizedData), + name: tempDisplayName, + fullName: tempDisplayName, + value: resizedData, + kind: 'image', + }); + }, true)); + } + // --- Picker --- showPicker(): void { diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index eae4d116d1b09..15bfafb1e29a7 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -319,6 +319,7 @@ class NewChatWidget extends Disposable { // Input area inside the input slot const inputArea = dom.$('.sessions-chat-input-area'); this._contextAttachments.registerDropTarget(inputArea); + this._contextAttachments.registerPasteHandler(inputArea); // Attachments row (plus button + pills) inside input area, above editor const attachRow = dom.append(inputArea, dom.$('.sessions-chat-attach-row')); From 478c7156cbb88b5b70c951805a906cd2916a90b5 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Feb 2026 15:58:56 -0800 Subject: [PATCH 25/31] Review comments --- .../chat/browser/newChatContextAttachments.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 60588f161579e..7c9f50f3481ad 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -214,17 +214,13 @@ export class NewChatContextAttachments extends Disposable { return; } - const resizedData = await resizeImage(data); - const displayName = localize('pastedImage', "Pasted Image"); - let tempDisplayName = displayName; - for (let appendValue = 2; this._attachedContext.some(a => a.name === tempDisplayName); appendValue++) { - tempDisplayName = `${displayName} ${appendValue}`; - } + const resizedData = await resizeImage(data, imageFile.type); + const displayName = this._getUniqueImageName(); this._addAttachments({ id: await imageToHash(resizedData), - name: tempDisplayName, - fullName: tempDisplayName, + name: displayName, + fullName: displayName, value: resizedData, kind: 'image', }); @@ -337,10 +333,12 @@ export class NewChatContextAttachments extends Disposable { return; } + const displayName = this._getUniqueImageName(); + this._addAttachments({ id: await imageToHash(imageData), - name: localize('pastedImage', "Pasted Image"), - fullName: localize('pastedImage', "Pasted Image"), + name: displayName, + fullName: displayName, value: imageData, kind: 'image', }); @@ -348,6 +346,15 @@ export class NewChatContextAttachments extends Disposable { // --- State management --- + private _getUniqueImageName(): string { + const baseName = localize('pastedImage', "Pasted Image"); + let name = baseName; + for (let i = 2; this._attachedContext.some(a => a.name === name); i++) { + name = `${baseName} ${i}`; + } + return name; + } + private _addAttachments(...entries: IChatRequestVariableEntry[]): void { for (const entry of entries) { if (!this._attachedContext.some(e => e.id === entry.id)) { From 7081c98cc438c6a92ea39d4cf28f8dc02da67315 Mon Sep 17 00:00:00 2001 From: David Dossett <25163139+daviddossett@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:18:49 -0800 Subject: [PATCH 26/31] Fix missing border beneath markdown table header row (#296385) The tr:last-child rule was removing the bottom border from thead th cells since the header row is always the last (and only) child of thead. Scope the rule to tbody only so the header border is preserved. --- src/vs/workbench/contrib/chat/browser/widget/media/chat.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 9ff7e671ca371..4f9bbd680332a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -329,8 +329,7 @@ border-right: none; } -.interactive-item-container .value .rendered-markdown table tr:last-child td, -.interactive-item-container .value .rendered-markdown table tr:last-child th { +.interactive-item-container .value .rendered-markdown table tbody tr:last-child td { border-bottom: none; } From 603ec3dfd4ee4ccc9ac394e2eabde4890f2ea518 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 20 Feb 2026 12:58:52 +1100 Subject: [PATCH 27/31] Wait for request to be handled before disposing chat model (#296421) --- .../chatSessions/chatSessions.contribution.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index aab01ee1d492a..ada489b06acfe 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -576,8 +576,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ if (chatOptions) { const resource = URI.revive(chatOptions.resource); const ref = await chatService.loadSessionForResource(resource, ChatAgentLocation.Chat, CancellationToken.None); - await chatService.sendRequest(resource, chatOptions.prompt, { agentIdSilent: type, attachedContext: chatOptions.attachedContext }); - ref?.dispose(); + try { + const result = await chatService.sendRequest(resource, chatOptions.prompt, { agentIdSilent: type, attachedContext: chatOptions.attachedContext }); + if (result.kind === 'queued') { + await result.deferred; + } else if (result.kind === 'sent') { + await result.data.responseCompletePromise; + } + } finally { + ref?.dispose(); + } } } }), From 614c47a41c5cc94b377be3ea0f52aa4ee0cd6515 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:00:05 -0800 Subject: [PATCH 28/31] fix rerendering and duplication (#296406) * fix rerendering and duplication * fix parent issue --- .../contrib/chat/browser/widget/chatListRenderer.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index aac466afd0536..62dc18fafa583 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1216,6 +1216,15 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer Date: Thu, 19 Feb 2026 18:05:59 -0800 Subject: [PATCH 29/31] add spacing between large thinking chunks (#296423) --- .../widget/chatContentParts/media/chatThinkingContent.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 553704389064c..3a2d4c3cef96b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -233,6 +233,10 @@ .rendered-markdown > p { margin: 0; + + & + p { + margin-top: 5px; + } } } From 3c4f8c24bec30a0d1456e2d5bb287460625324a9 Mon Sep 17 00:00:00 2001 From: Paul Date: Thu, 19 Feb 2026 18:42:31 -0800 Subject: [PATCH 30/31] Add ~/.copilot/instructions (#296424) --- .../workbench/contrib/chat/browser/chat.contribution.ts | 8 +++----- .../common/promptSyntax/config/promptFileLocations.ts | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 59c3f042f6d1a..9c86e875e9b01 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -51,7 +51,7 @@ import { ILanguageModelToolsConfirmationService } from '../common/tools/language import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; +import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS, DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS } from '../common/promptSyntax/config/promptFileLocations.js'; import { PromptLanguageFeaturesProvider } from '../common/promptSyntax/promptFileContributions.js'; import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; import { hookFileSchema, HOOK_SCHEMA_URI, HOOK_FILE_GLOB } from '../common/promptSyntax/hookSchema.js'; @@ -748,8 +748,7 @@ configurationRegistry.registerConfiguration({ INSTRUCTIONS_DOCUMENTATION_URL, ), default: { - [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, - [CLAUDE_RULES_SOURCE_FOLDER]: true, + ...DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS.map((folder) => ({ [folder.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), }, additionalProperties: { type: 'boolean' }, propertyNames: { @@ -760,8 +759,7 @@ configurationRegistry.registerConfiguration({ tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], examples: [ { - [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, - [CLAUDE_RULES_SOURCE_FOLDER]: true, + [DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS[0].path]: true, }, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index db61519b2b852..ed04e3aa641e2 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -167,6 +167,7 @@ export const DEFAULT_SKILL_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ export const DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ { path: INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, { path: CLAUDE_RULES_SOURCE_FOLDER, source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, + { path: '~/.copilot/instructions', source: PromptFileSource.CopilotPersonal, storage: PromptsStorage.user }, { path: '~/' + CLAUDE_RULES_SOURCE_FOLDER, source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, ]; From 08106a9ff7dfc4af5058035c563f4c246cfc311c Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 19 Feb 2026 19:35:08 -0800 Subject: [PATCH 31/31] mcp: add resource support to gateway (#296402) * mcp: add resource support to gateway Extends the MCP gateway to support resources alongside tools: - Adds resource-related interfaces to IMcpGatewayToolInvoker: onDidChangeResources, listResources, readResource, listResourceTemplates - Implements resource URI encoding/decoding to namespace resources from different MCP servers using server index suffixes (e.g., 'file:///resource-0') - Adds handlers for resources/list, resources/read, resources/templates/list RPC methods - Broadcasts notifications/resources/list_changed when resources change - Updates McpGatewayToolBrokerChannel to enumerate resources from all servers and track server indices for resource URI namespacing - Adds comprehensive test coverage for resource operations and URI encoding/decoding (Commit message generated by Copilot) * fix test --- src/vs/platform/mcp/common/mcpGateway.ts | 21 +- src/vs/platform/mcp/node/mcpGatewayChannel.ts | 8 +- src/vs/platform/mcp/node/mcpGatewaySession.ts | 129 ++++++++++++- .../mcp/test/node/mcpGatewaySession.test.ts | 182 +++++++++++++++++- .../mcp/common/mcpGatewayToolBrokerChannel.ts | 124 +++++++++++- .../mcpGatewayToolBrokerChannel.test.ts | 9 +- 6 files changed, 447 insertions(+), 26 deletions(-) diff --git a/src/vs/platform/mcp/common/mcpGateway.ts b/src/vs/platform/mcp/common/mcpGateway.ts index e8d2194d37a6b..27fd54269229e 100644 --- a/src/vs/platform/mcp/common/mcpGateway.ts +++ b/src/vs/platform/mcp/common/mcpGateway.ts @@ -13,10 +13,29 @@ export const IMcpGatewayService = createDecorator('IMcpGatew export const McpGatewayChannelName = 'mcpGateway'; export const McpGatewayToolBrokerChannelName = 'mcpGatewayToolBroker'; +export interface IGatewayCallToolResult { + result: MCP.CallToolResult; + serverIndex: number; +} + +export interface IGatewayServerResources { + serverIndex: number; + resources: readonly MCP.Resource[]; +} + +export interface IGatewayServerResourceTemplates { + serverIndex: number; + resourceTemplates: readonly MCP.ResourceTemplate[]; +} + export interface IMcpGatewayToolInvoker { readonly onDidChangeTools: Event; + readonly onDidChangeResources: Event; listTools(): Promise; - callTool(name: string, args: Record): Promise; + callTool(name: string, args: Record): Promise; + listResources(): Promise; + readResource(serverIndex: number, uri: string): Promise; + listResourceTemplates(): Promise; } /** diff --git a/src/vs/platform/mcp/node/mcpGatewayChannel.ts b/src/vs/platform/mcp/node/mcpGatewayChannel.ts index d8a976f3308a2..0b0ce1edb0ac8 100644 --- a/src/vs/platform/mcp/node/mcpGatewayChannel.ts +++ b/src/vs/platform/mcp/node/mcpGatewayChannel.ts @@ -6,7 +6,7 @@ import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; +import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates, IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; import { MCP } from '../common/modelContextProtocol.js'; /** @@ -35,8 +35,12 @@ export class McpGatewayChannel extends Disposable implements IServerCh const brokerChannel = ipcChannelForContext(this._ipcServer, ctx); const result = await this.mcpGatewayService.createGateway(ctx, { onDidChangeTools: brokerChannel.listen('onDidChangeTools'), + onDidChangeResources: brokerChannel.listen('onDidChangeResources'), listTools: () => brokerChannel.call('listTools'), - callTool: (name, callArgs) => brokerChannel.call('callTool', { name, args: callArgs }), + callTool: (name, callArgs) => brokerChannel.call('callTool', { name, args: callArgs }), + listResources: () => brokerChannel.call('listResources'), + readResource: (serverIndex, uri) => brokerChannel.call('readResource', { serverIndex, uri }), + listResourceTemplates: () => brokerChannel.call('listResourceTemplates'), }); return result as T; } diff --git a/src/vs/platform/mcp/node/mcpGatewaySession.ts b/src/vs/platform/mcp/node/mcpGatewaySession.ts index 691453ec05932..836d6571e3b5b 100644 --- a/src/vs/platform/mcp/node/mcpGatewaySession.ts +++ b/src/vs/platform/mcp/node/mcpGatewaySession.ts @@ -19,6 +19,56 @@ const MCP_INVALID_REQUEST = -32600; const MCP_METHOD_NOT_FOUND = -32601; const MCP_INVALID_PARAMS = -32602; +const GATEWAY_URI_AUTHORITY_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/?#]*)(.*)/; + +/** + * Encodes a resource URI for the gateway by appending `-{serverIndex}` to the authority. + * This namespaces resources from different MCP servers served through the same gateway. + */ +export function encodeGatewayResourceUri(uri: string, serverIndex: number): string { + const match = uri.match(GATEWAY_URI_AUTHORITY_RE); + if (!match) { + return uri; + } + const [, prefix, authority, rest] = match; + return `${prefix}${authority}-${serverIndex}${rest}`; +} + +/** + * Decodes a gateway-encoded resource URI, extracting the server index and original URI. + */ +export function decodeGatewayResourceUri(uri: string): { serverIndex: number; originalUri: string } { + const match = uri.match(GATEWAY_URI_AUTHORITY_RE); + if (!match) { + throw new JsonRpcError(MCP_INVALID_PARAMS, `Invalid resource URI: ${uri}`); + } + const [, prefix, authority, rest] = match; + const suffixMatch = authority.match(/^(.*)-([0-9]+)$/); + if (!suffixMatch) { + throw new JsonRpcError(MCP_INVALID_PARAMS, `Invalid gateway resource URI (no server index): ${uri}`); + } + const [, originalAuthority, indexStr] = suffixMatch; + return { + serverIndex: parseInt(indexStr, 10), + originalUri: `${prefix}${originalAuthority}${rest}`, + }; +} + +function encodeResourceUrisInContent(content: MCP.ContentBlock[], serverIndex: number): MCP.ContentBlock[] { + return content.map(block => { + if (block.type === 'resource_link') { + return { ...block, uri: encodeGatewayResourceUri(block.uri, serverIndex) }; + } + if (block.type === 'resource') { + return { + ...block, + resource: { ...block.resource, uri: encodeGatewayResourceUri(block.resource.uri, serverIndex) }, + }; + } + return block; + }); +} + export class McpGatewaySession extends Disposable { private readonly _rpc: JsonRpcProtocol; private readonly _sseClients = new Set(); @@ -50,6 +100,14 @@ export class McpGatewaySession extends Disposable { this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); })); + + this._register(this._toolInvoker.onDidChangeResources(() => { + if (!this._isInitialized) { + return; + } + + this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); + })); } public attachSseClient(_req: http.IncomingMessage, res: http.ServerResponse): void { @@ -148,6 +206,12 @@ export class McpGatewaySession extends Disposable { return this._handleListTools(); case 'tools/call': return this._handleCallTool(request); + case 'resources/list': + return this._handleListResources(); + case 'resources/read': + return this._handleReadResource(request); + case 'resources/templates/list': + return this._handleListResourceTemplates(); default: throw new JsonRpcError(MCP_METHOD_NOT_FOUND, `Method not found: ${request.method}`); } @@ -157,6 +221,7 @@ export class McpGatewaySession extends Disposable { if (notification.method === 'notifications/initialized') { this._isInitialized = true; this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); + this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); } } @@ -167,6 +232,9 @@ export class McpGatewaySession extends Disposable { tools: { listChanged: true, }, + resources: { + listChanged: true, + }, }, serverInfo: { name: 'VS Code MCP Gateway', @@ -175,7 +243,7 @@ export class McpGatewaySession extends Disposable { }; } - private _handleCallTool(request: IJsonRpcRequest): unknown { + private async _handleCallTool(request: IJsonRpcRequest): Promise { const params = typeof request.params === 'object' && request.params ? request.params as Record : undefined; if (!params || typeof params.name !== 'string') { throw new JsonRpcError(MCP_INVALID_PARAMS, 'Missing tool call params'); @@ -189,16 +257,71 @@ export class McpGatewaySession extends Disposable { ? params.arguments as Record : {}; - return this._toolInvoker.callTool(params.name, argumentsValue).catch(error => { + try { + const { result, serverIndex } = await this._toolInvoker.callTool(params.name, argumentsValue); + return { + ...result, + content: encodeResourceUrisInContent(result.content, serverIndex), + }; + } catch (error) { this._logService.error('[McpGatewayService] Tool call invocation failed', error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); - }); + } } private _handleListTools(): unknown { return this._toolInvoker.listTools() .then(tools => ({ tools })); } + + private async _handleListResources(): Promise { + const serverResults = await this._toolInvoker.listResources(); + const allResources: MCP.Resource[] = []; + for (const { serverIndex, resources } of serverResults) { + for (const resource of resources) { + allResources.push({ + ...resource, + uri: encodeGatewayResourceUri(resource.uri, serverIndex), + }); + } + } + return { resources: allResources }; + } + + private async _handleReadResource(request: IJsonRpcRequest): Promise { + const params = typeof request.params === 'object' && request.params ? request.params as Record : undefined; + if (!params || typeof params.uri !== 'string') { + throw new JsonRpcError(MCP_INVALID_PARAMS, 'Missing resource URI'); + } + + const { serverIndex, originalUri } = decodeGatewayResourceUri(params.uri); + try { + const result = await this._toolInvoker.readResource(serverIndex, originalUri); + return { + contents: result.contents.map(content => ({ + ...content, + uri: encodeGatewayResourceUri(content.uri, serverIndex), + })), + }; + } catch (error) { + this._logService.error('[McpGatewayService] Resource read failed', error); + throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); + } + } + + private async _handleListResourceTemplates(): Promise { + const serverResults = await this._toolInvoker.listResourceTemplates(); + const allTemplates: MCP.ResourceTemplate[] = []; + for (const { serverIndex, resourceTemplates } of serverResults) { + for (const template of resourceTemplates) { + allTemplates.push({ + ...template, + uriTemplate: encodeGatewayResourceUri(template.uriTemplate, serverIndex), + }); + } + } + return { resourceTemplates: allTemplates }; + } } export function isInitializeMessage(message: JsonRpcMessage | JsonRpcMessage[]): boolean { diff --git a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts index 945b2877ca348..98712bb96810a 100644 --- a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts +++ b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts @@ -11,7 +11,7 @@ import { IJsonRpcErrorResponse, IJsonRpcSuccessResponse } from '../../../../base import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { MCP } from '../../common/modelContextProtocol.js'; -import { McpGatewaySession } from '../../node/mcpGatewaySession.js'; +import { decodeGatewayResourceUri, encodeGatewayResourceUri, McpGatewaySession } from '../../node/mcpGatewaySession.js'; class TestServerResponse extends EventEmitter { public statusCode: number | undefined; @@ -48,6 +48,7 @@ suite('McpGatewaySession', () => { function createInvoker() { const onDidChangeTools = new Emitter(); + const onDidChangeResources = new Emitter(); const tools: readonly MCP.Tool[] = [{ name: 'test_tool', description: 'Test tool', @@ -59,20 +60,35 @@ suite('McpGatewaySession', () => { } }]; + const resources: readonly MCP.Resource[] = [{ + uri: 'file:///test/resource.txt', + name: 'resource.txt', + }]; + return { onDidChangeTools, + onDidChangeResources, invoker: { onDidChangeTools: onDidChangeTools.event, + onDidChangeResources: onDidChangeResources.event, listTools: async () => tools, - callTool: async (_name: string, args: Record): Promise => ({ - content: [{ type: 'text', text: `Hello, ${typeof args.name === 'string' ? args.name : 'World'}!` }] - }) + callTool: async (_name: string, args: Record) => ({ + result: { + content: [{ type: 'text' as const, text: `Hello, ${typeof args.name === 'string' ? args.name : 'World'}!` }] + }, + serverIndex: 0, + }), + listResources: async () => [{ serverIndex: 0, resources }], + readResource: async (_serverIndex: number, _uri: string) => ({ + contents: [{ uri: 'file:///test/resource.txt', text: 'hello world', mimeType: 'text/plain' }], + }), + listResourceTemplates: async () => [{ serverIndex: 0, resourceTemplates: [{ uriTemplate: 'file:///test/{name}', name: 'Test Template' }] }], } }; } test('returns initialize result', async () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-1', new NullLogService(), () => { }, invoker); const responses = await session.handleIncoming({ @@ -93,10 +109,11 @@ suite('McpGatewaySession', () => { assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25'); session.dispose(); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); }); test('rejects non-initialize requests before initialized notification', async () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-2', new NullLogService(), () => { }, invoker); const responses = await session.handleIncoming({ @@ -112,10 +129,11 @@ suite('McpGatewaySession', () => { assert.strictEqual(response.error.code, -32600); session.dispose(); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); }); test('serves tools/list and tools/call after initialized notification', async () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-3', new NullLogService(), () => { }, invoker); await session.handleIncoming({ jsonrpc: '2.0', id: 1, method: 'initialize' }); @@ -145,10 +163,11 @@ suite('McpGatewaySession', () => { assert.strictEqual(text, 'Hello, VS Code!'); session.dispose(); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); }); test('broadcasts notifications to attached SSE clients', async () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-4', new NullLogService(), () => { }, invoker); const response = new TestServerResponse(); @@ -161,12 +180,14 @@ suite('McpGatewaySession', () => { assert.ok(response.writes.some(chunk => chunk.includes(': connected'))); assert.ok(response.writes.some(chunk => chunk.includes('event: message'))); assert.ok(response.writes.some(chunk => chunk.includes('notifications/tools/list_changed'))); + assert.ok(response.writes.some(chunk => chunk.includes('notifications/resources/list_changed'))); session.dispose(); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); }); test('emits list changed on tool invoker changes', async () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-5', new NullLogService(), () => { }, invoker); const response = new TestServerResponse(); @@ -181,10 +202,11 @@ suite('McpGatewaySession', () => { assert.ok(response.writes.slice(writesBefore).some(chunk => chunk.includes('notifications/tools/list_changed'))); session.dispose(); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); }); test('disposes attached SSE clients and callback', () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); let disposed = false; const session = new McpGatewaySession('session-6', new NullLogService(), () => { disposed = true; @@ -197,5 +219,145 @@ suite('McpGatewaySession', () => { assert.strictEqual(response.writableEnded, true); assert.strictEqual(disposed, true); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('emits resources list changed on resource invoker changes', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-7', new NullLogService(), () => { }, invoker); + const response = new TestServerResponse(); + + session.attachSseClient({} as http.IncomingMessage, response as unknown as http.ServerResponse); + await session.handleIncoming({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + await session.handleIncoming({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const writesBefore = response.writes.length; + onDidChangeResources.fire(); + + assert.ok(response.writes.length > writesBefore); + assert.ok(response.writes.slice(writesBefore).some(chunk => chunk.includes('notifications/resources/list_changed'))); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('serves resources/list with encoded URIs', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-8', new NullLogService(), () => { }, invoker); + + await session.handleIncoming({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + await session.handleIncoming({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const responses = await session.handleIncoming({ jsonrpc: '2.0', id: 2, method: 'resources/list' }); + const response = responses[0] as IJsonRpcSuccessResponse; + const resources = (response.result as { resources: Array<{ uri: string; name: string }> }).resources; + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].uri, 'file://-0/test/resource.txt'); + assert.strictEqual(resources[0].name, 'resource.txt'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('serves resources/read with URI decoding and re-encoding', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-9', new NullLogService(), () => { }, invoker); + + await session.handleIncoming({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + await session.handleIncoming({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 2, + method: 'resources/read', + params: { uri: 'file://-0/test/resource.txt' }, + }); + const response = responses[0] as IJsonRpcSuccessResponse; + const contents = (response.result as { contents: Array<{ uri: string; text: string }> }).contents; + assert.strictEqual(contents.length, 1); + assert.strictEqual(contents[0].uri, 'file://-0/test/resource.txt'); + assert.strictEqual(contents[0].text, 'hello world'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('serves resources/templates/list with encoded URI templates', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-10', new NullLogService(), () => { }, invoker); + + await session.handleIncoming({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + await session.handleIncoming({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const responses = await session.handleIncoming({ jsonrpc: '2.0', id: 2, method: 'resources/templates/list' }); + const response = responses[0] as IJsonRpcSuccessResponse; + const templates = (response.result as { resourceTemplates: Array<{ uriTemplate: string; name: string }> }).resourceTemplates; + assert.strictEqual(templates.length, 1); + assert.strictEqual(templates[0].uriTemplate, 'file://-0/test/{name}'); + assert.strictEqual(templates[0].name, 'Test Template'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); +}); + +suite('Gateway Resource URI encoding', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('encodes and decodes URI with authority', () => { + const encoded = encodeGatewayResourceUri('https://example.com/resource', 3); + assert.strictEqual(encoded, 'https://example.com-3/resource'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 3); + assert.strictEqual(decoded.originalUri, 'https://example.com/resource'); + }); + + test('encodes and decodes URI with empty authority', () => { + const encoded = encodeGatewayResourceUri('file:///path/to/file', 0); + assert.strictEqual(encoded, 'file://-0/path/to/file'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 0); + assert.strictEqual(decoded.originalUri, 'file:///path/to/file'); + }); + + test('encodes and decodes URI with authority containing hyphens', () => { + const encoded = encodeGatewayResourceUri('https://my-server.example.com/res', 12); + assert.strictEqual(encoded, 'https://my-server.example.com-12/res'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 12); + assert.strictEqual(decoded.originalUri, 'https://my-server.example.com/res'); + }); + + test('encodes and decodes URI with port', () => { + const encoded = encodeGatewayResourceUri('http://localhost:8080/api', 5); + assert.strictEqual(encoded, 'http://localhost:8080-5/api'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 5); + assert.strictEqual(decoded.originalUri, 'http://localhost:8080/api'); + }); + + test('encodes and decodes URI with query and fragment', () => { + const encoded = encodeGatewayResourceUri('https://example.com/resource?q=1#section', 2); + assert.strictEqual(encoded, 'https://example.com-2/resource?q=1#section'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 2); + assert.strictEqual(decoded.originalUri, 'https://example.com/resource?q=1#section'); + }); + + test('encodes and decodes custom scheme URIs', () => { + const encoded = encodeGatewayResourceUri('custom://myhost/path', 7); + assert.strictEqual(encoded, 'custom://myhost-7/path'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 7); + assert.strictEqual(decoded.originalUri, 'custom://myhost/path'); + }); + + test('returns URI unchanged if no scheme match', () => { + const encoded = encodeGatewayResourceUri('not-a-uri', 1); + assert.strictEqual(encoded, 'not-a-uri'); + }); + + test('throws on decode of URI without server index suffix', () => { + assert.throws(() => decodeGatewayResourceUri('https://example.com/resource')); }); }); diff --git a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts index 796de07cf62bf..88a1da8b4b6d8 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts @@ -8,8 +8,10 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { IMcpServer, IMcpService, McpServerCacheState, McpToolVisibility } from './mcpTypes.js'; +import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates } from '../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../../../platform/mcp/common/modelContextProtocol.js'; +import { McpServer } from './mcpServer.js'; +import { IMcpServer, IMcpService, McpCapability, McpServerCacheState, McpToolVisibility } from './mcpTypes.js'; import { startServerAndWaitForLiveTools } from './mcpTypesUtils.js'; interface ICallToolArgs { @@ -17,32 +19,74 @@ interface ICallToolArgs { args: Record; } +interface IReadResourceArgs { + serverIndex: number; + uri: string; +} + export class McpGatewayToolBrokerChannel extends Disposable implements IServerChannel { private readonly _onDidChangeTools = this._register(new Emitter()); + private readonly _onDidChangeResources = this._register(new Emitter()); + private readonly _serverIdMap = new Map(); + private _nextServerIndex = 0; constructor( private readonly _mcpService: IMcpService, ) { super(); - let initialized = false; + let toolsInitialized = false; this._register(autorun(reader => { for (const server of this._mcpService.servers.read(reader)) { server.tools.read(reader); } - if (initialized) { + if (toolsInitialized) { this._onDidChangeTools.fire(); } else { - initialized = true; + toolsInitialized = true; + } + })); + + let resourcesInitialized = false; + this._register(autorun(reader => { + for (const server of this._mcpService.servers.read(reader)) { + server.capabilities.read(reader); + } + + if (resourcesInitialized) { + this._onDidChangeResources.fire(); + } else { + resourcesInitialized = true; } })); } + private _getServerIndex(server: IMcpServer): number { + const defId = server.definition.id; + let index = this._serverIdMap.get(defId); + if (index === undefined) { + index = this._nextServerIndex++; + this._serverIdMap.set(defId, index); + } + return index; + } + + private _getServerByIndex(serverIndex: number): IMcpServer | undefined { + for (const server of this._mcpService.servers.get()) { + if (this._getServerIndex(server) === serverIndex) { + return server; + } + } + return undefined; + } + listen(_ctx: unknown, event: string): Event { switch (event) { case 'onDidChangeTools': return this._onDidChangeTools.event as Event; + case 'onDidChangeResources': + return this._onDidChangeResources.event as Event; } throw new Error(`Invalid listen: ${event}`); @@ -59,6 +103,19 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh const result = await this._callTool(name, args || {}, cancellationToken); return result as T; } + case 'listResources': { + const resources = await this._listResources(); + return resources as T; + } + case 'readResource': { + const { serverIndex, uri } = arg as IReadResourceArgs; + const result = await this._readResource(serverIndex, uri, cancellationToken); + return result as T; + } + case 'listResourceTemplates': { + const templates = await this._listResourceTemplates(); + return templates as T; + } } throw new Error(`Invalid call: ${command}`); @@ -87,20 +144,75 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh return mcpTools; } - private async _callTool(name: string, args: Record, token: CancellationToken = CancellationToken.None): Promise { + private async _callTool(name: string, args: Record, token: CancellationToken = CancellationToken.None): Promise { for (const server of this._mcpService.servers.get()) { const tool = server.tools.get().find(t => t.definition.name === name && (t.visibility & McpToolVisibility.Model) ); if (tool) { - return tool.call(args, undefined, token); + const result = await tool.call(args, undefined, token); + return { result, serverIndex: this._getServerIndex(server) }; } } throw new Error(`Unknown tool: ${name}`); } + private async _listResources(): Promise { + const results: IGatewayServerResources[] = []; + const servers = this._mcpService.servers.get(); + await Promise.all(servers.map(async server => { + await this._ensureServerReady(server); + + const capabilities = server.capabilities.get(); + if (!capabilities || !(capabilities & McpCapability.Resources)) { + return; + } + + try { + const resources = await McpServer.callOn(server, h => h.listResources()); + results.push({ serverIndex: this._getServerIndex(server), resources }); + } catch { + // Server failed; skip + } + })); + + return results; + } + + private async _readResource(serverIndex: number, uri: string, token: CancellationToken = CancellationToken.None): Promise { + const server = this._getServerByIndex(serverIndex); + if (!server) { + throw new Error(`Unknown server index: ${serverIndex}`); + } + + return McpServer.callOn(server, h => h.readResource({ uri }, token), token); + } + + private async _listResourceTemplates(): Promise { + const results: IGatewayServerResourceTemplates[] = []; + const servers = this._mcpService.servers.get(); + + await Promise.all(servers.map(async server => { + await this._ensureServerReady(server); + + const capabilities = server.capabilities.get(); + if (!capabilities || !(capabilities & McpCapability.Resources)) { + return; + } + + try { + const resourceTemplates = await McpServer.callOn(server, h => h.listResourceTemplates()); + results.push({ serverIndex: this._getServerIndex(server), resourceTemplates }); + } catch { + // Server failed; skip + } + })); + + return results; + } + private async _ensureServerReady(server: IMcpServer): Promise { const cacheState = server.cacheState.get(); if (cacheState !== McpServerCacheState.Unknown && cacheState !== McpServerCacheState.Outdated) { diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts index ee893807ecf1c..53124c8acc37f 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IGatewayCallToolResult } from '../../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../common/modelContextProtocol.js'; import { McpGatewayToolBrokerChannel } from '../../common/mcpGatewayToolBrokerChannel.js'; import { IMcpIcons, IMcpServer, IMcpTool, McpConnectionState, McpServerCacheState, McpToolVisibility } from '../../common/mcpTypes.js'; @@ -60,18 +61,18 @@ suite('McpGatewayToolBrokerChannel', () => { mcpService.servers.set([serverA, serverB], undefined); - const resultA = await channel.call(undefined, 'callTool', { + const resultA = await channel.call(undefined, 'callTool', { name: 'mcp_serverA_echo', args: { name: 'one' }, }); - const resultB = await channel.call(undefined, 'callTool', { + const resultB = await channel.call(undefined, 'callTool', { name: 'mcp_serverB_echo', args: { name: 'two' }, }); assert.deepStrictEqual(invoked, ['A:one', 'B:two']); - assert.strictEqual((resultA.content[0] as MCP.TextContent).text, 'from A'); - assert.strictEqual((resultB.content[0] as MCP.TextContent).text, 'from B'); + assert.strictEqual((resultA.result.content[0] as MCP.TextContent).text, 'from A'); + assert.strictEqual((resultB.result.content[0] as MCP.TextContent).text, 'from B'); channel.dispose(); });