diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index deecc7c28629a..adfbb9a8697b6 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -691,6 +691,16 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi this.logger.info(`[Model][openRepository] Opened repository (real path): ${repository.rootRealPath ?? repository.root}`); this.logger.info(`[Model][openRepository] Opened repository (kind): ${gitRepository.kind}`); + // For repositories that are opened in the sessions app, we want to wait for + // the initial `git status` to complete before updating the repository cache + // and firing events. + if (workspace.isAgentSessionsWorkspace) { + await repository.status(); + this._repositoryCache.update(repository.remotes, [], repository.root); + + return; + } + // Do not await this, we want SCM // to know about the repo asap repository.status().then(() => { diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 2dee14681eae6..ff213c40f16dd 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -137,6 +137,8 @@ import { McpGalleryManifestIPCService } from '../../../platform/mcp/common/mcpGa import { IMeteredConnectionService } from '../../../platform/meteredConnection/common/meteredConnection.js'; import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js'; import { PlaywrightChannel } from '../../../platform/browserView/node/playwrightChannel.js'; +import { ILocalGitService } from '../../../platform/git/common/localGitService.js'; +import { LocalGitService } from '../../../platform/git/node/localGitService.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -404,6 +406,9 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Web Content Extractor services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService)); + // Local Git + services.set(ILocalGitService, new SyncDescriptor(LocalGitService, undefined, false /* proxied to other processes */)); + // SSH Remote Agent Host services.set(ISSHRemoteAgentHostMainService, new SyncDescriptor(SSHRemoteAgentHostMainService, undefined, true)); @@ -478,6 +483,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService))); this.server.registerChannel('playwright', playwrightChannel); + // Local Git + const localGitChannel = ProxyChannel.fromService(accessor.get(ILocalGitService), this._store); + this.server.registerChannel('localGit', localGitChannel); + // SSH Remote Agent Host const sshRemoteAgentHostChannel = ProxyChannel.fromService(accessor.get(ISSHRemoteAgentHostMainService), this._store); this.server.registerChannel(SSH_REMOTE_AGENT_HOST_CHANNEL, sshRemoteAgentHostChannel); diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts index 443bd8c2a1e86..4e6aec9b66d6b 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -50,13 +50,28 @@ interface SSHClient { const LOG_PREFIX = '[SSHRemoteAgentHost]'; +/** + * Validate that a quality string is safe for bare interpolation in shell commands. + * Quality comes from `productService.quality` (not user input) but we validate + * as defense-in-depth since these values end up in unquoted shell paths (the `~` + * prefix requires shell expansion, so we cannot single-quote the entire path). + */ +function validateShellToken(value: string, label: string): string { + if (!/^[a-zA-Z0-9._-]+$/.test(value)) { + throw new Error(`Unsafe ${label} value for shell interpolation: ${JSON.stringify(value)}`); + } + return value; +} + /** Install location for the VS Code CLI on the remote machine. */ function getRemoteCLIDir(quality: string): string { - return quality === 'stable' || !quality ? '~/.vscode-cli' : `~/.vscode-cli-${quality}`; + const q = validateShellToken(quality, 'quality'); + return q === 'stable' ? '~/.vscode-cli' : `~/.vscode-cli-${q}`; } function getRemoteCLIBin(quality: string): string { - const binaryName = quality === 'stable' ? 'code' : 'code-insiders'; - return `${getRemoteCLIDir(quality)}/${binaryName}`; + const q = validateShellToken(quality, 'quality'); + const binaryName = q === 'stable' ? 'code' : 'code-insiders'; + return `${getRemoteCLIDir(q)}/${binaryName}`; } /** Escape a string for use as a single shell argument (single-quote wrapping). */ @@ -653,7 +668,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem const installCmd = [ `mkdir -p ${cliDir}`, - `curl -fsSL '${url}' | tar xz -C ${cliDir}`, + `curl -fsSL ${shellEscape(url)} | tar xz -C ${cliDir}`, `chmod +x ${cliBin}`, ].join(' && '); diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 45ecb23266931..23d840d811340 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -141,7 +141,10 @@ export class BrowserView extends Disposable implements ICDPTarget { // Return the webContents so Electron can complete the window.open() call return childView.webContents; - } + }, + + // We want the standard browser behavior as opposed to Electron's default of closing the new window when the parent is closed + outlivesOpener: true }; }); @@ -236,7 +239,11 @@ export class BrowserView extends Disposable implements ICDPTarget { // Loading state events webContents.on('did-start-loading', () => { this._lastError = undefined; - fireLoadingEvent(true); + + // Don't fire loading events for e.g. same-document navigations + if (webContents.isLoadingMainFrame()) { + fireLoadingEvent(true); + } }); webContents.on('did-stop-loading', () => fireLoadingEvent(false)); webContents.on('did-fail-load', (e, errorCode, errorDescription, validatedURL, isMainFrame) => { diff --git a/src/vs/platform/git/common/localGitService.ts b/src/vs/platform/git/common/localGitService.ts new file mode 100644 index 0000000000000..a0260a9d6bcb4 --- /dev/null +++ b/src/vs/platform/git/common/localGitService.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const ILocalGitService = createDecorator('localGitService'); + +/** + * Low-level service for executing git commands on the local machine. + * Used in the shared process where Node.js APIs are available. + * All path arguments are native file-system paths. + */ +export interface ILocalGitService { + readonly _serviceBrand: undefined; + + clone(operationId: string, cloneUrl: string, targetPath: string, ref?: string): Promise; + pull(operationId: string, repoPath: string): Promise; + checkout(operationId: string, repoPath: string, treeish: string, detached?: boolean): Promise; + revParse(repoPath: string, ref: string): Promise; + fetch(operationId: string, repoPath: string): Promise; + revListCount(repoPath: string, fromRef: string, toRef: string): Promise; + cancel(operationId: string): Promise; +} diff --git a/src/vs/platform/git/node/localGitService.ts b/src/vs/platform/git/node/localGitService.ts new file mode 100644 index 0000000000000..b7b3f9dd82c0b --- /dev/null +++ b/src/vs/platform/git/node/localGitService.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as cp from 'child_process'; +import { CancellationError } from '../../../base/common/errors.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { ILocalGitService } from '../common/localGitService.js'; +import { ILogService } from '../../log/common/log.js'; + +export class LocalGitService implements ILocalGitService { + declare readonly _serviceBrand: undefined; + + private _runningProcesses = new Map(); + + constructor( + @ILogService private readonly _logService: ILogService, + ) { } + + private _exec(operationId: string, args: string[], cwd?: string): Promise { + return new Promise((resolve, reject) => { + this._logService.trace(`[LocalGitService] git ${args.join(' ')}${cwd ? ` (cwd: ${cwd})` : ''}`); + const proc = cp.execFile('git', args, { cwd, encoding: 'utf8' }, (err, stdout, stderr) => { + if (!this._runningProcesses.delete(operationId)) { + reject(new CancellationError()); + return; + } + if (err) { + this._logService.error(`[LocalGitService] git ${args[0]} failed:`, err.message, stderr); + reject(err); + return; + } + resolve(stdout); + }); + + this._runningProcesses.set(operationId, proc); + }); + } + + async clone(operationId: string, cloneUrl: string, targetPath: string, ref?: string): Promise { + const args = ['clone']; + if (ref) { + args.push('--branch', ref); + } + args.push('--', cloneUrl, targetPath); + await this._exec(operationId, args); + } + + async pull(operationId: string, repoPath: string): Promise { + const before = (await this._exec(operationId, ['rev-parse', 'HEAD'], repoPath)).trim(); + await this._exec(operationId, ['pull', '--ff-only'], repoPath); + const after = (await this._exec(operationId, ['rev-parse', 'HEAD'], repoPath)).trim(); + return before !== after; + } + + async checkout(operationId: string, repoPath: string, treeish: string, detached?: boolean): Promise { + const args = detached + ? ['checkout', '--detach', treeish] + : ['checkout', treeish]; + await this._exec(operationId, args, repoPath); + } + + async revParse(repoPath: string, ref: string): Promise { + return (await this._exec(generateUuid(), ['rev-parse', ref], repoPath)).trim(); + } + + async fetch(operationId: string, repoPath: string): Promise { + await this._exec(operationId, ['fetch'], repoPath); + } + + async revListCount(repoPath: string, fromRef: string, toRef: string): Promise { + const result = await this._exec(generateUuid(), ['rev-list', '--count', `${fromRef}..${toRef}`], repoPath); + return Number(result.trim()) || 0; + } + + async cancel(operationId: string): Promise { + const proc = this._runningProcesses.get(operationId); + if (proc) { + this._runningProcesses.delete(operationId); + proc.kill(); + } + } +} diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 494b6e0059ba3..981cbc934949b 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -82,7 +82,7 @@ The Agent Sessions titlebar includes a command center with a custom title bar wi The widget: - Extends `BaseActionViewItem` and renders a clickable label showing the active session title -- Shows kind icon (provider type icon), session title, repository folder name, and changes summary (+insertions -deletions) +- Shows kind icon (provider type icon), session title, repository folder name, and the active git branch/worktree name in parentheses when available, plus the changes summary (+insertions -deletions) - On click, opens the `AgentSessionsPicker` quick pick to switch between sessions - Gets the active session label from `IActiveSessionService.getActiveSession()` and the live model title from `IChatService`, falling back to "New Session" if no active session is found - Re-renders automatically when the active session changes via `autorun` on `IActiveSessionService.activeSession`, and when session data changes via `IAgentSessionsService.model.onDidChangeSessions` @@ -173,6 +173,8 @@ This structure places the sidebar at the root level spanning the full window hei | Panel | 300px height | | Titlebar | Determined by `minimumHeight` (~30px) | +The sessions sidebar can be resized down to a minimum width of 170px. + ### 4.3 Editor Modal The main editor part is created but hidden (`display:none`). It exists for future use but is not currently visible. All editors are forced to open in the `ModalEditorPart` overlay via the standard `createModalEditorPart()` mechanism. @@ -644,6 +646,8 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-03 | Updated `SessionsTitleBarWidget` to format active session titles as `{Title} · {repo name} ({git branch/worktree name})` when repository detail metadata is available, falling back to the worktree folder name when needed. | +| 2026-04-03 | Reduced the sessions left sidebar minimum resizable width from 270px to 170px so it can shrink significantly more while keeping the default 300px width unchanged | | 2026-03-30 | Adjusted `.agent-sessions-titlebar-container` padding so it sits flush when the sidebar is visible and restores 16px left padding when the sidebar is hidden | | 2026-03-26 | Updated the sessions sidebar appear animation so only the body content (`.part.sidebar > .content`) slides/fades in during reveal while the sidebar title/header and footer remain fixed | | 2026-03-24 | Polished the sessions task configuration quick input modal to use stronger modal-style header chrome, increased horizontal padding in the quick input/form content, and added an explicit close action in the modal header | diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts index 46b7a3166c6b5..0bd85fb27d657 100644 --- a/src/vs/sessions/browser/parts/sidebarPart.ts +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -68,7 +68,7 @@ export class SidebarPart extends AbstractPaneCompositePart { //#region IView - readonly minimumWidth: number = 270; + readonly minimumWidth: number = 170; readonly maximumWidth: number = Number.POSITIVE_INFINITY; readonly minimumHeight: number = 0; readonly maximumHeight: number = Number.POSITIVE_INFINITY; diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 5e6550fa6f936..9158a5a9fa7d2 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -64,7 +64,7 @@ import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/b import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; import { IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; -import { GitDiffChange, IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; +import { GitDiffChange, GitRepositoryState, IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { CIStatusWidget } from './checksWidget.js'; import { arrayEqualsC, structuralEquals } from '../../../../base/common/equals.js'; import { GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../sessions/common/sessionData.js'; @@ -249,6 +249,17 @@ function toChangesFileItem(changes: GitDiffChange[], modifiedRef: string | undef // --- View Model +interface ActiveSessionState { + readonly isolationMode: IsolationMode; + readonly hasGitRepository: boolean; + readonly isMergeBaseBranchProtected: boolean | undefined; + readonly incomingChanges: number | undefined; + readonly outgoingChanges: number | undefined; + readonly uncommittedChanges: number | undefined; + readonly hasPullRequest: boolean | undefined; + readonly hasOpenPullRequest: boolean | undefined; +} + class ChangesViewModel extends Disposable { readonly sessionsChangedSignal: IObservable; readonly activeSessionResourceObs: IObservable; @@ -256,6 +267,7 @@ class ChangesViewModel extends Disposable { readonly activeSessionBaseBranchNameObs: IObservable; readonly activeSessionIsolationModeObs: IObservable; readonly activeSessionRepositoryObs: IObservableWithChange; + readonly activeSessionRepositoryStateObs: IObservableWithChange; readonly activeSessionChangesObs: IObservable; readonly activeSessionReviewCommentCountByFileObs: IObservable>; readonly activeSessionAgentFeedbackCountByFileObs: IObservable>; @@ -265,6 +277,9 @@ class ChangesViewModel extends Disposable { readonly activeSessionFirstCheckpointRefObs: IObservable; readonly activeSessionLastCheckpointRefObs: IObservable; + readonly activeSessionStateObs: IObservable; + readonly activeSessionIsLoadingObs: IObservable; + readonly versionModeObs: ISettableObservable; setVersionMode(mode: ChangesVersionMode): void { if (this.versionModeObs.get() === mode) { @@ -351,10 +366,24 @@ class ChangesViewModel extends Disposable { return activeSessionRepositoryPromise.read(reader); }); + this.activeSessionRepositoryStateObs = derived(reader => { + const repository = this.activeSessionRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + + // If the repository has no HEAD, it is likely not fully loaded yet. + // Treat it as undefined to avoid showing incorrect information to + // the user. + if (!repositoryState?.HEAD) { + return undefined; + } + + return repositoryState; + }); + // Active session branch name this.activeSessionBranchNameObs = derived(reader => { const repository = activeSessionRepositoryObs.read(reader); - const repositoryState = this.activeSessionRepositoryObs.read(reader)?.state.read(reader); + const repositoryState = this.activeSessionRepositoryStateObs.read(reader); return repository?.detail ?? repositoryState?.HEAD?.name; }); @@ -503,6 +532,95 @@ class ChangesViewModel extends Disposable { const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; this.viewModeObs = observableValue(this, initialMode); + + // Active session state + const { isLoading, state } = this._getActiveSessionState(); + this.activeSessionIsLoadingObs = isLoading; + this.activeSessionStateObs = state; + } + + private _getActiveSessionState(): { isLoading: IObservable; state: IObservable } { + const isLoadingObs = derived(reader => { + // If there is a git repository, wait for the repository to be opened first, + // as there are many context keys that depend on the repository information. + const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader); + if (hasGitRepository && this.activeSessionRepositoryStateObs.read(reader) === undefined) { + return true; + } + + // Branch changes + const versionMode = this.versionModeObs.read(reader); + if (versionMode === ChangesVersionMode.BranchChanges) { + return false; + } + + // All changes + if (versionMode === ChangesVersionMode.AllChanges) { + const allChangesResult = this.activeSessionAllChangesObs.read(reader).read(reader); + return allChangesResult === undefined; + } + + // Last turn changes + if (versionMode === ChangesVersionMode.LastTurn) { + const lastTurnChangesResult = this.activeSessionLastTurnChangesObs.read(reader).read(reader); + return lastTurnChangesResult === undefined; + } + + return false; + }); + + const activeSessionStateObs = derivedObservableWithCache(this, (reader, lastValue) => { + const isLoading = isLoadingObs.read(reader); + const activeSession = this.sessionManagementService.activeSession.read(reader); + const repositoryState = this.activeSessionRepositoryStateObs.read(reader); + if (isLoading && repositoryState === undefined) { + return lastValue; + } + + // Session state + const workspace = activeSession?.workspace.read(reader); + const isolationMode = this.activeSessionIsolationModeObs.read(reader); + const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader); + const isMergeBaseBranchProtected = workspace?.repositories[0]?.baseBranchProtected; + + // Pull request state + const gitHubInfo = activeSession?.gitHubInfo.read(reader); + const hasPullRequest = gitHubInfo?.pullRequest?.uri !== undefined; + const hasOpenPullRequest = hasPullRequest && + (gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id || + gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id); + + // Repository state + const incomingChanges = hasGitRepository + ? repositoryState?.HEAD?.behind ?? 0 + : undefined; + const outgoingChanges = hasGitRepository + ? repositoryState?.HEAD?.ahead ?? 0 + : undefined; + const uncommittedChanges = hasGitRepository + ? (repositoryState?.mergeChanges.length ?? 0) + + (repositoryState?.indexChanges.length ?? 0) + + (repositoryState?.workingTreeChanges.length ?? 0) + + (repositoryState?.untrackedChanges.length ?? 0) + : undefined; + + return { + isolationMode, + hasGitRepository, + isMergeBaseBranchProtected, + incomingChanges, + outgoingChanges, + uncommittedChanges, + hasPullRequest, + hasOpenPullRequest + } satisfies ActiveSessionState; + }); + + return { + isLoading: isLoadingObs, + state: derivedOpts({ equalsFn: structuralEquals }, + reader => activeSessionStateObs.read(reader)) + }; } } @@ -530,7 +648,6 @@ export class ChangesViewPane extends ViewPane { private readonly isMergeBaseBranchProtectedContextKey: IContextKey; private readonly isolationModeContextKey: IContextKey; private readonly hasGitRepositoryContextKey: IContextKey; - private readonly hasChangesContextKey: IContextKey; private readonly hasIncomingChangesContextKey: IContextKey; private readonly hasOpenPullRequestContextKey: IContextKey; private readonly hasOutgoingChangesContextKey: IContextKey; @@ -572,7 +689,6 @@ export class ChangesViewPane extends ViewPane { this.isMergeBaseBranchProtectedContextKey = isMergeBaseBranchProtectedContextKey.bindTo(this.scopedContextKeyService); this.isolationModeContextKey = isolationModeContextKey.bindTo(this.scopedContextKeyService); this.hasGitRepositoryContextKey = hasGitRepositoryContextKey.bindTo(this.scopedContextKeyService); - this.hasChangesContextKey = ChatContextKeys.hasAgentSessionChanges.bindTo(this.scopedContextKeyService); this.hasIncomingChangesContextKey = hasIncomingChangesContextKey.bindTo(this.scopedContextKeyService); this.hasOutgoingChangesContextKey = hasOutgoingChangesContextKey.bindTo(this.scopedContextKeyService); this.hasUncommittedChangesContextKey = hasUncommittedChangesContextKey.bindTo(this.scopedContextKeyService); @@ -793,34 +909,8 @@ export class ChangesViewPane extends ViewPane { }); }); - const isLoadingChangesObs = derived(reader => { - // If there is a git repository, wait for the repository to be opened first, - // as there are many context keys that depend on the repository information. - const hasGitRepository = this.viewModel.activeSessionHasGitRepositoryObs.read(reader); - if (hasGitRepository && this.viewModel.activeSessionRepositoryObs.read(reader) === undefined) { - return true; - } - - const versionMode = this.viewModel.versionModeObs.read(reader); - if (versionMode === ChangesVersionMode.BranchChanges) { - return false; - } - - if (versionMode === ChangesVersionMode.AllChanges) { - const allChangesResult = this.viewModel.activeSessionAllChangesObs.read(reader).read(reader); - return allChangesResult === undefined; - } - - if (versionMode === ChangesVersionMode.LastTurn) { - const lastTurnChangesResult = this.viewModel.activeSessionLastTurnChangesObs.read(reader).read(reader); - return lastTurnChangesResult === undefined; - } - - return false; - }); - this.renderDisposables.add(autorun(reader => { - const isLoading = isLoadingChangesObs.read(reader); + const isLoading = this.viewModel.activeSessionIsLoadingObs.read(reader); if (isLoading) { this.changesProgressBar.infinite().show(200); } else { @@ -881,22 +971,20 @@ export class ChangesViewPane extends ViewPane { dom.clearNode(this.actionsContainer); // Bind context keys - this._bindContextKeys(isLoadingChangesObs, topLevelStats); + this._bindContextKeys(topLevelStats); const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); this.renderDisposables.add(scopedInstantiationService); const outgoingChangesObs = derived(reader => { - const repository = this.viewModel.activeSessionRepositoryObs.read(reader); - const repositoryState = repository?.state.read(reader); - - return repositoryState?.HEAD?.ahead ?? 0; + const activeSessionState = this.viewModel.activeSessionStateObs.read(reader); + return activeSessionState?.outgoingChanges ?? 0; }); this.renderDisposables.add(autorun(reader => { - const outgoingChanges = outgoingChangesObs.read(reader); const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); + const outgoingChanges = outgoingChangesObs.read(reader); // Read code review state to update the button label dynamically let reviewCommentCount: number | undefined; @@ -993,7 +1081,7 @@ export class ChangesViewPane extends ViewPane { // Update visibility and file count badge based on entries this.renderDisposables.add(autorun(reader => { - if (isLoadingChangesObs.read(reader)) { + if (this.viewModel.activeSessionIsLoadingObs.read(reader)) { return; } @@ -1023,7 +1111,7 @@ export class ChangesViewPane extends ViewPane { this.summaryContainer.appendChild(linesRemovedSpan); this.renderDisposables.add(autorun(reader => { - if (isLoadingChangesObs.read(reader)) { + if (this.viewModel.activeSessionIsLoadingObs.read(reader)) { return; } @@ -1116,7 +1204,7 @@ export class ChangesViewPane extends ViewPane { this.renderDisposables.add(autorun(reader => { const entries = combinedEntriesObs.read(reader); const viewMode = this.viewModel.viewModeObs.read(reader); - const isLoading = isLoadingChangesObs.read(reader); + const isLoading = this.viewModel.activeSessionIsLoadingObs.read(reader); if (!this.tree || isLoading) { return; @@ -1143,101 +1231,35 @@ export class ChangesViewPane extends ViewPane { })); } - private _bindContextKeys(isLoadingChangesObs: IObservable, topLevelStats: IObservable<{ files: number }>): void { + private _bindContextKeys(topLevelStats: IObservable<{ files: number }>): void { // Request in progress (can be updated independently since it only affects action enablement, and not visibility) this.renderDisposables.add(bindContextKey(ChatContextKeys.requestInProgress, this.scopedContextKeyService, reader => { const activeSessionStatus = this.sessionManagementService.activeSession.read(reader)?.status.read(reader); return activeSessionStatus !== SessionStatus.Completed && activeSessionStatus !== SessionStatus.Error; })); - type ContextKeys = { - readonly hasChanges: boolean; - readonly isolationMode: IsolationMode; - readonly hasGitRepository: boolean; - readonly isMergeBaseBranchProtected: boolean; - readonly hasPullRequest: boolean; - readonly hasOpenPullRequest: boolean; - readonly hasIncomingChanges: boolean; - readonly hasOutgoingChanges: boolean; - readonly hasUncommittedChanges: boolean; - }; - - // The following context keys have to be updated together based on the combined entries - // to avoid flickering of actions when switching between sessions and changes are loading - const contextKeysRawObs = derivedObservableWithCache( - this, (reader, lastValue) => { - const isLoading = isLoadingChangesObs.read(reader); - if (isLoading) { - return lastValue; - } - - const activeSession = this.sessionManagementService.activeSession.read(reader); - const repository = this.viewModel.activeSessionRepositoryObs.read(reader); - - // Changes state - const { files } = topLevelStats.read(reader); - const hasChanges = files > 0; - - // Session state - const isolationMode = this.viewModel.activeSessionIsolationModeObs.read(reader); - const hasGitRepository = this.viewModel.activeSessionHasGitRepositoryObs.read(reader); - const isMergeBaseBranchProtected = activeSession?.workspace.read(reader)?.repositories[0]?.baseBranchProtected === true; - - // Pull request state - const gitHubInfo = activeSession?.gitHubInfo.read(reader); - const hasPullRequest = gitHubInfo?.pullRequest?.uri !== undefined; - const hasOpenPullRequest = hasPullRequest && - (gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id || - gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id); - - // Repository state - const repositoryState = repository?.state.read(reader); - const hasIncomingChanges = (repositoryState?.HEAD?.behind ?? 0) > 0; - const hasOutgoingChanges = (repositoryState?.HEAD?.ahead ?? 0) > 0; - const hasUncommittedChanges = (repositoryState?.mergeChanges.length ?? 0) > 0 || - (repositoryState?.indexChanges.length ?? 0) > 0 || - (repositoryState?.workingTreeChanges.length ?? 0) > 0 || - (repositoryState?.untrackedChanges.length ?? 0) > 0; - - return { - hasChanges, - isolationMode, - hasGitRepository, - isMergeBaseBranchProtected, - hasPullRequest, - hasOpenPullRequest, - hasIncomingChanges, - hasOutgoingChanges, - hasUncommittedChanges, - }; - }); - - // Create a derived observable that only emits when the - // context keys actually change to avoid unnecessary updates - const contextKeysObs = derivedOpts({ - equalsFn: structuralEquals - }, reader => { - const contextKeysRaw = contextKeysRawObs.read(reader); - return contextKeysRaw; - }); + // Has changes (can be updated independently since it only affects action enablement, and not visibility) + this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => { + const { files } = topLevelStats.read(reader); + return files > 0; + })); // Bulk update the context keys this.renderDisposables.add(autorun(reader => { - const contextKeys = contextKeysObs.read(reader); - if (!contextKeys) { + const state = this.viewModel.activeSessionStateObs.read(reader); + if (!state) { return; } this.scopedContextKeyService.bufferChangeEvents(() => { - this.hasChangesContextKey.set(contextKeys.hasChanges); - this.isMergeBaseBranchProtectedContextKey.set(contextKeys.isMergeBaseBranchProtected); - this.isolationModeContextKey.set(contextKeys.isolationMode); - this.hasGitRepositoryContextKey.set(contextKeys.hasGitRepository); - this.hasPullRequestContextKey.set(contextKeys.hasPullRequest); - this.hasOpenPullRequestContextKey.set(contextKeys.hasOpenPullRequest); - this.hasIncomingChangesContextKey.set(contextKeys.hasIncomingChanges); - this.hasOutgoingChangesContextKey.set(contextKeys.hasOutgoingChanges); - this.hasUncommittedChangesContextKey.set(contextKeys.hasUncommittedChanges); + this.isolationModeContextKey.set(state.isolationMode); + this.hasGitRepositoryContextKey.set(state.hasGitRepository); + this.isMergeBaseBranchProtectedContextKey.set(state.isMergeBaseBranchProtected === true); + this.hasPullRequestContextKey.set(state.hasPullRequest === true); + this.hasOpenPullRequestContextKey.set(state.hasOpenPullRequest === true); + this.hasIncomingChangesContextKey.set(state.incomingChanges !== undefined && state.incomingChanges > 0); + this.hasOutgoingChangesContextKey.set(state.outgoingChanges !== undefined && state.outgoingChanges > 0); + this.hasUncommittedChangesContextKey.set(state.uncommittedChanges !== undefined && state.uncommittedChanges > 0); }); })); } diff --git a/src/vs/sessions/contrib/changes/browser/checksViewModel.ts b/src/vs/sessions/contrib/changes/browser/checksViewModel.ts index 33526c759ad7c..e44422f93b9d9 100644 --- a/src/vs/sessions/contrib/changes/browser/checksViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/checksViewModel.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; -import { derived, IObservable } from '../../../../base/common/observable.js'; +import { derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IGitHubService } from '../../github/browser/githubService.js'; import { GitHubPullRequestCIModel } from '../../github/browser/models/githubPullRequestCIModel.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; export class ChecksViewModel extends Disposable { readonly activeSessionResourceObs: IObservable; @@ -25,7 +26,9 @@ export class ChecksViewModel extends Disposable { return session?.resource; }); - this.checksObs = derived(this, reader => { + const pullRequestInfoObs = derivedOpts<{ owner: string; repo: string; headRef: string } | undefined>({ + equalsFn: structuralEquals + }, reader => { const session = sessionManagementService.activeSession.read(reader); if (!session) { return undefined; @@ -42,10 +45,23 @@ export class ChecksViewModel extends Disposable { return undefined; } + return { + owner: gitHubInfo.owner, + repo: gitHubInfo.repo, + headRef: pr.headSha + }; + }); + + this.checksObs = derived(this, reader => { + const pullRequestInfo = pullRequestInfoObs.read(reader); + if (!pullRequestInfo) { + return undefined; + } + // Use the PR's headSha (commit SHA) rather than the branch // name so CI checks can still be fetched after branch deletion // (e.g. after the PR is merged). - const ciModel = gitHubService.getPullRequestCI(gitHubInfo.owner, gitHubInfo.repo, pr.headSha); + const ciModel = gitHubService.getPullRequestCI(pullRequestInfo.owner, pullRequestInfo.repo, pullRequestInfo.headRef); ciModel.refresh(); ciModel.startPolling(); reader.store.add({ dispose: () => ciModel.stopPolling() }); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts index d3a69f34fd246..fed92499d7e8a 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts @@ -137,7 +137,7 @@ export class BranchPicker extends Disposable { const session = this._getSession(); const branches = session?.branches.get() ?? []; const isLoading = session?.loading.get() ?? false; - const isDisabled = session?.isolationMode.get() === 'workspace' || branches.length === 0; + const isDisabled = session?.isolationMode.get() === 'workspace'; const label = session?.branch.get() ?? localize('branchPicker.select', "Branch"); dom.append(this._triggerElement, renderIcon(Codicon.gitBranch)); @@ -145,11 +145,11 @@ export class BranchPicker extends Disposable { labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - const visible = !(isLoading || isDisabled); + const visible = !(isLoading || branches.length === 0); dom.setVisibility(visible, this._slotElement); - this._slotElement.classList.toggle('disabled', false); + this._slotElement.classList.toggle('disabled', isDisabled); this._triggerElement.setAttribute('aria-hidden', String(!visible)); - this._triggerElement.setAttribute('aria-disabled', String(!visible)); - this._triggerElement.tabIndex = visible ? 0 : -1; + this._triggerElement.setAttribute('aria-disabled', String(isDisabled)); + this._triggerElement.tabIndex = visible && !isDisabled ? 0 : -1; } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 7bf7e919cc2d3..ce66e5ee1f6d5 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -6,6 +6,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { raceTimeout } from '../../../../base/common/async.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { CancellationError } from '../../../../base/common/errors.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, constObservable, derived, IObservable, IReader, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; @@ -1417,10 +1418,20 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return committedSession; } catch (error) { - // Clean up temp session on error + this._currentNewSession = undefined; + + if (error instanceof CancellationError) { + // Session was stopped before the agent created a worktree. + // Keep the temp session in the list so the user can review + // whatever content the agent produced before cancellation. + session.setStatus(SessionStatus.Completed); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newSession] }); + return newSession; + } + + // Unexpected error — clean up the temp session entirely this._sessionCache.delete(key); this._sessionGroupCache.delete(session.id); - this._currentNewSession = undefined; this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(session)], changed: [] }); session.dispose(); throw error; @@ -1516,11 +1527,22 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return updatedSession; } catch (error) { - // Clean up on error — fire changed on the parent session group + this._currentNewSession = undefined; + + if (error instanceof CancellationError) { + // Cancelled before commit — keep the chat in the group so the + // user can review the content the agent produced. + newChatSession.setStatus(SessionStatus.Completed); + this._sessionGroupCache.delete(sessionId); + const updatedSession = this._chatToSession(newChatSession); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] }); + return updatedSession; + } + + // Unexpected error — clean up on error, fire changed on the parent session group this._sessionCache.delete(key); this._groupModel.removeChat(newChatSession.id); this._sessionGroupCache.delete(sessionId); - this._currentNewSession = undefined; newChatSession.dispose(); // Find the parent session's primary chat to fire a valid changed event const parentChatIds = this._groupModel.getChatIds(sessionId); @@ -1578,13 +1600,13 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * {@link IChatSessionsService.onDidCommitSession} event. * * When {@link responseCompletePromise} is provided, the wait is bounded by - * response completion. If the response finishes before the commit event: - * - If the response was **cancelled**, the session was stopped before the - * agent created the backing resource → throw immediately. - * - If the response completed **normally**, the commit event is legitimately - * in-flight (the extension fired it mid-turn but the async IPC chain in - * {@link MainThreadChatSessions.$onDidCommitChatSessionItem} hasn't finished - * yet) → keep waiting with the safety timeout. + * response completion. If the response finishes before the commit event, + * the commit may still be in-flight (e.g. the user cancelled after the + * worktree was initiated but before the commit IPC finished, or the + * extension fired the commit mid-turn but it hasn't been delivered yet). + * In both cases we wait with the safety timeout. Only if the timeout + * expires *and* the response was cancelled do we throw a + * {@link CancellationError} — signalling that the commit will never come. */ private async _waitForCommittedSession( untitledResource: URI, @@ -1613,20 +1635,19 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } // Response finished before the commit event arrived. - // Check whether it was cancelled — if so, the commit will never come. - const response = await responseCreatedPromise; - if (response?.isCanceled) { - throw new Error('Session was cancelled before being committed'); - } - - // Response completed normally — the commit is in-flight (the extension - // fired it before the response finished, but the async IPC chain hasn't - // delivered it yet). It should arrive in milliseconds; use a short - // safety timeout to avoid blocking forever on an IPC failure. + // The commit may still be in-flight — the agent could have + // initiated the worktree before the user cancelled, and the + // async IPC chain hasn't delivered the event yet. Fall through + // to the safety timeout to give it a chance to arrive. } const result = await raceTimeout(commitPromise, 5_000); if (!result) { + // Timed out — check whether this was a cancellation + const response = responseCreatedPromise ? await responseCreatedPromise : undefined; + if (response?.isCanceled) { + throw new CancellationError(); + } throw new Error('Timed out waiting for session commit'); } return result; diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/branchPicker.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/branchPicker.test.ts new file mode 100644 index 0000000000000..f4d9b48a366df --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/branchPicker.test.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IActiveSession, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvider } from '../../../sessions/browser/sessionsProvider.js'; +import { ISessionsProvidersService } from '../../../sessions/browser/sessionsProvidersService.js'; +import { COPILOT_PROVIDER_ID, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; +import { BranchPicker } from '../../browser/branchPicker.js'; +import { IsolationMode } from '../../browser/isolationPicker.js'; + +function createActiveSession(providerId: string, sessionId: string): IActiveSession { + const chat = { + resource: URI.parse(`test:///chat/${sessionId}`), + createdAt: new Date(), + title: constObservable('Chat'), + updatedAt: constObservable(new Date()), + status: constObservable(0), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + }; + + return { + sessionId, + resource: URI.parse(`test:///session/${sessionId}`), + providerId, + sessionType: 'copilot-cli', + icon: Codicon.copilot, + createdAt: new Date(), + workspace: constObservable(undefined), + title: constObservable('Session'), + updatedAt: constObservable(new Date()), + status: constObservable(0), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + loading: constObservable(false), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + gitHubInfo: constObservable(undefined), + chats: constObservable([chat]), + mainChat: chat, + activeChat: constObservable(chat), + }; +} + +class TestCopilotSession extends mock() { + override readonly loading = observableValue('loading', false); + override readonly branches = observableValue('branches', ['main', 'feature/test']); + override readonly branch = observableValue('branch', 'main'); + override readonly isolationMode = observableValue('isolationMode', 'worktree'); + + override setBranch(branch: string | undefined): void { + this.branch.set(branch, undefined); + } +} + +class TestCopilotProvider extends mock() { + constructor(private readonly sessionId: string, private readonly session: ICopilotChatSession) { + super(); + } + + override readonly id = COPILOT_PROVIDER_ID; + override readonly label = 'Copilot'; + override readonly icon = Codicon.copilot; + override readonly sessionTypes = []; + override readonly browseActions = []; + override readonly onDidChangeSessions = Event.None; + override readonly capabilities = { multipleChatsPerSession: false }; + + getSession(sessionId: string): ICopilotChatSession | undefined { + return sessionId === this.sessionId ? this.session : undefined; + } +} + +class TestSessionsProvidersService extends mock() { + constructor(private readonly provider: TestCopilotProvider) { + super(); + } + + override readonly onDidChangeProviders = Event.None; + override readonly onDidChangeSessions = Event.None; + override readonly onDidReplaceSession = Event.None; + + override getProviders(): ISessionsProvider[] { + return [this.provider]; + } + + override getProvider(providerId: string): T | undefined { + return providerId === this.provider.id ? this.provider as unknown as T : undefined; + } +} + +suite('BranchPicker', () => { + + const disposables = new DisposableStore(); + let activeSession: ReturnType>; + let providerSession: TestCopilotSession; + let showCalls: number; + let instantiationService: TestInstantiationService; + + setup(() => { + const sessionId = `${COPILOT_PROVIDER_ID}:session`; + showCalls = 0; + activeSession = observableValue('activeSession', createActiveSession(COPILOT_PROVIDER_ID, sessionId)); + providerSession = new TestCopilotSession(); + + const provider = new TestCopilotProvider(sessionId, providerSession); + const sessionsProvidersService = new TestSessionsProvidersService(provider); + + instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IActionWidgetService, { + isVisible: false, + hide: () => { }, + show: () => { showCalls++; }, + }); + instantiationService.stub(ISessionsManagementService, { + activeSession, + }); + instantiationService.stub(ISessionsProvidersService, sessionsProvidersService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('disables the picker instead of hiding it in folder mode', () => { + providerSession.isolationMode.set('workspace', undefined); + + const picker = disposables.add(instantiationService.createInstance(BranchPicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + const trigger = container.querySelector('a.action-label'); + assert.ok(slot); + assert.ok(trigger); + assert.strictEqual(slot.style.display, ''); + assert.strictEqual(slot.classList.contains('disabled'), true); + assert.strictEqual(trigger.getAttribute('aria-hidden'), 'false'); + assert.strictEqual(trigger.getAttribute('aria-disabled'), 'true'); + assert.strictEqual(trigger.tabIndex, -1); + + picker.showPicker(); + assert.strictEqual(showCalls, 0); + }); + + test('re-enables the picker when switching back to worktree mode', () => { + providerSession.isolationMode.set('workspace', undefined); + + const picker = disposables.add(instantiationService.createInstance(BranchPicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + const trigger = container.querySelector('a.action-label'); + assert.ok(slot); + assert.ok(trigger); + + providerSession.isolationMode.set('worktree', undefined); + + assert.strictEqual(slot.style.display, ''); + assert.strictEqual(slot.classList.contains('disabled'), false); + assert.strictEqual(trigger.getAttribute('aria-disabled'), 'false'); + assert.strictEqual(trigger.tabIndex, 0); + + picker.showPicker(); + assert.strictEqual(showCalls, 1); + }); +}); diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index c5306b912416e..7bc75aa03a3a9 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -177,6 +177,7 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: () => Promise, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }> }, ): CopilotChatSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); @@ -195,7 +196,7 @@ function createProviderForSendTests( instantiationService.stub(IChatSessionsService, { getChatSessionContribution: () => ({ type: 'test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }), getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }), - onDidCommitSession: Event.None, + onDidCommitSession: opts?.onDidCommitSession ?? Event.None, updateSessionOptions: () => true, setSessionOption: () => true, getSessionOption: () => undefined, @@ -737,7 +738,78 @@ suite('CopilotChatSessionsProvider', () => { await sendPromise.catch(() => { /* expected to reject */ }); }); - test('cancelling the request before commit removes the temp session', async () => { + /** + * Returns a provider where the commit event is controllable. The + * caller can fire the commit event at the right moment to simulate + * the session being committed mid-request, then cancel the request + * afterwards. The session should persist after cancellation. + */ + function makeCommittableProvider(): { + provider: CopilotChatSessionsProvider; + commitSession: (original: URI, committed: URI) => void; + cancelRequest: () => void; + } { + let resolveComplete!: () => void; + let resolveCreated!: (r: IChatResponseModel) => void; + const responseCompletePromise = new Promise(r => { resolveComplete = r; }); + const responseCreatedPromise = new Promise(r => { resolveCreated = r; }); + + const commitEmitter = disposables.add(new Emitter<{ original: URI; committed: URI }>()); + + const provider = createProviderForSendTests(disposables, model, async () => ({ + kind: 'sent' as const, + data: { + responseCompletePromise, + responseCreatedPromise, + agent: new class extends mock() { }(), + } as IChatSendRequestData, + }), { onDidCommitSession: commitEmitter.event }); + + return { + provider, + commitSession: (original, committed) => commitEmitter.fire({ original, committed }), + cancelRequest: () => { + resolveCreated({ isCanceled: true } as unknown as IChatResponseModel); + resolveComplete(); + }, + }; + } + + test('stopping a committed session keeps it in the list', async () => { + const { provider, commitSession, cancelRequest } = makeCommittableProvider(); + + const newSession = provider.createNewSession(workspace); + const sessionId = newSession.sessionId; + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' }); + await added; + + assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight'); + + // Get the temp session's resource so we can fire the commit event + const tempSession = provider.getSessions()[0]; + const tempResource = tempSession.resource; + + // Simulate commit: the agent created the worktree, so the URI + // swaps from untitled to a real committed resource. + const committedResource = URI.from({ scheme: AgentSessionProviders.Background, path: `/committed-${Date.now()}` }); + const committedAgentSession = createMockAgentSession(committedResource); + model.addSession(committedAgentSession); + commitSession(tempResource, committedResource); + + // _sendFirstChat should complete successfully now + await sendPromise; + + assert.strictEqual(provider.getSessions().length, 1, 'committed session should remain in list'); + + // Now cancel the request — session must stay + cancelRequest(); + + assert.strictEqual(provider.getSessions().length, 1, 'committed session should persist after stopping'); + }); + + test('cancelling the request before commit keeps the session with completed status', async () => { const { provider, cancelRequest } = makeInFlightProvider(); const changes: ISessionChangeEvent[] = []; @@ -755,13 +827,16 @@ suite('CopilotChatSessionsProvider', () => { // Simulate user stopping the request cancelRequest(); - await sendPromise.catch(() => { /* expected to reject */ }); + await sendPromise; - assert.strictEqual(provider.getSessions().length, 0, 'session should be cleaned up after cancellation'); + assert.strictEqual(provider.getSessions().length, 1, 'session should stay in list after cancellation'); assert.ok( - changes.some(e => e.removed.some(s => s.sessionId === sessionId)), - 'removed event should have fired', + changes.some(e => e.changed.some(s => s.sessionId === sessionId)), + 'changed event should have fired', ); + + // Clean up the kept session so it doesn't leak + await provider.deleteSession(sessionId); }); }); }); diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts index 8c5a667460c71..8bcfbc0fbe9a2 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts @@ -11,7 +11,7 @@ import { IGitHubPRComment, IGitHubPRReviewThread, IGitHubPullRequest, IGitHubPul import { GitHubPRFetcher } from '../fetchers/githubPRFetcher.js'; const LOG_PREFIX = '[GitHubPullRequestModel]'; -const DEFAULT_POLL_INTERVAL_MS = 30_000; +const DEFAULT_POLL_INTERVAL_MS = 60_000; /** * Reactive model for a GitHub pull request. Wraps fetcher data in diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 04b1dc2c6c3d0..7cca78452546b 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -22,6 +22,7 @@ import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/ import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -209,6 +210,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @INotificationService private readonly _notificationService: INotificationService, + @IStorageService private readonly _storageService: IStorageService, ) { super(); @@ -407,12 +409,24 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess // -- Session Actions -- - async archiveSession(_sessionId: string): Promise { - // Agent host sessions don't support archiving + async archiveSession(sessionId: string): Promise { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId) { + cached.isArchived.set(true, undefined); + this._storeArchivedState(rawId, true); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); + } } - async unarchiveSession(_sessionId: string): Promise { - // Agent host sessions don't support unarchiving + async unarchiveSession(sessionId: string): Promise { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId) { + cached.isArchived.set(false, undefined); + this._storeArchivedState(rawId, false); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); + } } async deleteSession(sessionId: string): Promise { @@ -421,6 +435,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess if (cached && rawId && this._connection) { await this._connection.disposeSession(AgentSession.uri(cached.agentProvider, rawId)); this._sessionCache.delete(rawId); + this._storeArchivedState(rawId, false); this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(cached)], changed: [] }); } } @@ -574,6 +589,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess changed.push(this._chatToSession(existing)); } else { const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label, this._connectionAuthority); + this._restoreArchivedState(rawId, cached); this._sessionCache.set(rawId, cached); added.push(this._chatToSession(cached)); } @@ -587,6 +603,9 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } } + // Prune archived IDs that no longer exist on the server + this._pruneArchivedIds(currentKeys); + if (added.length > 0 || removed.length > 0 || changed.length > 0) { this._onDidChangeSessions.fire({ added, removed, changed }); } @@ -650,6 +669,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess workingDirectory: workingDir, }; const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label, this._connectionAuthority); + this._restoreArchivedState(rawId, cached); this._sessionCache.set(rawId, cached); this._onDidChangeSessions.fire({ added: [this._chatToSession(cached)], removed: [], changed: [] }); } @@ -659,6 +679,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess const cached = this._sessionCache.get(rawId); if (cached) { this._sessionCache.delete(rawId); + this._storeArchivedState(rawId, false); this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(cached)], changed: [] }); } } @@ -672,6 +693,63 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } } + // -- Private: Archived State Persistence -- + + private get _archivedStorageKey(): string { + return `remoteAgentHost.archivedSessions.${this.id}`; + } + + private _loadArchivedIds(): Set { + const raw = this._storageService.get(this._archivedStorageKey, StorageScope.PROFILE); + if (!raw) { + return new Set(); + } + try { + const parsed = JSON.parse(raw); + return new Set(Array.isArray(parsed) ? parsed : []); + } catch { + return new Set(); + } + } + + private _storeArchivedState(rawId: string, archived: boolean): void { + const ids = this._loadArchivedIds(); + if (archived) { + ids.add(rawId); + } else { + ids.delete(rawId); + } + this._storageService.store(this._archivedStorageKey, JSON.stringify([...ids]), StorageScope.PROFILE, StorageTarget.USER); + } + + private _restoreArchivedState(rawId: string, session: RemoteSessionAdapter): void { + if (this._loadArchivedIds().has(rawId)) { + session.isArchived.set(true, undefined); + } + } + + /** + * Remove archived IDs that are no longer present on the server. + * Called after a full refresh to prevent unbounded growth of stored IDs. + */ + private _pruneArchivedIds(validIds: Set): void { + const archivedIds = this._loadArchivedIds(); + let changed = false; + for (const id of archivedIds) { + if (!validIds.has(id)) { + archivedIds.delete(id); + changed = true; + } + } + if (changed) { + if (archivedIds.size === 0) { + this._storageService.remove(this._archivedStorageKey, StorageScope.PROFILE); + } else { + this._storageService.store(this._archivedStorageKey, JSON.stringify([...archivedIds]), StorageScope.PROFILE, StorageTarget.USER); + } + } + } + private _rawIdFromChatId(chatId: string): string | undefined { const prefix = `${this.id}:`; const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 13c1dfaee496b..5bcceacf8f5e4 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -19,6 +19,7 @@ import { SessionStatus as ProtocolSessionStatus } from '../../../../../platform/ import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService, type ChatSendResult } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -113,6 +114,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined, }); + instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); const config: IRemoteAgentHostSessionsProviderConfig = { address: overrides?.address ?? 'localhost:4321', diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 6818eef1bb8e2..a06c1f590722c 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -33,6 +33,7 @@ import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; import { IsSessionArchivedContext, IsSessionPinnedContext, IsSessionReadContext, SessionItemContextMenuId } from './views/sessionsList.js'; import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { basename } from '../../../../base/common/resources.js'; /** * Sessions Title Bar Widget - renders the active chat session title @@ -41,7 +42,7 @@ import { IViewsService } from '../../../../workbench/services/views/common/views * Shows the current chat session label as a clickable pill with: * - Kind icon at the beginning (provider type icon) * - Session title - * - Repository folder name + * - Repository folder name and active branch/worktree name when available * * Session actions (changes, terminal, etc.) are rendered via the * SessionTitleActions menu toolbar next to the session title. @@ -79,6 +80,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { if (sessionData) { sessionData.title.read(reader); sessionData.status.read(reader); + sessionData.workspace.read(reader); } this.sessionsManagementService.activeProviderId.read(reader); this._lastRenderState = undefined; @@ -132,8 +134,10 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { const label = this._getActiveSessionLabel(); const icon = this._getActiveSessionIcon(); const repoLabel = this._getRepositoryLabel(); + const repoDetailLabel = this._getRepositoryDetailLabel(); + const pillLabel = repoLabel ? `${label} \u00B7 ${repoLabel}${repoDetailLabel ? ` (${repoDetailLabel})` : ''}` : label; // Build a render-state key from all displayed data - const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`; + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${repoDetailLabel ?? ''}`; // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -174,7 +178,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { centerGroup.appendChild(separator1); const repoEl = $('span.agent-sessions-titlebar-repo'); - repoEl.textContent = repoLabel; + repoEl.textContent = repoDetailLabel ? `${repoLabel} (${repoDetailLabel})` : repoLabel; centerGroup.appendChild(repoEl); } @@ -202,7 +206,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._dynamicDisposables.add(this.hoverService.setupManagedHover( getDefaultHoverDelegate('mouse'), sessionPill, - label + pillLabel )); // Keyboard handler @@ -254,6 +258,35 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { return undefined; } + private _getRepositoryDetailLabel(): string | undefined { + const sessionData = this.sessionsManagementService.activeSession.get(); + const workspace = sessionData?.workspace.get(); + const repository = workspace?.repositories[0]; + if (!workspace || !repository) { + return undefined; + } + + if (repository.detail && !workspace.label.includes(`[${repository.detail}]`)) { + return repository.detail; + } + + if (!repository.workingDirectory) { + return undefined; + } + + const worktreeName = basename(repository.workingDirectory); + if (!worktreeName) { + return undefined; + } + + const repositoryName = basename(repository.uri); + if (worktreeName === workspace.label || worktreeName === repositoryName) { + return undefined; + } + + return worktreeName; + } + private _showContextMenu(e: MouseEvent): void { const sessionData = this.sessionsManagementService.activeSession.get(); if (!sessionData) { diff --git a/src/vs/sessions/skills/merge/SKILL.md b/src/vs/sessions/skills/merge/SKILL.md index cf406f75be57b..d3661e85902bb 100644 --- a/src/vs/sessions/skills/merge/SKILL.md +++ b/src/vs/sessions/skills/merge/SKILL.md @@ -6,8 +6,67 @@ description: Merge changes from the topic branch to the merge base branch. Use w # Merge Changes -Merge changes from the topic branch to the merge base branch. -The context block appended to the prompt contains the source and target branch information. +Merge the topic branch (checked out in the current worktree) into the merge base branch (checked out in the main worktree). The context block appended to the prompt contains the source branch, target branch, and main worktree path. -1. If there are any uncommitted changes, use the `/commit` skill to commit them -2. Merge the topic branch into the merge base branch. If there are any merge conflicts, resolve them and commit the merge. When in doubt on how to resolve a merge conflict, ask the user for guidance on how to proceed +## Guidelines + +- **Never force-push** (`--force`, `--force-with-lease`) without explicit user approval. +- **Never skip pre-push hooks** (do not use `--no-verify`). +- **Never rewrite or drop commits** without asking the user. +- When in doubt about conflict resolution — ask the user. + +## Workflow + +### 1. Commit uncommitted changes in the current worktree + +Check for uncommitted changes in the current worktree: +``` +git status --porcelain +``` +If there are uncommitted changes, use the `/commit` skill to commit them before continuing. + +### 2. Merge the topic branch into the base branch + +Use `git -C ` to run commands against the main worktree without leaving the current worktree. + +``` +git -C merge +``` + +### 3. Handle merge conflicts + +If the merge reports conflicts: + +3.1. List conflicted files: +``` +git -C diff --name-only --diff-filter=U +``` + +3.2. For each conflicted file, read the file content, resolve the conflict by preserving the intent of both sides, and stage the resolved file: +``` +git -C add +``` + +3.3. When in doubt on how to resolve a merge conflict, ask the user for guidance. If the user wants to abort, run: +``` +git -C merge --abort +``` + +3.4. Once all conflicts are resolved and staged, commit the merge: +``` +git -C commit --no-edit +``` + +## Validation + +After the merge completes, verify the result: + +1. Confirm the main worktree is clean: +``` +git -C status --porcelain +``` + +2. Confirm the topic branch is an ancestor of the base branch (i.e. all commits are merged): +``` +git -C merge-base --is-ancestor HEAD +``` diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 98813cd36cc69..7c41e88e605c9 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -8,6 +8,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; +import { autorun } from '../../../base/common/observable.js'; import { revive } from '../../../base/common/marshalling.js'; import { Schemas } from '../../../base/common/network.js'; import { escapeRegExpCharacters } from '../../../base/common/strings.js'; @@ -30,7 +31,7 @@ import { AgentSessionProviders, getAgentSessionProvider } from '../../contrib/ch import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/attachments/chatDynamicVariables.js'; import { IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../contrib/chat/common/participants/chatAgents.js'; import { IPromptFileContext, IPromptsService, PromptsStorage } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; -import { isValidPromptType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { isValidPromptType, PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../contrib/chat/common/requestParser/chatRequestParser.js'; @@ -42,12 +43,13 @@ import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/lang import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IInstructionDto, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; +import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IPluginDto, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { ICustomizationHarnessService, IExternalCustomizationItem, IExternalCustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; interface AgentData { dispose: () => void; @@ -133,6 +135,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, @IConfigurationService private readonly _configurationService: IConfigurationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatAgents2); @@ -193,6 +196,19 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._register(this._promptsService.onDidChangeSkills(() => { void this._pushSkills(); })); + + // Push hooks to ext host + void this._pushHooks(); + this._register(this._promptsService.onDidChangeHooks(() => { + void this._pushHooks(); + })); + + // Push plugins to ext host (reactive via autorun) + this._register(autorun(reader => { + const plugins = this._agentPluginService.plugins.read(reader); + const dtos: IPluginDto[] = plugins.map(p => ({ uri: p.uri })); + this._proxy.$acceptPlugins(dtos); + })); } private _acceptActiveChatSession(widget: IChatWidget | undefined): void { @@ -231,6 +247,16 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA } } + private async _pushHooks(): Promise { + try { + const hookFiles = await this._promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); + const dtos: IHookDto[] = hookFiles.map(hookFile => ({ uri: hookFile.uri })); + this._proxy.$acceptHooks(dtos); + } catch (error) { + this._logService.error('[chat] Failed to push hooks to extension host', error); + } + } + $unregisterAgent(handle: number): void { this._agents.deleteAndDispose(handle); } diff --git a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts index 15774eafe5f6a..9f8db99b6d7d2 100644 --- a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts +++ b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts @@ -38,7 +38,6 @@ function toGitRepositoryState(dto: GitRepositoryStateDto | undefined): GitReposi name: dto.HEAD.name, commit: dto.HEAD.commit, remote: dto.HEAD.remote, - base: dto.HEAD.base, upstream: dto.HEAD.upstream, ahead: dto.HEAD.ahead, behind: dto.HEAD.behind, diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index a2c054bf3995b..512a1074be285 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1705,6 +1705,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.skill, provider); }, + registerHookProvider(provider: vscode.ChatHookProvider): vscode.Disposable { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.hook, provider); + }, registerChatDebugLogProvider(provider: vscode.ChatDebugLogProvider): vscode.Disposable { checkProposedApiEnabled(extension, 'chatDebug'); return extHostChatDebug.registerChatDebugLogProvider(provider); @@ -1737,6 +1741,22 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangeSkills(listener, thisArgs, disposables); }, + get hooks() { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.hooks as readonly vscode.ChatResource[]; + }, + onDidChangeHooks: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.onDidChangeHooks(listener, thisArgs, disposables); + }, + get plugins() { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.plugins as readonly vscode.ChatResource[]; + }, + onDidChangePlugins: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.onDidChangePlugins(listener, thisArgs, disposables); + }, registerChatSessionCustomizationProvider(chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider): vscode.Disposable { checkProposedApiEnabled(extension, 'chatSessionCustomizationProvider'); return extHostChatAgents2.registerChatSessionCustomizationProvider(extension, chatSessionType, metadata, provider); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 45bb9f3cdc5d5..3c9d7f0bac8e3 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1671,6 +1671,8 @@ export interface ExtHostChatAgentsShape2 { $acceptCustomAgents(agents: ICustomAgentDto[]): void; $acceptInstructions(instructions: IInstructionDto[]): void; $acceptSkills(skills: ISkillDto[]): void; + $acceptHooks(hooks: IHookDto[]): void; + $acceptPlugins(plugins: IPluginDto[]): void; } export interface ICustomAgentDto { @@ -1685,6 +1687,14 @@ export interface ISkillDto { uri: UriComponents; } +export interface IHookDto { + uri: UriComponents; +} + +export interface IPluginDto { + uri: UriComponents; +} + export interface IChatSessionCustomizationProviderMetadataDto { readonly label: string; readonly iconId?: string; @@ -3732,7 +3742,6 @@ export interface GitBranchDto { readonly commit?: string; readonly type: GitRefTypeDto; readonly remote?: string; - readonly base?: GitBaseRefDto; readonly upstream?: GitUpstreamRefDto; readonly ahead?: number; readonly behind?: number; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 96cae65b2871b..2c60850754dd2 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -28,7 +28,7 @@ import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js' import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IChatAgentInvokeResult, IChatAgentProgressShape, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IExtensionChatAgentMetadata, IInstructionDto, IMainContext, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from './extHost.protocol.js'; +import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IChatAgentInvokeResult, IChatAgentProgressShape, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IMainContext, IPluginDto, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from './extHost.protocol.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostDiagnostics } from './extHostDiagnostics.js'; import { ExtHostDocuments } from './extHostDocuments.js'; @@ -473,7 +473,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS private readonly _participantDetectionProviders = new Map(); private static _contributionsProviderIdPool = 0; - private readonly _promptFileProviders = new Map(); + private readonly _promptFileProviders = new Map(); private static _customizationProviderIdPool = 0; private readonly _customizationProviders = new Map(); @@ -498,10 +498,16 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS readonly onDidChangeInstructions = this._onDidChangeInstructions.event; private readonly _onDidChangeSkills = this._register(new Emitter()); readonly onDidChangeSkills = this._onDidChangeSkills.event; + private readonly _onDidChangeHooks = this._register(new Emitter()); + readonly onDidChangeHooks = this._onDidChangeHooks.event; + private readonly _onDidChangePlugins = this._register(new Emitter()); + readonly onDidChangePlugins = this._onDidChangePlugins.event; private _customAgents: vscode.ChatResource[] = []; private _instructions: vscode.ChatResource[] = []; private _skills: vscode.ChatResource[] = []; + private _hooks: vscode.ChatResource[] = []; + private _plugins: vscode.ChatResource[] = []; private _activeChatPanelSessionResource: URI | undefined; @@ -524,6 +530,14 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return this._skills; } + get hooks(): readonly vscode.ChatResource[] { + return this._hooks; + } + + get plugins(): readonly vscode.ChatResource[] { + return this._plugins; + } + $acceptCustomAgents(agents: ICustomAgentDto[]): void { this._customAgents = agents.map(a => Object.freeze({ uri: URI.revive(a.uri) })); this._onDidChangeCustomAgents.fire(); @@ -539,6 +553,16 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS this._onDidChangeSkills.fire(); } + $acceptHooks(hooks: IHookDto[]): void { + this._hooks = hooks.map(h => Object.freeze({ uri: URI.revive(h.uri) })); + this._onDidChangeHooks.fire(); + } + + $acceptPlugins(plugins: IPluginDto[]): void { + this._plugins = plugins.map(p => Object.freeze({ uri: URI.revive(p.uri) })); + this._onDidChangePlugins.fire(); + } + constructor( mainContext: IMainContext, private readonly _logService: ILogService, @@ -600,7 +624,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS * Internal method that handles all prompt file provider types. * Routes custom agents, instructions, prompt files, and skills to the unified internal implementation. */ - registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: vscode.ChatCustomAgentProvider | vscode.ChatInstructionsProvider | vscode.ChatPromptFileProvider | vscode.ChatSkillProvider): vscode.Disposable { + registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: vscode.ChatCustomAgentProvider | vscode.ChatInstructionsProvider | vscode.ChatPromptFileProvider | vscode.ChatSkillProvider | vscode.ChatHookProvider): vscode.Disposable { const handle = ExtHostChatAgents2._contributionsProviderIdPool++; this._promptFileProviders.set(handle, { extension, provider }); this._proxy.$registerPromptFileProvider(handle, type, extension.identifier); @@ -623,6 +647,9 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS case PromptsType.skill: changeEvent = (provider as vscode.ChatSkillProvider).onDidChangeSkills; break; + case PromptsType.hook: + changeEvent = (provider as vscode.ChatHookProvider).onDidChangeHooks; + break; } if (changeEvent) { @@ -660,6 +687,9 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS case PromptsType.skill: resources = await (provider as vscode.ChatSkillProvider).provideSkills(context, token) ?? undefined; break; + case PromptsType.hook: + resources = await (provider as vscode.ChatHookProvider).provideHooks(context, token) ?? undefined; + break; } return resources; diff --git a/src/vs/workbench/api/common/extHostGitExtensionService.ts b/src/vs/workbench/api/common/extHostGitExtensionService.ts index a0f7ec43df163..d60848c571575 100644 --- a/src/vs/workbench/api/common/extHostGitExtensionService.ts +++ b/src/vs/workbench/api/common/extHostGitExtensionService.ts @@ -11,7 +11,7 @@ import { ExtensionIdentifier } from '../../../platform/extensions/common/extensi import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { IExtHostExtensionService } from './extHostExtensionService.js'; import { IExtHostRpcService } from './extHostRpcService.js'; -import { ExtHostGitExtensionShape, GitBaseRefDto, GitBranchDto, GitChangeDto, GitDiffChangeDto, GitRefDto, GitRefQueryDto, GitRefTypeDto, GitRepositoryStateDto, GitUpstreamRefDto, MainContext, MainThreadGitExtensionShape } from './extHost.protocol.js'; +import { ExtHostGitExtensionShape, GitBranchDto, GitChangeDto, GitDiffChangeDto, GitRefDto, GitRefQueryDto, GitRefTypeDto, GitRepositoryStateDto, GitUpstreamRefDto, MainContext, MainThreadGitExtensionShape } from './extHost.protocol.js'; import { ResourceMap } from '../../../base/common/map.js'; const GIT_EXTENSION_ID = 'vscode.git'; @@ -31,7 +31,6 @@ function toGitBranchDto(branch: Branch): GitBranchDto { commit: branch.commit, type: toGitRefTypeDto(branch.type), remote: branch.remote, - base: branch.base, upstream: branch.upstream ? toGitUpstreamRefDto(branch.upstream) : undefined, ahead: branch.ahead, behind: branch.behind, @@ -208,20 +207,10 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi const existingHandle = this._repositoryByUri.get(repository.rootUri); if (existingHandle !== undefined) { - const state = await this._getRepositoryState(repository); + const state = this._getRepositoryState(repository); return { handle: existingHandle, rootUri: repository.rootUri, state }; } - let repositoryState = repository.state; - if (repositoryState.HEAD === undefined) { - // Opening the repository does not wait for the repository state to be - // initialized so we need to wait for the first change event to ensure - // that the repository state is fully loaded before we return it to the - // main thread. - await Event.toPromise(repositoryState.onDidChange, this._disposables); - repositoryState = repository.state; - } - // Store the repository and its handle in the maps const handle = ExtHostGitExtensionService._handlePool++; @@ -233,7 +222,7 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi this._proxy.$onDidChangeRepository(handle); })); - const state = await this._getRepositoryState(repository); + const state = this._getRepositoryState(repository); return { handle, rootUri: repository.rootUri, state }; } @@ -279,14 +268,11 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi return this._getRepositoryState(repository); } - private async _getRepositoryState(repository: Repository): Promise { + private _getRepositoryState(repository: Repository): GitRepositoryStateDto { const state = repository.state; - // Base branch - const base = await this._getBranchBase(repository); - return { - HEAD: state.HEAD ? toGitBranchDto({ ...state.HEAD, base }) : undefined, + HEAD: state.HEAD ? toGitBranchDto(state.HEAD) : undefined, mergeChanges: state.mergeChanges.map(toGitChangeDto), indexChanges: state.indexChanges.map(toGitChangeDto), workingTreeChanges: state.workingTreeChanges.map(toGitChangeDto), @@ -294,21 +280,6 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi }; } - private async _getBranchBase(repository: Repository): Promise { - const state = repository.state; - if (!state.HEAD?.name) { - return undefined; - } - - const baseBranch = await repository.getBranchBase(state.HEAD.name); - if (!baseBranch?.name) { - return undefined; - } - - const isProtected = repository.isBranchProtected(baseBranch); - return { name: baseBranch.name, isProtected }; - } - async $diffBetweenWithStats(handle: number, ref1: string, ref2: string, path?: string): Promise { const repository = this._repositories.get(handle); if (!repository) { diff --git a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 7652b7a7f49ab..4df20fdfeef8a 100644 --- a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts @@ -157,10 +157,6 @@ export class BrowserEditorInput extends EditorInput { } override get resource(): URI { - if (this._resourceBeforeDisposal) { - return this._resourceBeforeDisposal; - } - return BrowserViewUri.forId(this._id); } @@ -288,16 +284,19 @@ export class BrowserEditorInput extends EditorInput { }; } - // When closing the editor, toUntyped() is called after dispose(). - // So we save a snapshot of the resource so we can still use it after the model is disposed. - private _resourceBeforeDisposal: URI | undefined; override dispose(): void { + super.dispose(); // Emit `onWillDispose` event first, then clean up the model. if (this._model) { - this._resourceBeforeDisposal = this.resource; + // `toUntyped()` is called after disposal. Store the latest data in `_initialData` so we can still get them there. + this._initialData = { + id: this._id, + url: this._model.url, + title: this._model.title, + favicon: this._model.favicon + }; this._model.dispose(); this._model = undefined; } - super.dispose(); } serialize(): IBrowserEditorInputData { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 1e60e58fcd77c..be979c398020a 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -554,6 +554,12 @@ export class BrowserEditor extends EditorPane { }); this.setBackgroundImage(this._model.screenshot); + // When closing a tab, the model gets disposed before the editor input is cleared. + // So we make sure we don't keep a reference to the disposed model. + this._inputDisposables.add(this._model.onWillDispose(() => { + this._model = undefined; + })); + // Start / stop screenshots when the model visibility changes this._inputDisposables.add(this._model.onDidChangeVisibility(() => this.doScreenshot())); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts index 6770535d2770f..1f5c2b3adfb72 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts @@ -20,6 +20,7 @@ export enum BrowserOverlayType { const OVERLAY_DEFINITIONS: ReadonlyArray<{ className: string; type: BrowserOverlayType }> = [ { className: 'monaco-menu-container', type: BrowserOverlayType.Menu }, + { className: 'action-list-submenu-panel', type: BrowserOverlayType.Menu }, { className: 'quick-input-widget', type: BrowserOverlayType.QuickInput }, { className: 'monaco-hover', type: BrowserOverlayType.Hover }, { className: 'editor-widget', type: BrowserOverlayType.Hover }, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts index b6eb80ddbbfe6..a36982f973fd3 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts @@ -28,8 +28,6 @@ import { IChatWidget } from '../chat.js'; import { imageToHash, isImage } from '../widget/input/editor/chatPasteProviders.js'; import { convertBufferToScreenshotVariable } from '../attachments/chatScreenshotContext.js'; import { ChatInstructionsPickerPick } from '../promptSyntax/attachInstructionsAction.js'; -import { createDebugEventsAttachment } from '../chatDebug/chatDebugAttachment.js'; -import { IChatDebugService } from '../../common/chatDebugService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { getAgentSessionProviderIcon, AgentSessionProviders } from '../agentSessions/agentSessions.js'; import { ITerminalService } from '../../../terminal/browser/terminal.js'; @@ -66,7 +64,6 @@ export class ChatContextContributions extends Disposable implements IWorkbenchCo this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(OpenEditorContextValuePick))); this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(ClipboardImageContextValuePick))); this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(ScreenshotContextValuePick))); - this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(DebugEventsSnapshotContextValuePick))); this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(SessionReferenceContextPickerPick))); } } @@ -300,31 +297,6 @@ class ScreenshotContextValuePick implements IChatContextValueItem { } } -class DebugEventsSnapshotContextValuePick implements IChatContextValueItem { - - readonly type = 'valuePick'; - readonly icon = Codicon.output; - readonly label = localize('chatContext.debugEventsSnapshot', 'Debug Events Snapshot'); - readonly ordinal = -600; - - constructor( - @IChatDebugService private readonly _chatDebugService: IChatDebugService, - ) { } - - isEnabled(widget: IChatWidget): boolean { - const sessionResource = widget.viewModel?.sessionResource; - return !!sessionResource && this._chatDebugService.getEvents(sessionResource).length > 0; - } - - async asAttachment(widget: IChatWidget): Promise { - const sessionResource = widget.viewModel?.sessionResource; - if (!sessionResource) { - return undefined; - } - return createDebugEventsAttachment(sessionResource, this._chatDebugService); - } -} - class SessionReferenceContextPickerPick implements IChatContextPickerItem { readonly type = 'pickerPick'; diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index 4ceb53bc152b0..b6799d5a71523 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -5,6 +5,7 @@ import { Action } from '../../../../base/common/actions.js'; import { SequencerByKey } from '../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { revive } from '../../../../base/common/marshalling.js'; import { dirname, isEqual, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; @@ -22,6 +23,7 @@ import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; import { IPluginSource } from '../common/plugins/pluginSource.js'; +import { IPluginGitService } from '../common/plugins/pluginGitService.js'; import { GitHubPluginSource, GitUrlPluginSource, NpmPluginSource, PipPluginSource, RelativePathPluginSource } from './pluginSources.js'; const MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1'; @@ -50,6 +52,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @INotificationService private readonly _notificationService: INotificationService, + @IPluginGitService private readonly _pluginGit: IPluginGitService, @IProgressService private readonly _progressService: IProgressService, @IStorageService private readonly _storageService: IStorageService, ) { @@ -141,22 +144,24 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi const updateLabel = options?.pluginName ?? marketplace.displayLabel; try { - const doPull = async () => { - return !!(await this._commandService.executeCommand('_git.pull', repoDir.fsPath)); - }; - if (options?.silent) { - return await doPull(); + return await this._pluginGit.pull(repoDir); } - return await this._progressService.withProgress( - { - location: ProgressLocation.Notification, - title: localize('updatingPlugin', "Updating plugin '{0}'...", updateLabel), - cancellable: false, - }, - doPull, - ); + const cts = new CancellationTokenSource(); + try { + return await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: localize('updatingPlugin', "Updating plugin '{0}'...", updateLabel), + cancellable: true, + }, + () => this._pluginGit.pull(repoDir, cts.token), + () => cts.dispose(true), + ); + } finally { + cts.dispose(); + } } catch (err) { this._logService.error(`[AgentPluginRepositoryService] Failed to update ${marketplace.displayLabel}:`, err); if (!options?.silent) { @@ -232,17 +237,19 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise { + const cts = new CancellationTokenSource(); try { await this._progressService.withProgress( { location: ProgressLocation.Notification, title: progressTitle, - cancellable: false, + cancellable: true, }, async () => { await this._fileService.createFolder(dirname(repoDir)); - await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, repoDir.fsPath, ref); - } + await this._pluginGit.cloneRepository(cloneUrl, repoDir, ref, cts.token); + }, + () => cts.dispose(true), ); } catch (err) { this._logService.error(`[AgentPluginRepositoryService] Failed to clone ${cloneUrl}:`, err); @@ -256,6 +263,8 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi }, }); throw err; + } finally { + cts.dispose(); } } @@ -297,8 +306,8 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } try { - await this._commandService.executeCommand('_git.fetchRepository', repoDir.fsPath); - const behindCount = await this._commandService.executeCommand('_git.revListCount', repoDir.fsPath, 'HEAD', '@{u}') ?? 0; + await this._pluginGit.fetchRepository(repoDir); + const behindCount = await this._pluginGit.revListCount(repoDir, 'HEAD', '@{u}'); return behindCount > 0; } catch (err) { this._logService.debug(`[AgentPluginRepositoryService] Silent fetch failed for ${marketplace.displayLabel}:`, err); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 720d1b7aa81dc..e66527eb39d60 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -1646,6 +1646,11 @@ export class AICustomizationListWidget extends Disposable { } } + // Hooks: expand file-level items into individual hook entries (matching core path display) + if (promptType === PromptsType.hook) { + return this._expandProviderHookItems(allItems, workspaceFolders); + } + return allItems .filter(item => item.type === promptType) .map((item: IExternalCustomizationItem) => { @@ -1671,6 +1676,70 @@ export class AICustomizationListWidget extends Disposable { .sort((a, b) => a.name.localeCompare(b.name)); } + /** + * Expands provider hook items (file-level) into individual hook entries + * with hook type labels and command descriptions, matching the core path display. + */ + private async _expandProviderHookItems(allItems: readonly IExternalCustomizationItem[], workspaceFolders: readonly { uri: URI }[]): Promise { + const hookFileItems = allItems.filter(item => item.type === PromptsType.hook); + const items: IAICustomizationListItem[] = []; + const activeRoot = this.workspaceService.getActiveProjectRoot(); + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + + for (const item of hookFileItems) { + const { storage } = item.groupKey + ? { storage: undefined } + : this._inferStorageAndGroup(item.uri, workspaceFolders); + + let parsedHooks = false; + try { + const content = await this.fileService.readFile(item.uri); + const json = parseJSONC(content.value.toString()); + const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome); + + if (hooks.size > 0) { + parsedHooks = true; + for (const [hookType, entry] of hooks) { + const hookMeta = HOOK_METADATA[hookType]; + for (let i = 0; i < entry.hooks.length; i++) { + const hook = entry.hooks[i]; + const cmdLabel = formatHookCommandLabel(hook, OS); + const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; + items.push({ + id: `${item.uri.toString()}#${entry.originalId}[${i}]`, + uri: item.uri, + name: hookMeta?.label ?? entry.originalId, + filename: basename(item.uri), + description: truncatedCmd || localize('hookUnset', "(unset)"), + storage, + promptType: PromptsType.hook, + disabled: item.enabled === false, + }); + } + } + } + } catch { + // Parse failed — fall through to show raw file + } + + if (!parsedHooks) { + items.push({ + id: item.uri.toString(), + uri: item.uri, + name: item.name, + filename: basename(item.uri), + description: item.description, + storage, + promptType: PromptsType.hook, + disabled: item.enabled === false, + }); + } + } + + return items; + } + /** * Infers storage and groupKey from a URI for auto-grouping. * diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index b93715cb9e4dc..e8053119dc057 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -160,6 +160,8 @@ import { AgentPluginRecommendations } from './claudePluginRecommendations.js'; import { AgentPluginEditor } from './agentPluginEditor/agentPluginEditor.js'; import { AgentPluginEditorInput } from './agentPluginEditor/agentPluginEditorInput.js'; import { AgentPluginRepositoryService } from './agentPluginRepositoryService.js'; +import { BrowserPluginGitCommandService } from './pluginGitCommandService.js'; +import { IPluginGitService } from '../common/plugins/pluginGitService.js'; import { PluginInstallService } from './pluginInstallService.js'; import './promptSyntax/promptCodingAgentActionContribution.js'; import './promptSyntax/promptToolsCodeLensProvider.js'; @@ -835,15 +837,6 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['experimental'], }, - ['chat.statusWidget.anonymous']: { - type: 'boolean', - description: nls.localize('chat.statusWidget.anonymous.description', "Controls whether anonymous users see the status widget in new chat sessions when rate limited."), - default: false, - tags: ['experimental', 'advanced'], - experiment: { - mode: 'auto' - } - }, [mcpDiscoverySection]: { type: 'object', properties: Object.fromEntries(allDiscoverySources.map(k => [k, { type: 'boolean', description: discoverySourceSettingsLabel[k] }])), @@ -1725,6 +1718,64 @@ class ChatForegroundSessionCountContribution extends Disposable implements IWork } } +type ChatModelsAtStartupEvent = { + totalModels: number; + modelsOpenInWidgets: number; + backgroundModels: number; + modelsKeptAliveOnlyForEdits: number; +}; + +type ChatModelsAtStartupClassification = { + totalModels: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of live chat models after startup revival.' }; + modelsOpenInWidgets: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chat models that are open in a chat widget or editor.' }; + backgroundModels: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chat models kept alive in the background without a widget.' }; + modelsKeptAliveOnlyForEdits: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chat models kept alive solely because they have unaccepted edits.' }; + owner: 'roblourens'; + comment: 'Tracks chat model counts at startup after reviving sessions with pending edits.'; +}; + +class ChatModelsAtStartupTelemetry extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatModelsAtStartupTelemetry'; + + constructor( + @IChatService private readonly chatService: IChatService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + ) { + super(); + void this.logTelemetry(); + } + + private async logTelemetry(): Promise { + await this.chatService.whenSessionsRevived; + + const snapshot = this.chatService.getChatModelReferenceDebugInfo(); + + let modelsOpenInWidgets = 0; + let backgroundModels = 0; + let modelsKeptAliveOnlyForEdits = 0; + + for (const model of snapshot.models) { + if (this.chatWidgetService.getWidgetBySessionResource(model.sessionResource)) { + modelsOpenInWidgets++; + } else { + backgroundModels++; + if (model.hasPendingEdits && model.referenceCount === 1) { + modelsKeptAliveOnlyForEdits++; + } + } + } + + this.telemetryService.publicLog2('chat.modelsAtStartup', { + totalModels: snapshot.totalModels, + modelsOpenInWidgets, + backgroundModels, + modelsKeptAliveOnlyForEdits, + }); + } +} + /** * Given builtin and custom modes, returns only the custom mode IDs that should have actions registered. @@ -1932,6 +1983,7 @@ registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution registerWorkbenchContribution2(RenameToolContribution.ID, RenameToolContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatForegroundSessionCountContribution.ID, ChatForegroundSessionCountContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatModelsAtStartupTelemetry.ID, ChatModelsAtStartupTelemetry, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(HookSchemaAssociationContribution.ID, HookSchemaAssociationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored); @@ -2002,6 +2054,7 @@ registerSingleton(IAgentPluginService, AgentPluginService, InstantiationType.Del registerSingleton(IPluginMarketplaceService, PluginMarketplaceService, InstantiationType.Delayed); registerSingleton(IWorkspacePluginSettingsService, WorkspacePluginSettingsService, InstantiationType.Delayed); registerSingleton(IAgentPluginRepositoryService, AgentPluginRepositoryService, InstantiationType.Delayed); +registerSingleton(IPluginGitService, BrowserPluginGitCommandService, InstantiationType.Delayed); registerSingleton(IPluginInstallService, PluginInstallService, InstantiationType.Delayed); registerSingleton(ILanguageModelToolsService, LanguageModelToolsService, InstantiationType.Delayed); registerSingleton(ILanguageModelToolsConfirmationService, LanguageModelToolsConfirmationService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugAttachment.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugAttachment.ts deleted file mode 100644 index cede789e8df3f..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugAttachment.ts +++ /dev/null @@ -1,40 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { URI } from '../../../../../base/common/uri.js'; -import * as nls from '../../../../../nls.js'; -import { IChatDebugService } from '../../common/chatDebugService.js'; -import { formatDebugEventsForContext, getDebugEventsModelDescription } from '../../common/chatDebugEvents.js'; -import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; - -/** - * Creates a debug events attachment for a chat session. - * This can be used to attach debug logs to a chat request. - */ -export async function createDebugEventsAttachment( - sessionResource: URI, - chatDebugService: IChatDebugService -): Promise { - chatDebugService.markDebugDataAttached(sessionResource); - if (!chatDebugService.hasInvokedProviders(sessionResource)) { - await chatDebugService.invokeProviders(sessionResource); - } - const events = chatDebugService.getEvents(sessionResource); - const summary = events.length > 0 - ? formatDebugEventsForContext(events) - : nls.localize('debugEventsSnapshot.noEvents', "No debug events found for this conversation."); - - return { - id: 'chatDebugEvents', - name: nls.localize('debugEventsSnapshot.contextName', "Debug Events Snapshot"), - icon: Codicon.output, - kind: 'debugEvents', - snapshotTime: Date.now(), - sessionResource, - value: summary, - modelDescription: getDebugEventsModelDescription(), - }; -} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts index 80fb8635e65e0..fa874ac6e0c82 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts @@ -158,11 +158,12 @@ export class ChatDebugEditor extends EditorPane { } else if (this.chatDebugService.activeSessionResource && event.sessionResource.toString() === this.chatDebugService.activeSessionResource.toString()) { if (this.viewState === ViewState.Overview) { this.overviewView?.refresh(); - } else if (this.viewState === ViewState.Logs) { - this.logsView?.refreshList(); } else if (this.viewState === ViewState.FlowChart) { this.flowChartView?.refresh(); } + // Note: Logs view is intentionally omitted here — it handles + // onDidAddEvent internally via loadEvents() → addEvent() → + // scheduleRefresh() to avoid a redundant full refresh. } })); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index d2f6573afe641..1612e74bb79d4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -11,7 +11,7 @@ import { ProgressBar } from '../../../../../base/browser/ui/progressbar/progress import { IObjectTreeElement } from '../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { combinedDisposable, Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../base/common/observable.js'; import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -24,15 +24,13 @@ import { WorkbenchList, WorkbenchObjectTree } from '../../../../../platform/list import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles, defaultProgressBarStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { FilterWidget } from '../../../../browser/parts/views/viewFilter.js'; import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; -import { filterDebugEventsByText } from '../../common/chatDebugEvents.js'; +import { debugEventMatchesText } from '../../common/chatDebugEvents.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { ChatDebugEventRenderer, ChatDebugEventDelegate, ChatDebugEventTreeRenderer, getEventCreatedText, getEventNameText, getEventDetailsText } from './chatDebugEventList.js'; import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem, LogsViewMode } from './chatDebugTypes.js'; import { ChatDebugFilterState, bindFilterContextKeys } from './chatDebugFilters.js'; import { ChatDebugDetailPanel } from './chatDebugDetailPanel.js'; -import { IChatWidgetService } from '../chat.js'; -import { createDebugEventsAttachment } from './chatDebugAttachment.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { Action, Separator } from '../../../../../base/common/actions.js'; @@ -40,6 +38,8 @@ import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; const $ = DOM.$; +const PAGE_SIZE = 1000; + export const enum LogsNavigation { Home = 'home', Overview = 'overview', @@ -67,11 +67,22 @@ export class ChatDebugLogsView extends Disposable { private currentSessionResource: URI | undefined; private logsViewMode: LogsViewMode = LogsViewMode.Tree; private events: IChatDebugEvent[] = []; + private filteredEvents: IChatDebugEvent[] = []; + private filterDirty = true; + private cachedIncludeTerms: string[] = []; + private cachedExcludeTerms: string[] = []; + private cachedTextFilter: string | undefined; private currentDimension: Dimension | undefined; private readonly eventListener = this._register(new MutableDisposable()); private readonly sessionStateDisposable = this._register(new MutableDisposable()); private readonly refreshScheduler: RunOnceScheduler; private readonly progressBar: ProgressBar; + private readonly showMoreContainer: HTMLElement; + private readonly showMoreDisposables = this._register(new DisposableStore()); + private showMoreStatusLabel: HTMLElement | undefined; + private showMoreBtn: Button | undefined; + private showMoreVisible = false; + private visibleLimit = PAGE_SIZE; constructor( parent: HTMLElement, @@ -80,7 +91,6 @@ export class ChatDebugLogsView extends Disposable { @IChatDebugService private readonly chatDebugService: IChatDebugService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IClipboardService private readonly clipboardService: IClipboardService, @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { @@ -133,22 +143,6 @@ export class ChatDebugLogsView extends Disposable { const filterContainer = DOM.append(this.headerContainer, $('.viewpane-filter-container')); filterContainer.appendChild(this.filterWidget.element); - // Troubleshoot button - const troubleshootButton = this._register(new Button(this.headerContainer, { ...defaultButtonStyles, secondary: true, title: localize('chatDebug.troubleshoot', "Add snapshot to Chat") })); - troubleshootButton.element.classList.add('chat-debug-troubleshoot-button', 'monaco-text-button'); - DOM.append(troubleshootButton.element, $(`span${ThemeIcon.asCSSSelector(Codicon.chatSparkle)}`)); - this._register(troubleshootButton.onDidClick(async () => { - if (!this.currentSessionResource) { - return; - } - const widget = await this.chatWidgetService.openSession(this.currentSessionResource); - if (widget) { - const attachment = await createDebugEventsAttachment(this.currentSessionResource, this.chatDebugService); - widget.attachmentModel.addContext(attachment); - widget.focusInput(); - } - })); - this._register(this.filterWidget.onDidChangeFilterText(text => { this.filterState.setTextFilter(text); })); @@ -157,6 +151,8 @@ export class ChatDebugLogsView extends Disposable { this._register(this.filterState.onDidChange(() => { syncContextKeys(); this.updateMoreFiltersChecked(); + this.visibleLimit = PAGE_SIZE; + this.filterDirty = true; this.refreshList(); })); @@ -181,6 +177,10 @@ export class ChatDebugLogsView extends Disposable { // Body container this.bodyContainer = DOM.append(mainColumn, $('.chat-debug-logs-body')); + // "Show More" container (below the body, shown when events exceed the visible limit) + this.showMoreContainer = DOM.append(mainColumn, $('.chat-debug-logs-show-more')); + DOM.hide(this.showMoreContainer); + // List container (initially hidden — tree view is default) this.listContainer = DOM.append(this.bodyContainer, $('.chat-debug-list-container')); DOM.hide(this.listContainer); @@ -287,6 +287,9 @@ export class ChatDebugLogsView extends Disposable { } setSession(sessionResource: URI): void { + if (!this.currentSessionResource || this.currentSessionResource.toString() !== sessionResource.toString()) { + this.visibleLimit = PAGE_SIZE; + } this.currentSessionResource = sessionResource; } @@ -329,9 +332,10 @@ export class ChatDebugLogsView extends Disposable { const breadcrumbHeight = 22; const headerHeight = this.headerContainer.offsetHeight; const tableHeaderHeight = this.tableHeader.offsetHeight; + const showMoreHeight = this.showMoreContainer.offsetHeight; const detailVisible = this.detailPanel.isVisible; const detailWidth = detailVisible ? this.detailPanel.width : 0; - const listHeight = dimension.height - breadcrumbHeight - headerHeight - tableHeaderHeight; + const listHeight = dimension.height - breadcrumbHeight - headerHeight - tableHeaderHeight - showMoreHeight; const listWidth = dimension.width - detailWidth; if (this.logsViewMode === LogsViewMode.Tree) { this.tree.layout(listHeight, listWidth); @@ -345,50 +349,113 @@ export class ChatDebugLogsView extends Disposable { } refreshList(): void { - let filtered: readonly IChatDebugEvent[] = this.events; - - // Filter by kind toggles (pass category for generic events so only - // discovery-category events are affected by the Prompt Discovery toggle) - filtered = filtered.filter(e => { - const category = e.kind === 'generic' ? e.category : undefined; - return this.filterState.isKindVisible(e.kind, category); - }); - - // Filter by text search and timestamp (before:/after: syntax is handled - // inside filterDebugEventsByText) - const filterText = this.filterState.textFilter; - if (filterText) { - filtered = filterDebugEventsByText(filtered, filterText); + // Rebuild the filtered list from scratch only when filter criteria + // changed or events were bulk-reloaded. During streaming backfill + // the filtered list is kept up-to-date incrementally via addEvent(), + // making each refresh O(1) instead of O(n). + if (this.filterDirty) { + this.filteredEvents = this.events.filter(e => this.passesCurrentFilter(e)); + this.filterDirty = false; } + // Paginate: show only the first `visibleLimit` events to keep the UI + // responsive for large sessions. The "Show More" button loads the + // next page. + const totalFiltered = this.filteredEvents.length; + const display = totalFiltered > this.visibleLimit ? this.filteredEvents.slice(0, this.visibleLimit) : this.filteredEvents; + if (this.logsViewMode === LogsViewMode.List) { - this.list.splice(0, this.list.length, filtered); + this.list.splice(0, this.list.length, display); } else { - this.refreshTree(filtered); + this.refreshTree(display); + } + + this.updateShowMore(totalFiltered); + + // Re-layout when show-more visibility changed so the list/tree + // height accounts for the footer. + if (this.currentDimension) { + this.layout(this.currentDimension); } } addEvent(event: IChatDebugEvent): void { - // Binary-insert to maintain chronological order without a full sort. - // Events almost always arrive in order, so the insertion point is - // typically at the end (O(log n) comparison, O(1) splice). + // Binary-insert into the unfiltered array to maintain chronological + // order. Events almost always arrive in order, so the insertion + // point is typically at the end (O(log n) comparison, O(1) splice). + this.binaryInsert(this.events, event); + + // Incrementally update the filtered list so refreshList() does not + // need to re-scan the entire events array on every debounced tick. + if (!this.filterDirty && this.passesCurrentFilter(event)) { + this.binaryInsert(this.filteredEvents, event); + } + + this.scheduleRefresh(); + } + + private binaryInsert(arr: IChatDebugEvent[], event: IChatDebugEvent): void { const time = event.created.getTime(); let lo = 0; - let hi = this.events.length; + let hi = arr.length; while (lo < hi) { const mid = (lo + hi) >>> 1; - if (this.events[mid].created.getTime() <= time) { + if (arr[mid].created.getTime() <= time) { lo = mid + 1; } else { hi = mid; } } - if (lo === this.events.length) { - this.events.push(event); + if (lo === arr.length) { + arr.push(event); } else { - this.events.splice(lo, 0, event); + arr.splice(lo, 0, event); } - this.scheduleRefresh(); + } + + /** + * Tests whether a single event passes the current kind + text + timestamp + * filters. Used for incremental filtering on each addEvent() call. + */ + private passesCurrentFilter(event: IChatDebugEvent): boolean { + // Kind filter + const category = event.kind === 'generic' ? event.category : undefined; + if (!this.filterState.isKindVisible(event.kind, category)) { + return false; + } + + // Timestamp filter + if (!this.filterState.isTimestampVisible(event.created)) { + return false; + } + + // Text filter — use cached parsed terms to avoid re-splitting on + // every addEvent() call during rapid backfill. + this.ensureCachedTerms(); + if (this.cachedExcludeTerms.length > 0 && this.cachedExcludeTerms.some(term => debugEventMatchesText(event, term))) { + return false; + } + if (this.cachedIncludeTerms.length > 0 && !this.cachedIncludeTerms.some(term => debugEventMatchesText(event, term))) { + return false; + } + + return true; + } + + private ensureCachedTerms(): void { + const textOnly = this.filterState.textFilterWithoutTimestamps; + if (textOnly === this.cachedTextFilter) { + return; + } + this.cachedTextFilter = textOnly; + if (!textOnly) { + this.cachedIncludeTerms = []; + this.cachedExcludeTerms = []; + return; + } + const terms = textOnly.split(',').map(t => t.trim()).filter(t => t.length > 0); + this.cachedIncludeTerms = terms.filter(t => !t.startsWith('!')); + this.cachedExcludeTerms = terms.filter(t => t.startsWith('!')).map(t => t.slice(1).trim()).filter(t => t.length > 0); } private scheduleRefresh(): void { @@ -399,6 +466,7 @@ export class ChatDebugLogsView extends Disposable { private loadEvents(): void { this.events = [...this.chatDebugService.getEvents(this.currentSessionResource || undefined)]; + this.filterDirty = true; const addEventDisposable = this.chatDebugService.onDidAddEvent(e => { if (!this.currentSessionResource || e.sessionResource.toString() === this.currentSessionResource.toString()) { @@ -410,6 +478,7 @@ export class ChatDebugLogsView extends Disposable { const clearEventsDisposable = this.chatDebugService.onDidClearProviderEvents(sessionResource => { if (!this.currentSessionResource || sessionResource.toString() === this.currentSessionResource.toString()) { this.events = [...this.chatDebugService.getEvents(this.currentSessionResource || undefined)]; + this.filterDirty = true; this.refreshList(); } }); @@ -485,6 +554,39 @@ export class ChatDebugLogsView extends Disposable { return roots.map(toTreeElement); } + private updateShowMore(totalFiltered: number): void { + if (totalFiltered <= this.visibleLimit) { + if (this.showMoreVisible) { + DOM.hide(this.showMoreContainer); + this.showMoreVisible = false; + } + return; + } + + // Create the status label and button once, then reuse. + if (!this.showMoreStatusLabel) { + this.showMoreStatusLabel = DOM.append(this.showMoreContainer, $('span.chat-debug-logs-show-more-status')); + } + if (!this.showMoreBtn) { + this.showMoreBtn = this.showMoreDisposables.add(new Button(this.showMoreContainer, { ...defaultButtonStyles, secondary: true, title: localize('chatDebug.showMoreTitle', "Load more events") })); + this.showMoreDisposables.add(this.showMoreBtn.onDidClick(() => { + this.visibleLimit += PAGE_SIZE; + this.refreshList(); + })); + } + + const shown = Math.min(this.visibleLimit, totalFiltered); + const remaining = totalFiltered - shown; + + this.showMoreStatusLabel.textContent = localize('chatDebug.showingCount', "Showing {0} of {1} events", shown, totalFiltered); + this.showMoreBtn.label = localize('chatDebug.showMore', "Show More ({0})", remaining); + + if (!this.showMoreVisible) { + DOM.show(this.showMoreContainer); + this.showMoreVisible = true; + } + } + private toggleViewMode(): void { if (this.logsViewMode === LogsViewMode.List) { this.logsViewMode = LogsViewMode.Tree; diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css index 5709e69513e71..99eb0631eec22 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -359,6 +359,18 @@ flex: 1; overflow: hidden; } +.chat-debug-logs-show-more { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 6px 0; + flex-shrink: 0; +} +.chat-debug-logs-show-more-status { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} .chat-debug-log-row { display: flex; align-items: center; diff --git a/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts b/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts new file mode 100644 index 0000000000000..4e38b92e2bfca --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { IPluginGitService } from '../common/plugins/pluginGitService.js'; + +function notSupported(): never { + throw new Error(localize('pluginsNotSupported', 'Agent plugins are not available in this environment')); +} + +/** + * Stub implementation of {@link IPluginGitService} that throws on + * every call. On desktop the native implementation is registered instead; + * this exists only so the browser layer has a default registration. + */ +export class BrowserPluginGitCommandService implements IPluginGitService { + declare readonly _serviceBrand: undefined; + + async cloneRepository(_cloneUrl: string, _targetDir: URI, _ref?: string, _token?: CancellationToken): Promise { notSupported(); } + async pull(_repoDir: URI, _token?: CancellationToken): Promise { notSupported(); } + async checkout(_repoDir: URI, _treeish: string, _detached?: boolean, _token?: CancellationToken): Promise { notSupported(); } + async revParse(_repoDir: URI, _ref: string): Promise { notSupported(); } + async fetch(_repoDir: URI, _token?: CancellationToken): Promise { notSupported(); } + async fetchRepository(_repoDir: URI, _token?: CancellationToken): Promise { notSupported(); } + async revListCount(_repoDir: URI, _fromRef: string, _toRef: string): Promise { notSupported(); } +} diff --git a/src/vs/workbench/contrib/chat/browser/pluginSources.ts b/src/vs/workbench/contrib/chat/browser/pluginSources.ts index 383e3112e6396..f057302a1471e 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginSources.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginSources.ts @@ -5,6 +5,7 @@ import { Action } from '../../../../base/common/actions.js'; import { CancelablePromise, timeout } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { isWindows } from '../../../../base/common/platform.js'; @@ -22,6 +23,7 @@ import { ITerminalInstance, ITerminalService } from '../../terminal/browser/term import { IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; import { IGitHubPluginSource, IGitUrlPluginSource, IMarketplacePlugin, INpmPluginSource, IPipPluginSource, IPluginSourceDescriptor, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; import { IPluginSource } from '../common/plugins/pluginSource.js'; +import { IPluginGitService } from '../common/plugins/pluginGitService.js'; // --------------------------------------------------------------------------- // Shared helpers @@ -41,12 +43,6 @@ function gitRevisionCacheSuffix(ref?: string, sha?: string): string[] { return []; } -function showGitOutputAction(commandService: ICommandService): Action { - return new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { - commandService.executeCommand('git.showOutput'); - }); -} - function shellEscapeArg(value: string): string { if (isWindows) { return `"${value.replace(/[`$"]/g, '`$&')}"`; @@ -70,6 +66,7 @@ abstract class AbstractGitPluginSource implements IPluginSource { @IFileService protected readonly _fileService: IFileService, @ILogService protected readonly _logService: ILogService, @INotificationService protected readonly _notificationService: INotificationService, + @IPluginGitService protected readonly _pluginGit: IPluginGitService, @IProgressService protected readonly _progressService: IProgressService, ) { } @@ -124,19 +121,18 @@ abstract class AbstractGitPluginSource implements IPluginSource { const failureLabel = options?.failureLabel ?? updateLabel; try { - const doUpdate = async () => { - await this._commandService.executeCommand('git.openRepository', repoDir.fsPath); + const doUpdate = async (cts?: CancellationTokenSource) => { const git = descriptor as IGitHubPluginSource | IGitUrlPluginSource; let changed: boolean; if (git.sha) { - const headBefore = await this._commandService.executeCommand('_git.revParse', repoDir.fsPath, 'HEAD').catch(() => undefined); - await this._commandService.executeCommand('git.fetch', repoDir.fsPath); - await this._checkoutRevision(repoDir, descriptor, failureLabel); - const headAfter = await this._commandService.executeCommand('_git.revParse', repoDir.fsPath, 'HEAD').catch(() => undefined); + const headBefore = await this._pluginGit.revParse(repoDir, 'HEAD').catch(() => undefined); + await this._pluginGit.fetch(repoDir, cts?.token); + await this._checkoutRevision(repoDir, descriptor, failureLabel, cts?.token); + const headAfter = await this._pluginGit.revParse(repoDir, 'HEAD').catch(() => undefined); changed = headBefore !== headAfter; } else { - changed = !!(await this._commandService.executeCommand('_git.pull', repoDir.fsPath)); - await this._checkoutRevision(repoDir, descriptor, failureLabel); + changed = await this._pluginGit.pull(repoDir, cts?.token); + await this._checkoutRevision(repoDir, descriptor, failureLabel, cts?.token); } return changed; }; @@ -145,21 +141,26 @@ abstract class AbstractGitPluginSource implements IPluginSource { return await doUpdate(); } - return await this._progressService.withProgress( - { - location: ProgressLocation.Notification, - title: localize('updatingPluginSource', "Updating plugin '{0}'...", updateLabel), - cancellable: false, - }, - doUpdate, - ); + const cts = new CancellationTokenSource(); + try { + return await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: localize('updatingPluginSource', "Updating plugin '{0}'...", updateLabel), + cancellable: true, + }, + () => doUpdate(cts), + () => cts.dispose(true), + ); + } finally { + cts.dispose(); + } } catch (err) { this._logService.error(`[${this.kind}] Failed to update plugin source '${updateLabel}':`, err); if (!options?.silent) { this._notificationService.notify({ severity: Severity.Error, message: localize('pullPluginSourceFailed', "Failed to update plugin '{0}': {1}", failureLabel, err?.message ?? String(err)), - actions: { primary: [showGitOutputAction(this._commandService)] }, }); } throw err; @@ -169,30 +170,33 @@ abstract class AbstractGitPluginSource implements IPluginSource { // -- internal helpers --- private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise { + const cts = new CancellationTokenSource(); try { await this._progressService.withProgress( { location: ProgressLocation.Notification, title: progressTitle, - cancellable: false, + cancellable: true, }, async () => { await this._fileService.createFolder(dirname(repoDir)); - await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, repoDir.fsPath, ref); - } + await this._pluginGit.cloneRepository(cloneUrl, repoDir, ref, cts.token); + }, + () => cts.dispose(true), ); } catch (err) { this._logService.error(`[${this.kind}] Failed to clone ${cloneUrl}:`, err); this._notificationService.notify({ severity: Severity.Error, message: localize('cloneFailed', "Failed to install plugin '{0}': {1}", failureLabel, err?.message ?? String(err)), - actions: { primary: [showGitOutputAction(this._commandService)] }, }); throw err; + } finally { + cts.dispose(); } } - private async _checkoutRevision(repoDir: URI, descriptor: IPluginSourceDescriptor, failureLabel: string): Promise { + private async _checkoutRevision(repoDir: URI, descriptor: IPluginSourceDescriptor, failureLabel: string, token?: CancellationToken): Promise { const git = descriptor as IGitHubPluginSource | IGitUrlPluginSource; if (!git.sha && !git.ref) { return; @@ -200,16 +204,16 @@ abstract class AbstractGitPluginSource implements IPluginSource { try { if (git.sha) { - await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, git.sha, true); + await this._pluginGit.checkout(repoDir, git.sha, true, token); return; } - await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, git.ref); + // git.ref is guaranteed non-nullish by the guard above + await this._pluginGit.checkout(repoDir, git.ref!, undefined, token); } catch (err) { this._logService.error(`[${this.kind}] Failed to checkout revision for '${failureLabel}':`, err); this._notificationService.notify({ severity: Severity.Error, message: localize('checkoutPluginSourceFailed', "Failed to checkout plugin '{0}' to requested revision: {1}", failureLabel, err?.message ?? String(err)), - actions: { primary: [showGitOutputAction(this._commandService)] }, }); throw err; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 14c2ae100c6fb..eb69ec32bfab8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2414,7 +2414,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const requests = this.viewModel.model.getRequests(); for (let i = requests.length - 1; i >= 0; i -= 1) { const request = requests[i]; - if (request.shouldBeBlocked) { + if (request.shouldBeBlocked.get() || request === this.viewModel.model.checkpoint) { this.chatService.removeRequest(this.viewModel.sessionResource, request.id); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts index 87fa84c517cbf..cec2238e4ceb3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts @@ -9,7 +9,6 @@ import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } f import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; @@ -23,7 +22,8 @@ const $ = dom.$; /** * Widget that displays a status message with an optional action button. - * Only shown for free tier users when the setting is enabled (experiment controlled via onExP tag). + * Shown only when chat quota is exceeded and the chat session is empty, and only for + * anonymous or free tier users. */ export class ChatStatusWidget extends Disposable implements IChatInputPartWidget { @@ -37,7 +37,6 @@ export class ChatStatusWidget extends Disposable implements IChatInputPartWidget constructor( @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @ICommandService private readonly commandService: ICommandService, - @IConfigurationService private readonly configurationService: IConfigurationService, @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); @@ -51,7 +50,7 @@ export class ChatStatusWidget extends Disposable implements IChatInputPartWidget const entitlement = this.chatEntitlementService.entitlement; const isAnonymous = this.chatEntitlementService.anonymous; - if (isAnonymous && this.configurationService.getValue('chat.statusWidget.anonymous')) { + if (isAnonymous) { this.createWidgetContent('anonymous'); } else if (entitlement === ChatEntitlement.Free) { this.createWidgetContent('free'); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index 2d6454d6e2338..9e7e9c08cd9c4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -64,8 +64,6 @@ import { IChatWidget, IChatWidgetService } from '../../../chat.js'; import { resizeImage } from '../../../chatImageUtils.js'; import { ChatDynamicVariableModel } from '../../../attachments/chatDynamicVariables.js'; import { IChatService } from '../../../../common/chatService/chatService.js'; -import { IChatDebugService } from '../../../../common/chatDebugService.js'; -import { createDebugEventsAttachment } from '../../../chatDebug/chatDebugAttachment.js'; import { getPromptFileType } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { getChatSessionType } from '../../../../common/model/chatUri.js'; import { computeCompletionRanges, escapeForCharClass, IChatCompletionRangeResult, isEmptyUpToCompletionWord } from './chatInputCompletionUtils.js'; @@ -256,10 +254,6 @@ class SlashCommandCompletions extends Disposable { const userInvocableCommands = promptCommands .filter(c => { if (widget.lockedAgentId) { - // Exclude extension-provided prompt files for locked agents. - if (c.extension) { - return false; - } // Exclude hooks as those don't work in locked agent scenarios. try { const promptType = getPromptFileType(c.uri); @@ -861,7 +855,6 @@ interface IVariableCompletionsDetails { class BuiltinDynamicCompletions extends Disposable { private static readonly addReferenceCommand = '_addReferenceCmd'; - private static readonly addDebugEventsSnapshotCommand = '_addDebugEventsSnapshotCmd'; private static readonly VariableNameDef = new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][\\w:-]*`, 'g'); // MUST be using `g`-flag @@ -878,7 +871,6 @@ class BuiltinDynamicCompletions extends Disposable { @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IChatDebugService private readonly chatDebugService: IChatDebugService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, ) { super(); @@ -1037,46 +1029,10 @@ class BuiltinDynamicCompletions extends Disposable { return result; }, sessionWordPattern); - // Debug Events Snapshot completion - this.registerVariableCompletions('debugEventsSnapshot', ({ widget, range }) => { - if (widget.location !== ChatAgentLocation.Chat) { - return; - } - - const sessionResource = widget.viewModel?.sessionResource; - if (!sessionResource || this.chatDebugService.getEvents(sessionResource).length === 0) { - return; - } - - const text = `${chatVariableLeader}debugEventsSnapshot`; - const result: CompletionList = { suggestions: [] }; - result.suggestions.push({ - label: { label: text, description: localize('debugEventsSnapshot.description', 'Attach debug events snapshot') }, - filterText: text, - insertText: '', - range, - kind: CompletionItemKind.Text, - sortText: 'z', - command: { - id: BuiltinDynamicCompletions.addDebugEventsSnapshotCommand, title: '', arguments: [widget] - } - }); - return result; - }); - this._register(CommandsRegistry.registerCommand(BuiltinDynamicCompletions.addReferenceCommand, (_services, arg) => { assertType(arg instanceof ReferenceArgument); return this.cmdAddReference(arg); })); - - this._register(CommandsRegistry.registerCommand(BuiltinDynamicCompletions.addDebugEventsSnapshotCommand, async (_services, widget: IChatWidget) => { - const sessionResource = widget.viewModel?.sessionResource; - if (!sessionResource) { - return; - } - const attachment = await createDebugEventsAttachment(sessionResource, this.chatDebugService); - widget.attachmentModel.addContext(attachment); - })); } private findActiveCodeEditor(): ICodeEditor | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 0e69047f704d6..e02258b567d43 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -166,7 +166,13 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const currentMode = delegate.currentMode.get(); const filteredCustomModes = modes.custom.filter(mode => { const target = mode.target.get(); - return target === customAgentTarget || target === Target.Undefined; + if (target !== customAgentTarget && target !== Target.Undefined) { + return false; + } + if (mode.when && !this.contextKeyService.contextMatchesRules(mode.when)) { + return false; + } + return true; }); const customModes = groupBy( filteredCustomModes, @@ -196,6 +202,9 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { return mode.id !== ChatMode.Agent.id && shouldShowBuiltInMode(mode, assignments.get(), agentModeDisabledViaPolicy); }); const filteredCustomModes = modes.custom.filter(mode => { + if (mode.when && !this.contextKeyService.contextMatchesRules(mode.when)) { + return false; + } if (isModeConsideredBuiltIn(mode, this._productService)) { return shouldShowBuiltInMode(mode, assignments.get(), agentModeDisabledViaPolicy); } diff --git a/src/vs/workbench/contrib/chat/common/chatDebugEvents.ts b/src/vs/workbench/contrib/chat/common/chatDebugEvents.ts index 980e354a32512..1e34c3b7ea15e 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugEvents.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugEvents.ts @@ -5,75 +5,6 @@ import { IChatDebugEvent } from './chatDebugService.js'; -/** - * Descriptions of each debug event kind for the model. Adding a new event kind - * to {@link IChatDebugEvent} without adding an entry here will cause a compile error. - */ -export const debugEventKindDescriptions: Record = { - generic: '- generic (category: "discovery"): File discovery for instructions, skills, agents, hooks. Resolving returns a fileList with full file paths, load status, skip reasons, and source folders. Always resolve these for questions about customization files.\n' - + '- generic (category: "customization"): Resolved customizations for a request. Resolving returns per-file resolution logs showing how applyTo patterns matched files, which instructions were referenced, agent instructions added, and customization counts. Always resolve this for questions about why specific instructions were or were not included.\n' - + '- generic (other): Miscellaneous logs. Resolving returns additional text details.', - toolCall: '- toolCall: A tool invocation. Resolving returns tool name, input, output, status, and duration.', - modelTurn: '- modelTurn: An LLM round-trip. Resolving returns model name, token usage, timing, errors, and prompt sections.', - subagentInvocation: '- subagentInvocation: A sub-agent spawn. Resolving returns agent name, status, duration, and counts.', - userMessage: '- userMessage: The full prompt sent to the model. Resolving returns the complete message and all prompt sections (system prompt, instructions, context). Essential for understanding what the model received.', - agentResponse: '- agentResponse: The model\'s response. Resolving returns the full response text and sections.', -}; - -/** - * Formats debug events into a compact log-style summary for context attachment. - */ -export function formatDebugEventsForContext(events: readonly IChatDebugEvent[]): string { - const lines: string[] = []; - for (const event of events) { - const ts = event.created.toISOString(); - const id = event.id ? ` [id=${event.id}]` : ''; - switch (event.kind) { - case 'generic': - lines.push(`[${ts}]${id} ${event.level >= 3 ? 'ERROR' : event.level >= 2 ? 'WARN' : 'INFO'}: ${event.name}${event.details ? ' - ' + event.details : ''}${event.category ? ' (category: ' + event.category + ')' : ''}`); - break; - case 'toolCall': - lines.push(`[${ts}]${id} TOOL_CALL: ${event.toolName}${event.result ? ' result=' + event.result : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); - break; - case 'modelTurn': - lines.push(`[${ts}]${id} MODEL_TURN: ${event.requestName ?? 'unknown'}${event.model ? ' model=' + event.model : ''}${event.inputTokens !== undefined ? ' tokens(in=' + event.inputTokens + ',out=' + (event.outputTokens ?? '?') + ')' : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); - break; - case 'subagentInvocation': - lines.push(`[${ts}]${id} SUBAGENT: ${event.agentName}${event.status ? ' status=' + event.status : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); - break; - case 'userMessage': - lines.push(`[${ts}]${id} USER_MESSAGE: ${event.message.substring(0, 200)}${event.message.length > 200 ? '...' : ''} (${event.sections.length} sections)`); - break; - case 'agentResponse': - lines.push(`[${ts}]${id} AGENT_RESPONSE: ${event.message.substring(0, 200)}${event.message.length > 200 ? '...' : ''} (${event.sections.length} sections)`); - break; - default: { - const _: never = event; - void _; - break; - } - } - } - return lines.join('\n'); -} - -/** - * Constructs the model description for the debug events attachment, - * explaining to the model how to use the resolveDebugEventDetails tool. - */ -export function getDebugEventsModelDescription(): string { - return 'These are the debug event logs from the current chat conversation. Analyze them to help answer the user\'s troubleshooting question.\n' - + '\n' - + 'CRITICAL INSTRUCTION: You MUST call the resolveDebugEventDetails tool on relevant events BEFORE answering. The log lines below are only summaries — they do NOT contain the actual data (file paths, prompt content, tool I/O, etc.). The real information is only available by resolving events. Never answer based solely on the summary lines. Always resolve first, then answer.\n' - + '\n' - + 'Call resolveDebugEventDetails in parallel on all events that could be relevant to the user\'s question. When in doubt, resolve more events rather than fewer.\n' - + '\n' - + 'IMPORTANT: Do NOT mention event IDs, tool resolution steps, or internal debug mechanics in your response. The user does not know about debug events or event IDs. Present your findings directly and naturally, as if you simply know the answer. Never say things like "I need to resolve events" or show event IDs.\n' - + '\n' - + 'Event types and what resolving them returns:\n' - + Object.values(debugEventKindDescriptions).join('\n'); -} - /** * Checks whether a debug event matches a single text search term. * Used by both the debug panel filter and the listDebugEvents tool. @@ -205,11 +136,6 @@ export function filterDebugEventsByText(events: readonly IChatDebugEvent[], filt }); } -/** - * Description of the text filter syntax for tool schemas and documentation. - */ -export const debugEventFilterDescription = 'Comma-separated text search terms. Prefix a term with ! to exclude it. Matches against event kind, tool names, model names, agent names, categories, event names, and message content. Also supports before:YYYY[-MM[-DD[THH[:MM[:SS]]]]] and after:YYYY[-MM[-DD[THH[:MM[:SS]]]]] to filter by timestamp.'; - export interface DebugEventFilterOptions { readonly kind?: string; readonly filter?: string; diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index 747fc25c46c76..cbbe23d9f3c06 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -236,20 +236,6 @@ export interface IChatDebugService extends IDisposable { */ getImportedSessionTitle(sessionResource: URI): string | undefined; - /** - * Fired when debug data is attached to a session. - */ - readonly onDidAttachDebugData: Event; - - /** - * Mark a session as having debug data attached. - */ - markDebugDataAttached(sessionResource: URI): void; - - /** - * Check whether a session has had debug data attached. - */ - hasAttachedDebugData(sessionResource: URI): boolean; } /** diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index 60d45fe1f8dc8..a446b09717bf3 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -98,11 +98,6 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic private readonly _onDidClearProviderEvents = this._register(new Emitter()); readonly onDidClearProviderEvents: Event = this._onDidClearProviderEvents.event; - private readonly _onDidAttachDebugData = this._register(new Emitter()); - readonly onDidAttachDebugData: Event = this._onDidAttachDebugData.event; - - private readonly _debugDataAttachedSessions = new ResourceMap(); - private readonly _providers = new Set(); private readonly _invocationCts = new ResourceMap(); @@ -159,10 +154,15 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic this._sessionOrder.push(event.sessionResource); } else { // Move to end of LRU order so actively-used sessions are not evicted. - const idx = this._sessionOrder.findIndex(u => extUri.isEqual(u, event.sessionResource)); - if (idx !== -1 && idx !== this._sessionOrder.length - 1) { - this._sessionOrder.splice(idx, 1); - this._sessionOrder.push(event.sessionResource); + // Fast-path: during streaming/backfill all events target the same + // session which is already at the tail — skip the linear scan. + const last = this._sessionOrder.length - 1; + if (last < 0 || !extUri.isEqual(this._sessionOrder[last], event.sessionResource)) { + const idx = this._sessionOrder.findIndex(u => extUri.isEqual(u, event.sessionResource)); + if (idx !== -1 && idx !== last) { + this._sessionOrder.splice(idx, 1); + this._sessionOrder.push(event.sessionResource); + } } } buffer.push(event); @@ -175,20 +175,40 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic } getEvents(sessionResource?: URI): readonly IChatDebugEvent[] { - let result: IChatDebugEvent[]; if (sessionResource) { const buffer = this._sessionBuffers.get(sessionResource); - result = buffer ? buffer.toArray() : []; - } else { - result = []; - for (const buffer of this._sessionBuffers.values()) { - result.push(...buffer.toArray()); + if (!buffer) { + return []; + } + const result = buffer.toArray(); + // Sort only when the buffer is not in chronological order, + // which can happen when events arrive out of order (e.g. + // tail-first backfill). When events arrive in + // order (the common case) the check is O(n) with no sort. + if (!this._isSorted(result)) { + result.sort((a, b) => a.created.getTime() - b.created.getTime()); } + return result; + } + + // Cross-session query: merge all buffers and sort to interleave. + const result: IChatDebugEvent[] = []; + for (const buffer of this._sessionBuffers.values()) { + result.push(...buffer.toArray()); } result.sort((a, b) => a.created.getTime() - b.created.getTime()); return result; } + private _isSorted(events: IChatDebugEvent[]): boolean { + for (let i = 1; i < events.length; i++) { + if (events[i].created.getTime() < events[i - 1].created.getTime()) { + return false; + } + } + return true; + } + getSessionResources(): readonly URI[] { return [...this._sessionOrder]; } @@ -196,7 +216,6 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic clear(): void { this._sessionBuffers.clear(); this._sessionOrder.length = 0; - this._debugDataAttachedSessions.clear(); this._importedSessions.clear(); this._importedSessionTitles.clear(); } @@ -206,7 +225,6 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic this._sessionBuffers.delete(sessionResource); this._importedSessions.delete(sessionResource); this._importedSessionTitles.delete(sessionResource); - this._debugDataAttachedSessions.delete(sessionResource); const cts = this._invocationCts.get(sessionResource); if (cts) { cts.cancel(); @@ -305,28 +323,23 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic cts.dispose(); this._invocationCts.delete(sessionResource); } - this._debugDataAttachedSessions.delete(sessionResource); } private _clearProviderEvents(sessionResource: URI): void { const buffer = this._sessionBuffers.get(sessionResource); if (buffer) { - buffer.removeWhere(event => this._providerEvents.has(event)); + // Provider events are typically the vast majority (90%+). + // Instead of iterating to remove them, extract the few core + // events, clear the buffer, and re-add them. + const coreEvents = buffer.toArray().filter(e => !this._providerEvents.has(e)); + buffer.clear(); + for (const e of coreEvents) { + buffer.push(e); + } } this._onDidClearProviderEvents.fire(sessionResource); } - markDebugDataAttached(sessionResource: URI): void { - if (!this._debugDataAttachedSessions.has(sessionResource)) { - this._debugDataAttachedSessions.set(sessionResource, true); - this._onDidAttachDebugData.fire(sessionResource); - } - } - - hasAttachedDebugData(sessionResource: URI): boolean { - return this._debugDataAttachedSessions.has(sessionResource); - } - async resolveEvent(eventId: string): Promise { for (const provider of this._providers) { if (provider.resolveChatDebugLogEvent) { diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index 05788e29c8557..6a92395b8f212 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -11,7 +11,7 @@ import { isUriComponents, URI } from '../../../../base/common/uri.js'; import { IOffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService, ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -279,6 +279,7 @@ export interface IChatMode { readonly target: IObservable; readonly visibility?: IObservable; readonly agents?: IObservable; + readonly when?: ContextKeyExpression; } export interface IVariableReference { @@ -327,6 +328,7 @@ export class CustomChatMode implements IChatMode { private readonly _visibilityObservable: ISettableObservable; private readonly _agentsObservable: ISettableObservable; private _source: IAgentSource; + private _when: ContextKeyExpression | undefined; public readonly id: string; @@ -390,6 +392,10 @@ export class CustomChatMode implements IChatMode { return this._agentsObservable; } + get when(): ContextKeyExpression | undefined { + return this._when; + } + public readonly kind = ChatModeKind.Agent; constructor( @@ -408,6 +414,7 @@ export class CustomChatMode implements IChatMode { this._modeInstructions = observableValue('_modeInstructions', customChatMode.agentInstructions); this._uriObservable = observableValue('uri', customChatMode.uri); this._source = customChatMode.source; + this._when = customChatMode.when; } /** @@ -427,6 +434,7 @@ export class CustomChatMode implements IChatMode { this._modeInstructions.set(newData.agentInstructions, tx); this._uriObservable.set(newData.uri, tx); this._source = newData.source; + this._when = newData.when; }); } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 3e0f1eec780e9..fa3d4f258c962 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1431,6 +1431,11 @@ export interface IChatService { _serviceBrand: undefined; transferredSessionResource: URI | undefined; + /** + * Promise that resolves when sessions with pending edits have been revived at startup. + */ + readonly whenSessionsRevived: Promise; + readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>; readonly onDidCreateModel: Event; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index be9efdf281716..9d9826059f9fb 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -132,6 +132,8 @@ export class ChatService extends Disposable implements IChatService { private readonly _chatServiceTelemetry: ChatServiceTelemetry; private readonly _chatSessionStore: ChatSessionStore; + readonly whenSessionsRevived: Promise; + readonly requestInProgressObs: IObservable; readonly chatModels: IObservable>; @@ -207,7 +209,9 @@ export class ChatService extends Disposable implements IChatService { this._transferredSessionResource = transferredData; } - this.reviveSessionsWithEdits(); + this.whenSessionsRevived = this.reviveSessionsWithEdits().catch(error => { + this.logService.error('Failed to revive chat sessions with edits', error); + }); this._register(storageService.onWillSaveState(() => this.saveState())); diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginGitCommandService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginGitCommandService.ts new file mode 100644 index 0000000000000..9ea3ca237cadf --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginGitCommandService.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; + +export const IPluginGitService = createDecorator('pluginGitService'); + +/** + * Abstracts git operations used by the agent plugin system. + * + * Concrete behavior depends on the platform-specific implementation that is + * registered for this service. + */ +export interface IPluginGitService { + readonly _serviceBrand: undefined; + + cloneRepository(cloneUrl: string, targetDir: URI, ref?: string, token?: CancellationToken): Promise; + pull(repoDir: URI, token?: CancellationToken): Promise; + checkout(repoDir: URI, treeish: string, detached?: boolean, token?: CancellationToken): Promise; + revParse(repoDir: URI, ref: string): Promise; + fetch(repoDir: URI, token?: CancellationToken): Promise; + openRepository(repoDir: URI): Promise; + fetchRepository(repoDir: URI, token?: CancellationToken): Promise; + revListCount(repoDir: URI, fromRef: string, toRef: string): Promise; +} diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts new file mode 100644 index 0000000000000..26447f80811c6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; + +export const IPluginGitService = createDecorator('pluginGitService'); + +/** + * Abstracts git operations used by the agent plugin system. + * + * Concrete behavior depends on the platform-specific implementation that is + * registered for this service. + */ +export interface IPluginGitService { + readonly _serviceBrand: undefined; + + cloneRepository(cloneUrl: string, targetDir: URI, ref?: string, token?: CancellationToken): Promise; + pull(repoDir: URI, token?: CancellationToken): Promise; + checkout(repoDir: URI, treeish: string, detached?: boolean, token?: CancellationToken): Promise; + revParse(repoDir: URI, ref: string): Promise; + fetch(repoDir: URI, token?: CancellationToken): Promise; + fetchRepository(repoDir: URI, token?: CancellationToken): Promise; + revListCount(repoDir: URI, fromRef: string, toRef: string): Promise; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index bb79be786623f..f19174cb5f39b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -178,11 +178,18 @@ export class ComputeAutomaticInstructions { return; } - for (const { uri, pattern } of instructionFiles) { + for (const instructionFile of instructionFiles) { if (token.isCancellationRequested) { return; } + const { uri, pattern, when } = instructionFile; + + // If a `when` clause is present, evaluate it; otherwise always include. + if (when && !this._contextKeyService.contextMatchesRules(when)) { + continue; + } + if (!pattern) { this._logService.trace(`[InstructionsContextComputer] No pattern (applyTo / paths) found: ${uri}`); telemetryEvent.debugDetails.push({ category: 'skipped', name: basename(uri).toString(), uri, reason: localize('debugDetail.noPattern', 'no applyTo pattern') }); @@ -342,14 +349,17 @@ export class ComputeAutomaticInstructions { entries.push(`If the file is not already available as attachment, use the ${readTool.variable} tool to acquire it.`); entries.push('Make sure to acquire the instructions before working with the codebase.'); let hasContent = false; - for (const { uri, description, pattern } of instructionFiles) { + for (const instruction of instructionFiles) { + if (instruction.when && !this._contextKeyService.contextMatchesRules(instruction.when)) { + continue; + } entries.push(''); - if (description) { - entries.push(`${description}`); + if (instruction.description) { + entries.push(`${instruction.description}`); } - entries.push(`${filePath(uri)}`); - if (pattern) { - entries.push(`${pattern}`); + entries.push(`${filePath(instruction.uri)}`); + if (instruction.pattern) { + entries.push(`${instruction.pattern}`); } entries.push(''); hasContent = true; @@ -440,10 +450,10 @@ export class ComputeAutomaticInstructions { const customAgentsEnabled = !!this._configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents); const canUseAgent = (() => { if (!this._enabledSubagents || this._enabledSubagents.includes('*')) { - return (agent: ICustomAgent) => agent.visibility.agentInvocable; + return (agent: ICustomAgent) => agent.visibility.agentInvocable && (!agent.when || this._contextKeyService.contextMatchesRules(agent.when)); } else { const subagents = this._enabledSubagents; - return (agent: ICustomAgent) => subagents.includes(agent.name); + return (agent: ICustomAgent) => subagents.includes(agent.name) && (!agent.when || this._contextKeyService.contextMatchesRules(agent.when)); } })(); const agents = customAgentsEnabled ? await this._promptsService.getCustomAgents(token) : []; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index ec2ffdf26dfac..f9eff3f5057aa 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -259,6 +259,12 @@ export interface ICustomAgent { * Where the agent was loaded from. */ readonly source: IAgentSource; + + /** + * Optional context key expression. When set, the agent is only available + * when this expression evaluates to true against a scoped context. + */ + readonly when?: ContextKeyExpression; } export interface IAgentInstructions { @@ -325,6 +331,12 @@ export interface IInstructionFile { * The source that produced this prompt path. */ readonly source?: PromptFileSource; + + /** + * Optional context key expression. When set, the instruction file is only available + * when this expression evaluates to true against a scoped context. + */ + readonly when?: ContextKeyExpression; } /** @@ -632,9 +644,14 @@ export interface IPromptsService extends IDisposable { */ readonly onDidChangeSkills: Event; + /** + * Event that is triggered when the effective hook availability or configuration changes. + */ + readonly onDidChangeHooks: Event; + /** * Gets all hooks collected from hooks.json files. - * The result is cached and invalidated when hook files change. + * The result is cached and invalidated when the effective hook availability or configuration changes. */ getHooks(token: CancellationToken): Promise; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index dca550d60d5a3..10f09d2e435bd 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -34,7 +34,7 @@ import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAU import { PROMPT_LANGUAGE_ID, PromptFileSource, PromptsType, Target, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IWorkspaceInstructionFile, PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { evaluateApplyToPattern, PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IInstructionDiscoveryInfo, IInstructionDiscoveryResult, IInstructionFile, IUserPromptPath, PromptsStorage, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IAgentInstructionFile, AgentInstructionFileType, Logger, ISlashCommandDiscoveryInfo, ISlashCommandDiscoveryResult, IAgentDiscoveryInfo, IAgentDiscoveryResult, IHookDiscoveryInfo, IResolvedChatPromptSlashCommand, InstructionsCollectionEvent } from './promptsService.js'; +import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, isExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IInstructionDiscoveryInfo, IInstructionDiscoveryResult, IInstructionFile, IUserPromptPath, PromptsStorage, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IAgentInstructionFile, AgentInstructionFileType, Logger, ISlashCommandDiscoveryInfo, ISlashCommandDiscoveryResult, IAgentDiscoveryInfo, IAgentDiscoveryResult, IHookDiscoveryInfo, IResolvedChatPromptSlashCommand, InstructionsCollectionEvent } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { ChatRequestHooks, parseSubagentHooksFromYaml } from '../hookSchema.js'; @@ -512,19 +512,22 @@ export class PromptsService extends Disposable implements IPromptsService { private async getExtensionPromptFiles(type: PromptsType, token: CancellationToken): Promise { await this.extensionService.whenInstalledExtensionsRegistered(); const settledResults = await Promise.allSettled(this.contributedFiles[type].values()); + // Note: `when` clauses are intentionally NOT evaluated here (global context). + // They are propagated into the model types (IAgentSkill, IChatPromptSlashCommand, + // ICustomAgent, IInstructionFile) and evaluated later at session-scoped time: + // - slash commands: per-widget in chatInputCompletions.ts via `c.when` + // - skills: in ComputeAutomaticInstructions using its scoped IContextKeyService + // - instructions: in ComputeAutomaticInstructions using its scoped IContextKeyService + // - agents: in modePickerActionItem.ts and ComputeAutomaticInstructions const contributedFiles = settledResults .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') .map(result => result.value) .filter(file => { - if (!file.when) { - return true; - } - const expr = ContextKeyExpr.deserialize(file.when); - if (!expr) { + if (file.when && !ContextKeyExpr.deserialize(file.when)) { this.logger.warn(`[getExtensionPromptFiles] Ignoring contributed prompt file with invalid when clause: ${file.when}`); return false; } - return this.contextKeyService.contextMatchesRules(expr); + return true; }); const activationEvent = this.getProviderActivationEvent(type); @@ -686,6 +689,9 @@ export class PromptsService extends Disposable implements IPromptsService { private asChatPromptSlashCommand(argumentHint: string | undefined, userInvocable: boolean | undefined, promptPath: IPromptPath): IChatPromptSlashCommand { let name = promptPath.name ?? getCleanPromptName(promptPath.uri); name = name.replace(/[^\p{L}\d_\-\.:]+/gu, '-'); // replace spaces with dashes + const when = isExtensionPromptPath(promptPath) && promptPath.when + ? ContextKeyExpr.deserialize(promptPath.when) ?? undefined + : undefined; return { uri: promptPath.uri, name: name, @@ -697,7 +703,7 @@ export class PromptsService extends Disposable implements IPromptsService { description: promptPath.description, argumentHint: argumentHint, userInvocable: userInvocable ?? true, - when: undefined, + when, }; } @@ -797,8 +803,11 @@ export class PromptsService extends Disposable implements IPromptsService { const target = getTarget(PromptsType.agent, ast.header ?? uri); const source: IAgentSource = IAgentSource.fromPromptPath(promptPath); + const when = isExtensionPromptPath(promptPath) && promptPath.when + ? ContextKeyExpr.deserialize(promptPath.when) ?? undefined + : undefined; if (!ast.header) { - const agent: ICustomAgent = { uri, name, agentInstructions, source, target, visibility: { userInvocable: true, agentInvocable: true } }; + const agent: ICustomAgent = { uri, name, agentInstructions, source, target, visibility: { userInvocable: true, agentInvocable: true }, ...(when !== undefined ? { when } : undefined) }; return { status: 'loaded', promptPath: this.withPromptPathMetadata(promptPath, name, description), agent }; } const visibility = { @@ -825,7 +834,7 @@ export class PromptsService extends Disposable implements IPromptsService { hooks = parseSubagentHooksFromYaml(hooksRaw, workspaceRootUri, userHome, target); } - const agent: ICustomAgent = { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, hooks, agentInstructions, source }; + const agent: ICustomAgent = { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, hooks, agentInstructions, source, ...(when !== undefined ? { when } : undefined) }; return { status: 'loaded', promptPath: this.withPromptPathMetadata(promptPath, name, description), agent }; } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); @@ -1132,6 +1141,10 @@ export class PromptsService extends Disposable implements IPromptsService { return this.cachedSkills.onDidChangePromise; } + public get onDidChangeHooks(): Event { + return this.cachedHooks.onDidChangePromise; + } + public async findAgentSkills(token: CancellationToken): Promise { const useAgentSkills = this.configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS); if (!useAgentSkills) { @@ -1151,6 +1164,9 @@ export class PromptsService extends Disposable implements IPromptsService { for (const file of discoveryInfo.files) { if (file.status === 'loaded' && file.promptPath.name) { const sanitizedDescription = this.truncateAgentSkillDescription(file.promptPath.description, file.promptPath.uri); + const when = isExtensionPromptPath(file.promptPath) && file.promptPath.when + ? ContextKeyExpr.deserialize(file.promptPath.when) ?? undefined + : undefined; result.push({ uri: file.promptPath.uri, storage: file.promptPath.storage, @@ -1158,7 +1174,7 @@ export class PromptsService extends Disposable implements IPromptsService { description: sanitizedDescription, disableModelInvocation: file.disableModelInvocation ?? false, userInvocable: file.userInvocable ?? true, - when: undefined, + when, pluginUri: file.promptPath.pluginUri, extension: file.promptPath.extension, }); @@ -1302,6 +1318,9 @@ export class PromptsService extends Disposable implements IPromptsService { const result: IInstructionFile[] = []; for (const file of discoveryInfo.files) { if (file.status === 'loaded' && file.promptPath.name) { + const when = isExtensionPromptPath(file.promptPath) && file.promptPath.when + ? ContextKeyExpr.deserialize(file.promptPath.when) ?? undefined + : undefined; result.push({ uri: file.promptPath.uri, storage: file.promptPath.storage, @@ -1311,6 +1330,7 @@ export class PromptsService extends Disposable implements IPromptsService { name: file.promptPath.name, description: file.promptPath.description, pattern: file.pattern, + when, }); } } diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index bb636f37e610f..0e4361f2d06ff 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -4,45 +4,55 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; -import { AgentHostContribution } from '../browser/agentSessions/agentHost/agentHostChatContribution.js'; import { autorun } from '../../../../base/common/observable.js'; import { resolve } from '../../../../base/common/path.js'; import { isMacintosh } from '../../../../base/common/platform.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { localize } from '../../../../nls.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ILocalGitService } from '../../../../platform/git/common/localGitService.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { registerSharedProcessRemoteService } from '../../../../platform/ipc/electron-browser/services.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; -import { IViewsService } from '../../../services/views/common/viewsService.js'; import { ViewContainerLocation } from '../../../common/views.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { INativeWorkbenchEnvironmentService } from '../../../services/environment/electron-browser/environmentService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; +import { IViewsService } from '../../../services/views/common/viewsService.js'; import { ACTION_ID_NEW_CHAT, CHAT_OPEN_ACTION_ID, IChatViewOpenOptions } from '../browser/actions/chatActions.js'; -import { ChatViewId, ChatViewPaneTarget, IChatWidgetService } from '../browser/chat.js'; -import { ChatEditorInput } from '../browser/widgetHosts/editor/chatEditorInput.js'; -import { ChatViewPane } from '../browser/widgetHosts/viewPane/chatViewPane.js'; +import { AgentHostContribution } from '../browser/agentSessions/agentHost/agentHostChatContribution.js'; import { AgentSessionProviders } from '../browser/agentSessions/agentSessions.js'; import { isSessionInProgressStatus } from '../browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../browser/agentSessions/agentSessionsService.js'; -import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; +import { ChatViewId, ChatViewPaneTarget, IChatWidgetService } from '../browser/chat.js'; +import { ChatEditorInput } from '../browser/widgetHosts/editor/chatEditorInput.js'; +import { ChatViewPane } from '../browser/widgetHosts/viewPane/chatViewPane.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; -import { ChatModeKind } from '../common/constants.js'; import { IChatService } from '../common/chatService/chatService.js'; +import { ChatModeKind } from '../common/constants.js'; +import { IPluginGitService } from '../common/plugins/pluginGitService.js'; import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js'; import { registerChatExportZipAction } from './actions/chatExportZip.js'; import { HoldToVoiceChatInChatViewAction, InlineVoiceChatAction, KeywordActivationContribution, QuickVoiceChatAction, ReadChatResponseAloud, StartVoiceChatAction, StopListeningAction, StopListeningAndSubmitAction, StopReadAloud, StopReadChatItemAloud, VoiceChatInChatViewAction } from './actions/voiceChatActions.js'; -import { NativeBuiltinToolsContribution } from './builtInTools/tools.js'; import { OpenAgentsWindowAction } from './agentSessions/agentSessionsActions.js'; +import { NativeBuiltinToolsContribution } from './builtInTools/tools.js'; +import { NativePluginGitCommandService } from './pluginGitCommandService.js'; + +// Override the browser PluginGitCommandService with the native one that always +// runs git locally via the shared process. +registerSingleton(IPluginGitService, NativePluginGitCommandService, InstantiationType.Delayed); +registerSharedProcessRemoteService(ILocalGitService, 'localGit'); class ChatCommandLineHandler extends Disposable { diff --git a/src/vs/workbench/contrib/chat/electron-browser/pluginGitCommandService.ts b/src/vs/workbench/contrib/chat/electron-browser/pluginGitCommandService.ts new file mode 100644 index 0000000000000..6a5887b19d667 --- /dev/null +++ b/src/vs/workbench/contrib/chat/electron-browser/pluginGitCommandService.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ILocalGitService } from '../../../../platform/git/common/localGitService.js'; +import { IPluginGitService } from '../common/plugins/pluginGitService.js'; + +/** + * Desktop implementation that always runs git locally via the shared process. + * The plugin cache is always on the local machine, so there is no need to + * delegate to the git extension (which may be running on a remote host). + * + * Cancellation tokens are mapped to operation IDs so that cancel requests + * survive the IPC boundary to the shared process (tokens don't serialise). + */ +export class NativePluginGitCommandService implements IPluginGitService { + declare readonly _serviceBrand: undefined; + + constructor( + @ILocalGitService private readonly _localGitService: ILocalGitService, + ) { } + + private _withCancel(token: CancellationToken | undefined, fn: (operationId: string) => Promise): Promise { + const operationId = generateUuid(); + const listener = token?.onCancellationRequested(() => { + this._localGitService.cancel(operationId).catch(() => { /* ignore */ }); + }); + return fn(operationId).finally(() => listener?.dispose()); + } + + async cloneRepository(cloneUrl: string, targetDir: URI, ref?: string, token?: CancellationToken): Promise { + await this._withCancel(token, id => this._localGitService.clone(id, cloneUrl, targetDir.fsPath, ref)); + } + + async pull(repoDir: URI, token?: CancellationToken): Promise { + return this._withCancel(token, id => this._localGitService.pull(id, repoDir.fsPath)); + } + + async checkout(repoDir: URI, treeish: string, detached?: boolean, token?: CancellationToken): Promise { + await this._withCancel(token, id => this._localGitService.checkout(id, repoDir.fsPath, treeish, detached)); + } + + async revParse(repoDir: URI, ref: string): Promise { + return this._localGitService.revParse(repoDir.fsPath, ref); + } + + async fetch(repoDir: URI, token?: CancellationToken): Promise { + await this._withCancel(token, id => this._localGitService.fetch(id, repoDir.fsPath)); + } + + async fetchRepository(repoDir: URI, token?: CancellationToken): Promise { + await this._withCancel(token, id => this._localGitService.fetch(id, repoDir.fsPath)); + } + + async revListCount(repoDir: URI, fromRef: string, toRef: string): Promise { + return this._localGitService.revListCount(repoDir.fsPath, fromRef, toRef); + } +} diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index 83057f077cb5c..f69380c85c138 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -16,10 +16,25 @@ import { IProgressService } from '../../../../../../platform/progress/common/pro import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { AgentPluginRepositoryService } from '../../../browser/agentPluginRepositoryService.js'; import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IPluginGitService } from '../../../common/plugins/pluginGitService.js'; suite('AgentPluginRepositoryService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + function stubPluginGit(overrides?: Partial): IPluginGitService { + return { + _serviceBrand: undefined, + cloneRepository: async () => { }, + pull: async () => false, + checkout: async () => { }, + revParse: async () => '', + fetch: async () => { }, + fetchRepository: async () => { }, + revListCount: async () => 0, + ...overrides, + } as IPluginGitService; + } + function createPlugin(marketplace: string, source: string): IMarketplacePlugin { const marketplaceReference = parseMarketplaceReference(marketplace); assert.ok(marketplaceReference); @@ -42,6 +57,7 @@ suite('AgentPluginRepositoryService', () => { function createService( onExists?: (resource: URI) => Promise, onExecuteCommand?: (id: string, ...args: unknown[]) => void, + pluginGitStub?: Partial, ): AgentPluginRepositoryService { const instantiationService = store.add(new TestInstantiationService()); @@ -63,6 +79,9 @@ suite('AgentPluginRepositoryService', () => { instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); + instantiationService.stub(IPluginGitService, stubPluginGit({ + ...pluginGitStub, + })); instantiationService.stub(IProgressService, progressService); instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); @@ -126,16 +145,16 @@ suite('AgentPluginRepositoryService', () => { } as unknown as IProgressService; instantiationService.stub(ICommandService, { - executeCommand: async (id: string) => { - if (id === '_git.cloneRepository') { - cloneCount++; - // Simulate async clone by yielding, then mark repo as existing - await new Promise(r => setTimeout(r, 0)); - repoExists = true; - } - return undefined; - }, + executeCommand: async () => undefined, } as unknown as ICommandService); + instantiationService.stub(IPluginGitService, stubPluginGit({ + cloneRepository: async () => { + cloneCount++; + // Simulate async clone by yielding, then mark repo as existing + await new Promise(r => setTimeout(r, 0)); + repoExists = true; + }, + })); instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); @@ -176,6 +195,7 @@ suite('AgentPluginRepositoryService', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); + instantiationService.stub(IPluginGitService, stubPluginGit()); instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); instantiationService.stub(IFileService, { exists: async () => true } as unknown as IFileService); instantiationService.stub(ILogService, new NullLogService()); @@ -231,9 +251,12 @@ suite('AgentPluginRepositoryService', () => { }); test('updates git plugin source by pulling and checking out requested revision', async () => { - const commands: string[] = []; - const service = createService(async () => true, (id: string) => { - commands.push(id); + const calls: string[] = []; + const service = createService(async () => true, undefined, { + revParse: async () => { calls.push('revParse'); return ''; }, + fetch: async () => { calls.push('fetch'); }, + checkout: async () => { calls.push('checkout'); }, + pull: async () => { calls.push('pull'); return false; }, }); await service.updatePluginSource({ @@ -255,7 +278,7 @@ suite('AgentPluginRepositoryService', () => { marketplaceType: MarketplaceType.Copilot, }); - assert.deepStrictEqual(commands, ['git.openRepository', '_git.revParse', 'git.fetch', '_git.checkout', '_git.revParse']); + assert.deepStrictEqual(calls, ['revParse', 'fetch', 'checkout', 'revParse']); }); // ========================================================================= @@ -270,6 +293,7 @@ suite('AgentPluginRepositoryService', () => { ) { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); + instantiationService.stub(IPluginGitService, stubPluginGit()); instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); instantiationService.stub(IFileService, { exists: async () => true, @@ -363,6 +387,7 @@ suite('AgentPluginRepositoryService', () => { test('does not throw when delete fails', async () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); + instantiationService.stub(IPluginGitService, stubPluginGit()); instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); instantiationService.stub(IFileService, { exists: async () => true, diff --git a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts index 55ed1e84adf7e..64db19dc63fea 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts @@ -277,44 +277,6 @@ suite('ChatDebugServiceImpl', () => { }); }); - suite('markDebugDataAttached', () => { - test('should track attached debug data per session', () => { - assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), false); - - const fired: URI[] = []; - disposables.add(service.onDidAttachDebugData(uri => fired.push(uri))); - - service.markDebugDataAttached(sessionGeneric); - assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), true); - assert.strictEqual(fired.length, 1); - assert.strictEqual(fired[0].toString(), sessionGeneric.toString()); - - // Idempotent — second call should not fire again - service.markDebugDataAttached(sessionGeneric); - assert.strictEqual(fired.length, 1); - - // Other sessions remain unaffected - assert.strictEqual(service.hasAttachedDebugData(sessionA), false); - }); - - test('should clear attached debug data on endSession', () => { - service.markDebugDataAttached(sessionGeneric); - assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), true); - - service.endSession(sessionGeneric); - assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), false); - }); - - test('should clear attached debug data on clear', () => { - service.markDebugDataAttached(sessionA); - service.markDebugDataAttached(sessionB); - - service.clear(); - assert.strictEqual(service.hasAttachedDebugData(sessionA), false); - assert.strictEqual(service.hasAttachedDebugData(sessionB), false); - }); - }); - suite('registerProvider', () => { test('should register and unregister a provider', async () => { const extSession = URI.parse('vscode-chat-session://local/ext-session'); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index 062411654238c..9dff3229b4b76 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -20,6 +20,7 @@ export class MockChatService implements IChatService { _serviceBrand: undefined; editingSessions = []; transferredSessionResource = undefined; + whenSessionsRevived = Promise.resolve(); readonly onDidSubmitRequest = Event.None; private readonly _onDidCreateModel = new Emitter(); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 7c6aa3fd2ea58..44390778ccb14 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -46,8 +46,9 @@ import { IRemoteAgentService } from '../../../../../../workbench/services/remote import { basename } from '../../../../../../base/common/resources.js'; import { match } from '../../../../../../base/common/glob.js'; import { ChatModeKind, GeneralPurposeAgentName } from '../../../common/constants.js'; -import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService, ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { observableValue } from '../../../../../../base/common/observable.js'; @@ -1351,6 +1352,89 @@ suite('ComputeAutomaticInstructions', () => { }); }); + suite('skill session-type filtering', () => { + test('non-local session includes skills without when', async () => { + const rootFolderName = 'skill-session-filter-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + const stubSkills: IAgentSkill[] = [ + { + uri: URI.file(`${rootFolder}/.claude/skills/no-when-skill/SKILL.md`), + storage: PromptsStorage.local, + name: 'no-when-skill', + description: 'A skill without when clause', + disableModelInvocation: false, + userInvocable: true, + }, + ]; + sinon.stub(service, 'findAgentSkills').resolves(stubSkills); + + // Set chatSessionType to a non-local value + const mockContextKeyService = new MockContextKeyService(); + mockContextKeyService.createKey(ChatContextKeys.chatSessionType.key, 'remote-session'); + instaService.stub(IContextKeyService, mockContextKeyService); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatModeKind.Agent, + { 'vscode_readFile': true }, + undefined, + ); + const variables = new ChatRequestVariableSet(); + await contextComputer.collect(variables, CancellationToken.None); + + const allEntries = variables.asArray(); + const skillEntries = allEntries.filter(e => isPromptTextVariableEntry(e) && e.value.includes('')); + assert.strictEqual(skillEntries.length, 1, 'Skills without when should be included in non-local sessions'); + }); + + test('skills with matching when are included in non-local sessions', async () => { + const rootFolderName = 'skill-when-match-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + const whenExpr = ContextKeyExpr.equals('chatSessionType', 'remote-session'); + const stubSkills: IAgentSkill[] = [ + { + uri: URI.file(`${rootFolder}/.claude/skills/when-skill/SKILL.md`), + storage: PromptsStorage.local, + name: 'when-skill', + description: 'A skill with matching when clause', + disableModelInvocation: false, + userInvocable: true, + when: whenExpr!, + }, + ]; + sinon.stub(service, 'findAgentSkills').resolves(stubSkills); + + // Set chatSessionType to a non-local value and make contextMatchesRules return true for the when expression + const mockContextKeyService = new MockContextKeyService(); + mockContextKeyService.createKey(ChatContextKeys.chatSessionType.key, 'remote-session'); + sinon.stub(mockContextKeyService, 'contextMatchesRules').returns(true); + instaService.stub(IContextKeyService, mockContextKeyService); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatModeKind.Agent, + { 'vscode_readFile': true }, + undefined, + ); + const variables = new ChatRequestVariableSet(); + await contextComputer.collect(variables, CancellationToken.None); + + const allEntries = variables.asArray(); + const skillEntries = allEntries.filter(e => isPromptTextVariableEntry(e) && e.value.includes('')); + assert.strictEqual(skillEntries.length, 1, 'Skills with matching when should be included in non-local sessions'); + }); + }); + suite('instructions list variable', () => { function xmlContents(text: string, tag: string): string[] { const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index 1c596a10b4f94..3248d54405b85 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -72,4 +72,5 @@ export class MockPromptsService implements IPromptsService { onDidChangeInstructions: Event = Event.None; onDidChangePromptFiles: Event = Event.None; onDidChangeSkills: Event = Event.None; + onDidChangeHooks: Event = Event.None; } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index ba7c466db5889..44dda985f4900 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../../../../../base/common/event.js'; +import { Event } from '../../../../../../../base/common/event.js'; import { match } from '../../../../../../../base/common/glob.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../../base/common/network.js'; @@ -51,7 +51,7 @@ import { IExtensionService } from '../../../../../../services/extensions/common/ import { IRemoteAgentService } from '../../../../../../services/remote/common/remoteAgentService.js'; import { ChatModeKind } from '../../../../common/constants.js'; import { HookType } from '../../../../common/promptSyntax/hookTypes.js'; -import { IContextKeyService, IContextKeyChangeEvent } from '../../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from '../../../../common/plugins/agentPluginService.js'; import { IWorkspaceTrustManagementService } from '../../../../../../../platform/workspace/common/workspaceTrust.js'; @@ -2045,49 +2045,22 @@ suite('PromptsService', () => { registered2.dispose(); }); - test('Contributed file with when clause is filtered by context key', async () => { + test('Contributed file with when clause is included at discovery and propagated for later evaluation', async () => { const uri = URI.parse('file://extensions/my-extension/conditional.instructions.md'); const extension = {} as IExtensionDescription; - // Create a mock context key service that we can control - let matchResult = false; - const contextKeyChangeEmitter = disposables.add(new Emitter()); - const testContextKeyService = new class extends MockContextKeyService { - override contextMatchesRules(): boolean { - return matchResult; - } - override get onDidChangeContext() { - return contextKeyChangeEmitter.event; - } - }(); - instaService.stub(IContextKeyService, testContextKeyService); - service.dispose(); - const testService = disposables.add(instaService.createInstance(PromptsService)); - - const registered = testService.registerContributedFile( + const registered = service.registerContributedFile( PromptsType.instructions, uri, extension, 'Conditional Instructions', 'Only when enabled', 'myFeature.enabled', ); - // When clause is false - should be filtered out - const before = await testService.listPromptFiles(PromptsType.instructions, CancellationToken.None); - assert.strictEqual(before.length, 0, 'Should be filtered out when context key is false'); - - // Change context to make when clause true - matchResult = true; - contextKeyChangeEmitter.fire({ - affectsSome: (keys) => keys.has('myFeature.enabled'), - allKeysContainedIn: () => false, - }); - - const after = await testService.listPromptFiles(PromptsType.instructions, CancellationToken.None); - assert.strictEqual(after.length, 1, 'Should be included when context key is true'); - assert.strictEqual(after[0].uri.toString(), uri.toString()); + // `when` is no longer evaluated at discovery time; the file should always be + // included so consumers can evaluate it with session-scoped context later. + const files = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + assert.strictEqual(files.length, 1, 'Should be included regardless of context key match'); + assert.strictEqual(files[0].uri.toString(), uri.toString()); registered.dispose(); - - // Restore original stub - instaService.stub(IContextKeyService, new MockContextKeyService()); }); }); diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/pluginGitCommandService.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/pluginGitCommandService.test.ts new file mode 100644 index 0000000000000..efe70c1559c20 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/electron-browser/pluginGitCommandService.test.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ILocalGitService } from '../../../../../platform/git/common/localGitService.js'; +import { NativePluginGitCommandService } from '../../electron-browser/pluginGitCommandService.js'; + +suite('NativePluginGitCommandService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createLocalGitStub(overrides?: Partial): ILocalGitService { + return { + _serviceBrand: undefined, + clone: async () => { }, + pull: async () => false, + checkout: async () => { }, + revParse: async () => '', + fetch: async () => { }, + revListCount: async () => 0, + cancel: async () => { }, + ...overrides, + } as ILocalGitService; + } + + test('cloneRepository delegates to ILocalGitService', async () => { + const calls: string[] = []; + const service = new NativePluginGitCommandService(createLocalGitStub({ + clone: async (_operationId, url, path, ref) => { calls.push(`clone:${url}:${path}:${ref}`); }, + })); + + const targetDir = URI.file('/tmp/repo'); + await service.cloneRepository('https://github.com/test/repo.git', targetDir, 'main'); + assert.deepStrictEqual(calls, [`clone:https://github.com/test/repo.git:${targetDir.fsPath}:main`]); + }); + + test('pull delegates to ILocalGitService and returns result', async () => { + const service = new NativePluginGitCommandService(createLocalGitStub({ + pull: async () => true, + })); + + const result = await service.pull(URI.file('/tmp/repo')); + assert.strictEqual(result, true); + }); + + test('checkout delegates to ILocalGitService with detached flag', async () => { + const calls: string[] = []; + const service = new NativePluginGitCommandService(createLocalGitStub({ + checkout: async (_operationId, _path, treeish, detached) => { calls.push(`checkout:${treeish}:${detached}`); }, + })); + + await service.checkout(URI.file('/tmp/repo'), 'abc123', true); + assert.deepStrictEqual(calls, ['checkout:abc123:true']); + }); + + test('revParse delegates to ILocalGitService', async () => { + const service = new NativePluginGitCommandService(createLocalGitStub({ + revParse: async () => 'abc123', + })); + + const result = await service.revParse(URI.file('/tmp/repo'), 'HEAD'); + assert.strictEqual(result, 'abc123'); + }); + + test('fetch delegates to ILocalGitService', async () => { + const calls: string[] = []; + const service = new NativePluginGitCommandService(createLocalGitStub({ + fetch: async (_operationId, path) => { calls.push(`fetch:${path}`); }, + })); + + const repoDir = URI.file('/tmp/repo'); + await service.fetch(repoDir); + assert.deepStrictEqual(calls, [`fetch:${repoDir.fsPath}`]); + }); + + test('fetchRepository delegates to ILocalGitService.fetch', async () => { + const calls: string[] = []; + const service = new NativePluginGitCommandService(createLocalGitStub({ + fetch: async (_operationId, path) => { calls.push(`fetch:${path}`); }, + })); + + const repoDir = URI.file('/tmp/repo'); + await service.fetchRepository(repoDir); + assert.deepStrictEqual(calls, [`fetch:${repoDir.fsPath}`]); + }); + + test('revListCount delegates to ILocalGitService', async () => { + const service = new NativePluginGitCommandService(createLocalGitStub({ + revListCount: async () => 5, + })); + + const result = await service.revListCount(URI.file('/tmp/repo'), 'HEAD', '@{u}'); + assert.strictEqual(result, 5); + }); + + test('cancellation token triggers cancel on local git service', async () => { + const cts = store.add(new CancellationTokenSource()); + const cancelledIds: string[] = []; + let cloneResolve: (() => void) | undefined; + const service = new NativePluginGitCommandService(createLocalGitStub({ + clone: () => new Promise(resolve => { cloneResolve = resolve; }), + cancel: async (id) => { cancelledIds.push(id); }, + })); + + const p = service.cloneRepository('https://github.com/test/repo.git', URI.file('/tmp/repo'), undefined, cts.token); + cts.cancel(); + assert.strictEqual(cancelledIds.length, 1); + cloneResolve!(); + await p; + }); +}); diff --git a/src/vs/workbench/contrib/git/common/gitService.ts b/src/vs/workbench/contrib/git/common/gitService.ts index cbaa386370de0..5ebc3569de706 100644 --- a/src/vs/workbench/contrib/git/common/gitService.ts +++ b/src/vs/workbench/contrib/git/common/gitService.ts @@ -49,7 +49,6 @@ export interface GitRepositoryState { } export interface GitBranch extends GitRef { - readonly base?: GitBaseRef; readonly upstream?: GitUpstreamRef; readonly ahead?: number; readonly behind?: number; diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts index 6afde04ade579..f2b32036abb1b 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts @@ -2118,12 +2118,12 @@ class SettingBoolRenderer extends AbstractSettingRenderer implements ITreeRender template.descriptionElement.classList.remove('disabled'); // Need to listen for mouse clicks on description and toggle checkbox - use target ID for safety - // Also have to ignore embedded links - too buried to stop propagation + // Also have to ignore embedded links - use closest('a') to handle clicks on child elements of links (e.g. SVG icons inside tags) template.elementDisposables.add(DOM.addDisposableListener(template.descriptionElement, DOM.EventType.MOUSE_DOWN, (e) => { - const targetElement = e.target; + const targetElement: Element | null = e.target instanceof Element ? e.target : null; // Toggle target checkbox - if (targetElement.tagName.toLowerCase() !== 'a') { + if (!targetElement || !targetElement.closest('a')) { template.checkbox.checked = !template.checkbox.checked; template.onChange!(template.checkbox.checked); } diff --git a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts index a4cb032efa757..7d4c7ee69c59d 100644 --- a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts @@ -939,7 +939,7 @@ registerAction2(class FilterCoverageToTestInEditor extends Action2 { const result = coverage.fromResult; const previousSelection = testCoverageService.filterToTest.get(); - type TItem = { label: string; testId: TestId | undefined; buttons?: IQuickInputButton[] }; + type TItem = { label: string; description?: string; testId: TestId | undefined; buttons?: IQuickInputButton[] }; const buttons: IQuickInputButton[] = [{ iconClass: 'codicon-go-to-file', @@ -948,7 +948,7 @@ registerAction2(class FilterCoverageToTestInEditor extends Action2 { const items: QuickPickInput[] = [ { label: coverUtils.labels.allTests, testId: undefined }, { type: 'separator' }, - ...tests.map(id => ({ label: coverUtils.getLabelForItem(result, id, commonPrefix), testId: id, buttons })), + ...tests.map(id => ({ ...coverUtils.getLabelForItem(result, id, commonPrefix), testId: id, buttons })), ]; // These handle the behavior that reveals the start of coverage when the diff --git a/src/vs/workbench/contrib/testing/browser/codeCoverageDisplayUtils.ts b/src/vs/workbench/contrib/testing/browser/codeCoverageDisplayUtils.ts index 4750ce64a80a2..c04e3789a62f0 100644 --- a/src/vs/workbench/contrib/testing/browser/codeCoverageDisplayUtils.ts +++ b/src/vs/workbench/contrib/testing/browser/codeCoverageDisplayUtils.ts @@ -67,7 +67,7 @@ export const calculateDisplayedStat = (coverage: CoverageBarSource, method: Test } }; -export function getLabelForItem(result: LiveTestResult, testId: TestId, commonPrefixLen: number) { +export function getLabelForItem(result: LiveTestResult, testId: TestId, commonPrefixLen: number): { label: string; description?: string } { const parts: string[] = []; for (const id of testId.idsFromRoot()) { const item = result.getTestById(id.toString()); @@ -78,13 +78,21 @@ export function getLabelForItem(result: LiveTestResult, testId: TestId, commonPr parts.push(item.label); } - return parts.slice(commonPrefixLen).join(' \u203a '); + const relevant = parts.slice(commonPrefixLen); + if (relevant.length <= 1) { + return { label: relevant[0] ?? '' }; + } + + return { + label: relevant[relevant.length - 1], + description: relevant.slice(0, -1).join(' \u203A '), + }; } export namespace labels { export const showingFilterFor = (label: string) => localize('testing.coverageForTest', "Showing \"{0}\"", label); export const clickToChangeFiltering = localize('changePerTestFilter', 'Click to view coverage for a single test'); export const percentCoverage = (percent: number, precision?: number) => localize('testing.percentCoverage', '{0} Coverage', displayPercent(percent, precision)); - export const allTests = localize('testing.allTests', 'All tests'); + export const allTests = localize('testing.allTests', 'Entire run'); export const pickShowCoverage = localize('testing.pickTest', 'Pick a test to show coverage for'); } diff --git a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts index 3dbd89389eb98..02fdfa31a5e6b 100644 --- a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts +++ b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts @@ -741,12 +741,12 @@ registerAction2(class TestCoverageChangePerTestFilterAction extends Action2 { const previousSelection = coverageService.filterToTest.get(); const previousSelectionStr = previousSelection?.toString(); - type TItem = { label: string; testId?: TestId }; + type TItem = { label: string; description?: string; testId?: TestId }; const items: QuickPickInput[] = [ { label: coverUtils.labels.allTests, id: undefined }, { type: 'separator' }, - ...tests.map(testId => ({ label: coverUtils.getLabelForItem(result, testId, commonPrefix), testId })), + ...tests.map(testId => ({ ...coverUtils.getLabelForItem(result, testId, commonPrefix), testId })), ]; quickInputService.pick(items, { diff --git a/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts b/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts index b1b5b41edd8f7..f20c3d019059f 100644 --- a/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts +++ b/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts @@ -146,12 +146,15 @@ export class TestExplorerFilterState extends Disposable implements ITestExplorer let nextIndex = match.index + match[0].length; const tag = match[0]; - if (allTestFilterTerms.includes(tag as TestFilterTerm)) { + const isFilterTerm = allTestFilterTerms.includes(tag as TestFilterTerm); + if (isFilterTerm) { this.termFilterState[tag as TestFilterTerm] = true; } // recognize and parse @ctrlId:tagId or quoted like @ctrlId:"tag \\"id" + let isTag = false; if (text[nextIndex] === ':') { + isTag = true; nextIndex++; let delimiter = text[nextIndex]; @@ -180,6 +183,12 @@ export class TestExplorerFilterState extends Disposable implements ITestExplorer nextIndex++; } + // If the @-prefixed text is not a known filter term or tag, + // treat it as regular filter text (e.g., a test named "@smoke") + if (!isFilterTerm && !isTag) { + continue; + } + globText += text.slice(lastIndex, match.index); lastIndex = nextIndex; } diff --git a/src/vs/workbench/contrib/testing/test/common/testExplorerFilterState.test.ts b/src/vs/workbench/contrib/testing/test/common/testExplorerFilterState.test.ts index a9693426a6040..06b91edb19ac8 100644 --- a/src/vs/workbench/contrib/testing/test/common/testExplorerFilterState.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testExplorerFilterState.test.ts @@ -88,4 +88,31 @@ suite('TestExplorerFilterState', () => { assert.deepStrictEqual([...t.includeTags], ['hello\0world"1']); assert.deepStrictEqual(t.excludeTags, new Set()); }); + + test('treats unrecognized @-prefixed text as regular filter text', () => { + t.setText('@smoke'); + assert.deepStrictEqual(t.globList, [{ text: '@smoke', include: true }]); + assert.deepStrictEqual(t.includeTags, new Set()); + assert.deepStrictEqual(t.excludeTags, new Set()); + assertFilteringFor(termFiltersOff); + }); + + test('treats unrecognized @-prefixed text as filter text in mixed input', () => { + t.setText('@smoke @doc hello'); + assert.deepStrictEqual(t.globList, [{ text: '@smoke hello', include: true }]); + assert.deepStrictEqual(t.includeTags, new Set()); + assert.deepStrictEqual(t.excludeTags, new Set()); + assertFilteringFor({ + ...termFiltersOff, + [TestFilterTerm.CurrentDoc]: true, + }); + }); + + test('negated unrecognized @-prefixed text works as exclusion filter', () => { + t.setText('!@smoke'); + assert.deepStrictEqual(t.globList, [{ text: '@smoke', include: false }]); + assert.deepStrictEqual(t.includeTags, new Set()); + assert.deepStrictEqual(t.excludeTags, new Set()); + assertFilteringFor(termFiltersOff); + }); }); diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index 898b0cfbe27ef..38c99529cebdb 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -78,6 +78,28 @@ declare module 'vscode' { // #endregion + // #region HookProvider + + /** + * A provider that supplies hook configuration resources (from hooks JSON files). + */ + export interface ChatHookProvider { + /** + * An optional event to signal that hooks have changed. + */ + readonly onDidChangeHooks?: Event; + + /** + * Provide the list of hook configuration files available. + * @param context Context for the provide call. + * @param token A cancellation token. + * @returns An array of hook resources or a promise that resolves to such. + */ + provideHooks(context: unknown, token: CancellationToken): ProviderResult; + } + + // #endregion + // #region SkillProvider /** @@ -136,6 +158,28 @@ declare module 'vscode' { */ export const skills: readonly ChatResource[]; + /** + * An event that fires when the list of {@link hooks hooks} changes. + */ + export const onDidChangeHooks: Event; + + /** + * The list of currently available hook configuration files. + * These are JSON files that define lifecycle hooks from all sources + * (workspace, user, and extension-provided). + */ + export const hooks: readonly ChatResource[]; + + /** + * An event that fires when the list of {@link plugins plugins} changes. + */ + export const onDidChangePlugins: Event; + + /** + * The list of currently installed agent plugins. + */ + export const plugins: readonly ChatResource[]; + /** * Register a provider for custom agents. * @param provider The custom agent provider. @@ -163,6 +207,13 @@ declare module 'vscode' { * @returns A disposable that unregisters the provider when disposed. */ export function registerSkillProvider(provider: ChatSkillProvider): Disposable; + + /** + * Register a provider for hooks. + * @param provider The hook provider. + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerHookProvider(provider: ChatHookProvider): Disposable; } // #endregion