From c15e0861febb132f8c308664eba0799f3b8fcede Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Wed, 22 Apr 2026 16:59:14 -0700 Subject: [PATCH 1/3] feat(claude): implement workspace folder service for tracking file changes in chat sessions For now, we implement a Claude specific workspace folder service... although there is nothing Claude specific about it. This gets changes to show up in Claude sessions in the Agents App. --- .../common/claudeWorkspaceFolderService.ts | 26 +++ .../chatSessions/vscode-node/chatSessions.ts | 3 + .../claudeChatSessionContentProvider.ts | 43 ++-- .../claudeWorkspaceFolderServiceImpl.ts | 203 ++++++++++++++++ .../claudeChatSessionContentProvider.spec.ts | 107 +++++++++ .../test/claudeWorkspaceFolderService.spec.ts | 219 ++++++++++++++++++ 6 files changed, 584 insertions(+), 17 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/common/claudeWorkspaceFolderService.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeWorkspaceFolderService.spec.ts diff --git a/extensions/copilot/src/extension/chatSessions/common/claudeWorkspaceFolderService.ts b/extensions/copilot/src/extension/chatSessions/common/claudeWorkspaceFolderService.ts new file mode 100644 index 0000000000000..a5ae09dfaac59 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/common/claudeWorkspaceFolderService.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { createServiceIdentifier } from '../../../util/common/services'; + +export const IClaudeWorkspaceFolderService = createServiceIdentifier('IClaudeWorkspaceFolderService'); + +/** + * Service for computing and caching workspace file changes for Claude chat sessions. + */ +export interface IClaudeWorkspaceFolderService { + readonly _serviceBrand: undefined; + /** + * Computes file changes for a workspace directory by diffing the current branch against a base branch. + * Results are cached per unique (cwd, gitBranch, gitBaseBranch) combination. + * + * @param cwd The working directory of the session. + * @param gitBranch The current git branch name, or `undefined` if unknown. + * @param gitBaseBranch The base branch to diff against, or `undefined` to diff against HEAD. + * @param forceRefresh When `true`, bypasses the cache and recomputes changes. + */ + getWorkspaceChanges(cwd: string, gitBranch: string | undefined, gitBaseBranch: string | undefined, forceRefresh?: boolean): Promise; +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index da43cfc3bdb2e..40b5162b95280 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -38,6 +38,7 @@ import { ClaudeSlashCommandService, IClaudeSlashCommandService } from '../claude import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace'; import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; +import { IClaudeWorkspaceFolderService } from '../common/claudeWorkspaceFolderService'; import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService'; import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; import { IChatFolderMruService, IFolderRepositoryManager } from '../common/folderRepositoryManager'; @@ -61,6 +62,7 @@ import { UserQuestionHandler } from './askUserQuestionHandler'; import { ChatSessionMetadataStore } from './chatSessionMetadataStoreImpl'; import { ChatSessionRepositoryTracker } from './chatSessionRepositoryTracker'; import { ChatSessionWorkspaceFolderService } from './chatSessionWorkspaceFolderServiceImpl'; +import { ClaudeWorkspaceFolderService } from './claudeWorkspaceFolderServiceImpl'; import { ChatSessionWorktreeCheckpointService } from './chatSessionWorktreeCheckpointServiceImpl'; import { ChatSessionWorktreeService } from './chatSessionWorktreeServiceImpl'; import { ClaudeChatSessionContentProvider } from './claudeChatSessionContentProvider'; @@ -142,6 +144,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib [IChatSessionWorktreeService, new SyncDescriptor(ChatSessionWorktreeService)], [IChatSessionWorktreeCheckpointService, new SyncDescriptor(ChatSessionWorktreeCheckpointService)], [IChatSessionWorkspaceFolderService, new SyncDescriptor(ChatSessionWorkspaceFolderService)], + [IClaudeWorkspaceFolderService, new SyncDescriptor(ClaudeWorkspaceFolderService)], [IFolderRepositoryManager, new SyncDescriptor(ClaudeFolderRepositoryManager)], [IChatFolderMruService, new SyncDescriptor(ClaudeCodeFolderMruService)], [IClaudeRuntimeDataService, new SyncDescriptor(ClaudeRuntimeDataService)], diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index abfb16436e390..15288851c0aaf 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -18,6 +18,7 @@ import { autorun, derived, IObservable, ISettableObservable, observableFromEvent import { basename } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { generateUuid } from '../../../util/vs/base/common/uuid'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; import { ClaudeAgentManager } from '../claude/node/claudeCodeAgent'; @@ -29,6 +30,7 @@ import { IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCo import { IClaudeCodeSessionInfo } from '../claude/node/sessionParser/claudeSessionSchema'; import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService'; import { IChatFolderMruService } from '../common/folderRepositoryManager'; +import { IClaudeWorkspaceFolderService } from '../common/claudeWorkspaceFolderService'; import { buildChatHistory } from './chatHistoryBuilder'; import { ClaudeSessionOptionBuilder, buildPermissionModeItems, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder'; import { toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; @@ -60,21 +62,11 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco @IClaudeCodeSessionService private readonly sessionService: IClaudeCodeSessionService, @IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService, @IClaudeSlashCommandService private readonly slashCommandService: IClaudeSlashCommandService, - @IConfigurationService configurationService: IConfigurationService, @IClaudeCodeModels private readonly claudeModels: IClaudeCodeModels, - @IChatFolderMruService folderMruService: IChatFolderMruService, - @IWorkspaceService workspaceService: IWorkspaceService, - @INativeEnvService envService: INativeEnvService, - @IGitService gitService: IGitService, - @IClaudeCodeSdkService sdkService: IClaudeCodeSdkService, - @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService ) { super(); - this._controller = this._register(new ClaudeChatSessionItemController( - sessionService, sessionStateService, configurationService, - folderMruService, workspaceService, envService, - gitService, sdkService, logService, - )); + this._controller = this._register(instantiationService.createInstance(ClaudeChatSessionItemController)); } // #region Chat Participant Handler @@ -217,6 +209,7 @@ export class ClaudeChatSessionItemController extends Disposable { @IGitService private readonly _gitService: IGitService, @IClaudeCodeSdkService private readonly _sdkService: IClaudeCodeSdkService, @ILogService private readonly _logService: ILogService, + @IClaudeWorkspaceFolderService private readonly _claudeWorkspaceFolderService: IClaudeWorkspaceFolderService, ) { super(); this._optionBuilder = new ClaudeSessionOptionBuilder(_configurationService, folderMruService, _workspaceService); @@ -642,7 +635,7 @@ export class ClaudeChatSessionItemController extends Disposable { if (!item) { const session = await this._claudeCodeSessionService.getSession(resource, CancellationToken.None); if (session) { - item = this._createClaudeChatSessionItem(session); + item = await this._createClaudeChatSessionItem(session); } else { const newlyCreatedSessionInfo: IClaudeCodeSessionInfo = { id: sessionId, @@ -651,7 +644,7 @@ export class ClaudeChatSessionItemController extends Disposable { lastRequestEnded: Date.now(), folderName: undefined }; - item = this._createClaudeChatSessionItem(newlyCreatedSessionInfo); + item = await this._createClaudeChatSessionItem(newlyCreatedSessionInfo); } this._controller.items.add(item); @@ -676,18 +669,30 @@ export class ClaudeChatSessionItemController extends Disposable { } else { item.timing = { ...item.timing, lastRequestEnded: Date.now() }; } + const session = await this._claudeCodeSessionService.getSession(resource, CancellationToken.None); + if (session?.cwd) { + item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges( + session.cwd, + session.gitBranch, + undefined, + true, + ); + } } } } private async _refreshItems(token: vscode.CancellationToken): Promise { const sessions = await this._claudeCodeSessionService.getAllSessions(token); - const items = sessions.map(session => this._createClaudeChatSessionItem(session)); + const results = await Promise.allSettled(sessions.map(session => this._createClaudeChatSessionItem(session))); + const items = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map(r => r.value); items.push(...this._inProgressItems.values()); this._controller.items.replace(items); } - private _createClaudeChatSessionItem(session: IClaudeCodeSessionInfo): vscode.ChatSessionItem { + private async _createClaudeChatSessionItem(session: IClaudeCodeSessionInfo): Promise { let badge: vscode.MarkdownString | undefined; if (session.folderName && this._showBadge) { badge = new vscode.MarkdownString(`$(folder) ${session.folderName}`); @@ -704,8 +709,12 @@ export class ClaudeChatSessionItemController extends Disposable { }; item.iconPath = new vscode.ThemeIcon('claude'); if (session.cwd) { - // Agents app needs this to decide the working directory for the session item.metadata = { workingDirectoryPath: session.cwd }; + item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges( + session.cwd, + session.gitBranch, + undefined, + ); } return item; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts new file mode 100644 index 0000000000000..338ee8e235063 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts @@ -0,0 +1,203 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { IGitService } from '../../../platform/git/common/gitService'; +import { toGitUri } from '../../../platform/git/common/utils'; +import { buildTempIndexEnv, getUncommittedFilePaths, parseGitChangesRaw } from '../../../platform/git/vscode-node/utils'; +import { DiffChange } from '../../../platform/git/vscode/git'; +import { ILogService } from '../../../platform/log/common/logService'; +import * as path from '../../../util/vs/base/common/path'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { generateUuid } from '../../../util/vs/base/common/uuid'; +import { ChatSessionWorktreeFile } from '../common/chatSessionWorktreeService'; +import { IClaudeWorkspaceFolderService } from '../common/claudeWorkspaceFolderService'; + +// #region Constants + +const EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + +// #endregion + +export class ClaudeWorkspaceFolderService extends Disposable implements IClaudeWorkspaceFolderService { + declare _serviceBrand: undefined; + + private readonly _cache = new Map(); + + constructor( + @IGitService private readonly _gitService: IGitService, + @ILogService private readonly _logService: ILogService, + @IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext, + @IFileSystemService private readonly _fileSystemService: IFileSystemService, + ) { + super(); + } + + override dispose(): void { + this._cache.clear(); + super.dispose(); + } + + async getWorkspaceChanges( + cwd: string, + gitBranch: string | undefined, + gitBaseBranch: string | undefined, + forceRefresh?: boolean, + ): Promise { + const cacheKey = `${cwd}\0${gitBranch ?? ''}\0${gitBaseBranch ?? ''}`; + + if (!forceRefresh) { + const cached = this._cache.get(cacheKey); + if (cached) { + return cached; + } + } + + const result = await this.computeRepositoryChanges(cwd, gitBranch, gitBaseBranch); + if (!result) { + return []; + } + + const originalRef = result.mergeBaseCommit ?? 'HEAD'; + const changes = result.changes.map(change => new vscode.ChatSessionChangedFile( + vscode.Uri.file(change.filePath), + change.originalFilePath + ? toGitUri(vscode.Uri.file(change.originalFilePath), originalRef) + : undefined, + change.modifiedFilePath + ? vscode.Uri.file(change.modifiedFilePath) + : undefined, + change.statistics.additions, + change.statistics.deletions, + )); + + this._cache.set(cacheKey, changes); + return changes; + } + + private async computeRepositoryChanges( + repositoryPath: string, + branchName: string | undefined, + baseBranchName: string | undefined, + ): Promise<{ + readonly changes: ChatSessionWorktreeFile[]; + readonly mergeBaseCommit?: string; + } | undefined> { + const repository = await this._gitService.getRepository(vscode.Uri.file(repositoryPath)); + if (!repository?.changes) { + this._logService.warn(`[ClaudeWorkspaceFolderService] No repository found at ${repositoryPath}`); + return undefined; + } + + let resolvedBaseBranchName = baseBranchName; + if (!resolvedBaseBranchName && branchName && repository.headCommitHash) { + try { + const branchBase = await this._gitService.getBranchBase(repository.rootUri, branchName); + resolvedBaseBranchName = branchBase?.name; + } catch (error) { + this._logService.warn(`[ClaudeWorkspaceFolderService] Failed to resolve base branch for ${branchName}: ${error}`); + } + } + + // Check for untracked changes, only if the session branch matches the current branch + const hasUntrackedChanges = branchName === repository.headBranchName + ? [ + ...repository.changes?.workingTree ?? [], + ...repository.changes?.untrackedChanges ?? [], + ].some(change => change.status === 7 /* UNTRACKED */) + : false; + + const diffChanges: DiffChange[] = []; + + // If the repository is using a virtual file system, we need to + // disable rename detection to avoid expensive git operations + const noRenamesArg = repository.isUsingVirtualFileSystem + ? ['--no-renames'] + : []; + + const mergeBaseArg = resolvedBaseBranchName + ? ['--merge-base', resolvedBaseBranchName] + : []; + + if (hasUntrackedChanges) { + // Tracked + untracked changes + const tmpDirName = `vscode-sessions-${generateUuid()}`; + const diffIndexFile = path.join(this._extensionContext.globalStorageUri.fsPath, tmpDirName, 'diff.index'); + const pathspecFile = path.join(this._extensionContext.globalStorageUri.fsPath, tmpDirName, `pathspec.txt`); + + const env = buildTempIndexEnv(repository, diffIndexFile); + + try { + // Create temp index file directory + await this._fileSystemService.createDirectory(vscode.Uri.file(path.dirname(diffIndexFile))); + + try { + // Populate temp index from HEAD, fall back to empty tree if no commits exist + await this._gitService.exec(repository.rootUri, ['read-tree', 'HEAD'], env); + } catch { + // Fall back to empty tree for repositories with no commits + await this._gitService.exec(repository.rootUri, ['read-tree', EMPTY_TREE_OBJECT], env); + } + + // Stage entire working directory into temp index + const uncommittedFilePaths = getUncommittedFilePaths(repository); + await this._fileSystemService.writeFile(vscode.Uri.file(pathspecFile), new TextEncoder().encode(uncommittedFilePaths.join('\n'))); + await this._gitService.exec(repository.rootUri, ['add', '-A', `--pathspec-from-file=${pathspecFile}`], env); + + // Diff the temp index with the base branch + const result = await this._gitService.exec(repository.rootUri, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', ...mergeBaseArg, '--'], env); + diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result)); + } catch (error) { + this._logService.error(`[ClaudeWorkspaceFolderService] Error while processing workspace changes: ${error}`); + return undefined; + } finally { + try { + await this._fileSystemService.delete(vscode.Uri.file(path.dirname(diffIndexFile)), { recursive: true }); + } catch (error) { + this._logService.error(`[ClaudeWorkspaceFolderService] Error while cleaning up temp index file: ${error}`); + } + } + } else { + // Tracked changes + try { + const result = await this._gitService.exec(repository.rootUri, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', ...mergeBaseArg, '--']); + diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result)); + } catch (error) { + this._logService.error(`[ClaudeWorkspaceFolderService] Error while processing workspace changes: ${error}`); + return undefined; + } + } + + // Since the diff may be computed using the merge base commit of the current + // branch and the base branch, we need to compute it as well so that we can use + // it as the originalRef (left-hand side) of the diff editor + let mergeBaseCommit: string | undefined; + try { + if (branchName && resolvedBaseBranchName) { + mergeBaseCommit = await this._gitService.getMergeBase(repository.rootUri, branchName, resolvedBaseBranchName); + } + } catch (error) { + this._logService.error(`[ClaudeWorkspaceFolderService] Error while getting merge base (${branchName}, ${resolvedBaseBranchName}): ${error}`); + } + + const changes = diffChanges.map(change => ({ + filePath: change.uri.fsPath, + originalFilePath: change.status !== 1 /* INDEX_ADDED */ + ? change.originalUri?.fsPath + : undefined, + modifiedFilePath: change.status !== 6 /* DELETED */ + ? change.uri.fsPath + : undefined, + statistics: { + additions: change.insertions, + deletions: change.deletions + } + } satisfies ChatSessionWorktreeFile)); + + return { changes, mergeBaseCommit }; + } +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index 2bd2a3126ab37..a9246a68bb487 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -33,6 +33,7 @@ import { IClaudeCodeSessionService } from '../../claude/node/sessionParser/claud import { IClaudeCodeSessionInfo } from '../../claude/node/sessionParser/claudeSessionSchema'; import { IClaudeSlashCommandService } from '../../claude/vscode-node/claudeSlashCommandService'; import { FolderRepositoryMRUEntry, IChatFolderMruService } from '../../common/folderRepositoryManager'; +import { IClaudeWorkspaceFolderService } from '../../common/claudeWorkspaceFolderService'; import { ClaudeChatSessionContentProvider, ClaudeChatSessionItemController } from '../claudeChatSessionContentProvider'; // Expose the most recently created items map so tests can inspect controller items. @@ -256,6 +257,10 @@ function createProviderWithServices( listSubagents: vi.fn().mockResolvedValue([]), getSubagentMessages: vi.fn().mockResolvedValue([]), }); + serviceCollection.define(IClaudeWorkspaceFolderService, { + _serviceBrand: undefined, + getWorkspaceChanges: vi.fn().mockResolvedValue([]), + }); const accessor = serviceCollection.createTestingAccessor(); const instaService = accessor.get(IInstantiationService); @@ -1240,6 +1245,10 @@ describe('ClaudeChatSessionItemController', () => { getSubagentMessages: vi.fn().mockResolvedValue([]), }; serviceCollection.define(IClaudeCodeSdkService, mockSdkService); + serviceCollection.define(IClaudeWorkspaceFolderService, { + _serviceBrand: undefined, + getWorkspaceChanges: vi.fn().mockResolvedValue([]), + }); const accessor = serviceCollection.createTestingAccessor(); lastControllerAccessor = accessor; const ctrl = accessor.get(IInstantiationService).createInstance(ClaudeChatSessionItemController); @@ -1356,6 +1365,77 @@ describe('ClaudeChatSessionItemController', () => { expect(itemA!.status).toBe(ChatSessionStatus.Completed); expect(itemB!.status).toBe(ChatSessionStatus.InProgress); }); + + it('calls getWorkspaceChanges on Completed status when session has cwd', async () => { + const diskSession: IClaudeCodeSessionInfo = { + id: 'changes-session', + label: 'Changes Session', + created: Date.now(), + lastRequestEnded: Date.now(), + folderName: 'my-project', + cwd: '/home/user/my-project', + gitBranch: 'feature-branch', + }; + vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any); + + const mockChanges = [{ uri: URI.file('/home/user/my-project/file.ts') }]; + const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService); + vi.mocked(workspaceFolderService.getWorkspaceChanges).mockResolvedValue(mockChanges as any); + + await controller.updateItemStatus('changes-session', ChatSessionStatus.InProgress, 'Prompt'); + await controller.updateItemStatus('changes-session', ChatSessionStatus.Completed, 'Prompt'); + + expect(workspaceFolderService.getWorkspaceChanges).toHaveBeenCalledWith( + '/home/user/my-project', + 'feature-branch', + undefined, + true, + ); + const item = getItem('changes-session'); + expect(item!.changes).toBe(mockChanges); + }); + + it('does not call getWorkspaceChanges on Completed when session has no cwd', async () => { + const diskSession: IClaudeCodeSessionInfo = { + id: 'no-cwd', + label: 'No CWD', + created: Date.now(), + lastRequestEnded: Date.now(), + folderName: undefined, + }; + vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any); + + const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService); + + await controller.updateItemStatus('no-cwd', ChatSessionStatus.InProgress, 'Prompt'); + await controller.updateItemStatus('no-cwd', ChatSessionStatus.Completed, 'Prompt'); + + expect(workspaceFolderService.getWorkspaceChanges).not.toHaveBeenCalled(); + }); + + it('does not call getWorkspaceChanges with forceRefresh on InProgress status', async () => { + const diskSession: IClaudeCodeSessionInfo = { + id: 'in-progress', + label: 'In Progress', + created: Date.now(), + lastRequestEnded: Date.now(), + folderName: 'my-project', + cwd: '/home/user/my-project', + gitBranch: 'feature-branch', + }; + vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any); + + const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService); + + await controller.updateItemStatus('in-progress', ChatSessionStatus.InProgress, 'Prompt'); + + expect(workspaceFolderService.getWorkspaceChanges).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + true, + ); + }); }); // #endregion @@ -1433,6 +1513,33 @@ describe('ClaudeChatSessionItemController', () => { const item = getItem('no-cwd-session'); expect(item!.metadata).toBeUndefined(); }); + + it('populates item.changes when session has cwd and gitBranch', async () => { + const diskSession: IClaudeCodeSessionInfo = { + id: 'changes-item', + label: 'Changes Item', + created: Date.now(), + lastRequestEnded: Date.now(), + folderName: 'my-project', + cwd: '/home/user/my-project', + gitBranch: 'feature-branch', + }; + vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any); + + const mockChanges = [{ uri: URI.file('/home/user/my-project/file.ts') }]; + const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService); + vi.mocked(workspaceFolderService.getWorkspaceChanges).mockResolvedValue(mockChanges as any); + + await controller.updateItemStatus('changes-item', ChatSessionStatus.InProgress, 'Prompt'); + + expect(workspaceFolderService.getWorkspaceChanges).toHaveBeenCalledWith( + '/home/user/my-project', + 'feature-branch', + undefined, + ); + const item = getItem('changes-item'); + expect(item!.changes).toBe(mockChanges); + }); }); // #endregion diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeWorkspaceFolderService.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeWorkspaceFolderService.spec.ts new file mode 100644 index 0000000000000..cf0734a4fbd8b --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeWorkspaceFolderService.spec.ts @@ -0,0 +1,219 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext'; +import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService'; +import { RepoContext } from '../../../../platform/git/common/gitService'; +import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { mock } from '../../../../util/common/test/simpleMock'; +import { constObservable } from '../../../../util/vs/base/common/observableInternal'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { ClaudeWorkspaceFolderService } from '../claudeWorkspaceFolderServiceImpl'; + +class MockLogService extends mock() { + override trace = vi.fn(); + override info = vi.fn(); + override warn = vi.fn(); + override error = vi.fn(); + override debug = vi.fn(); +} + +class MockExtensionContext extends mock() { + override globalStorageUri = vscode.Uri.file('/mock/global/storage'); +} + +function createMockRepoContext(overrides?: Partial): RepoContext { + return { + rootUri: URI.file('/mock/repo'), + kind: 0 as any, + isUsingVirtualFileSystem: false, + headIncomingChanges: undefined, + headOutgoingChanges: undefined, + headBranchName: 'feature-branch', + headCommitHash: 'abc123', + upstreamBranchName: undefined, + upstreamRemote: undefined, + isRebasing: false, + remotes: [], + worktrees: [], + changes: { + mergeChanges: [], + indexChanges: [], + workingTree: [], + untrackedChanges: [], + }, + headBranchNameObs: constObservable('feature-branch'), + headCommitHashObs: constObservable('abc123'), + upstreamBranchNameObs: constObservable(undefined), + upstreamRemoteObs: constObservable(undefined), + isRebasingObs: constObservable(false), + isIgnored: vi.fn().mockResolvedValue(false), + ...overrides, + }; +} + +describe('ClaudeWorkspaceFolderService', () => { + let gitService: MockGitService; + let logService: MockLogService; + let extensionContext: MockExtensionContext; + let fileSystemService: MockFileSystemService; + let service: ClaudeWorkspaceFolderService; + + beforeEach(() => { + gitService = new MockGitService(); + logService = new MockLogService(); + extensionContext = new MockExtensionContext(); + fileSystemService = new MockFileSystemService(); + service = new ClaudeWorkspaceFolderService(gitService, logService, extensionContext, fileSystemService); + }); + + describe('getWorkspaceChanges', () => { + it('returns empty array when repository is not found', async () => { + gitService.getRepository = vi.fn().mockResolvedValue(undefined); + + const result = await service.getWorkspaceChanges('/nonexistent', 'main', undefined); + + expect(result).toEqual([]); + expect(logService.warn).toHaveBeenCalled(); + }); + + it('returns empty array when repository has no changes object', async () => { + gitService.getRepository = vi.fn().mockResolvedValue( + createMockRepoContext({ changes: undefined }), + ); + + const result = await service.getWorkspaceChanges('/mock/repo', 'main', undefined); + + expect(result).toEqual([]); + }); + + it('returns cached result on second call with same inputs', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.exec = vi.fn().mockResolvedValue(''); + + const result1 = await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + const result2 = await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(result1).toBe(result2); + expect(gitService.exec).toHaveBeenCalledTimes(1); + }); + + it('bypasses cache when forceRefresh is true', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.exec = vi.fn().mockResolvedValue(''); + + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined, true); + + expect(gitService.exec).toHaveBeenCalledTimes(2); + }); + + it('returns empty array on git exec error', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.exec = vi.fn().mockRejectedValue(new Error('git failed')); + + const result = await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(result).toEqual([]); + expect(logService.error).toHaveBeenCalled(); + }); + }); + + describe('base branch auto-resolution', () => { + it('calls getBranchBase when gitBaseBranch is undefined and gitBranch is provided', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.getBranchBase = vi.fn().mockResolvedValue({ name: 'main', commit: 'def456', type: 0 }); + gitService.exec = vi.fn().mockResolvedValue(''); + gitService.getMergeBase = vi.fn().mockResolvedValue('def456'); + + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(gitService.getBranchBase).toHaveBeenCalledWith(repo.rootUri, 'feature-branch'); + expect(gitService.exec).toHaveBeenCalledWith( + repo.rootUri, + expect.arrayContaining(['--merge-base', 'main']), + ); + }); + + it('does not call getBranchBase when gitBaseBranch is explicitly provided', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.getBranchBase = vi.fn(); + gitService.exec = vi.fn().mockResolvedValue(''); + + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', 'develop'); + + expect(gitService.getBranchBase).not.toHaveBeenCalled(); + expect(gitService.exec).toHaveBeenCalledWith( + repo.rootUri, + expect.arrayContaining(['--merge-base', 'develop']), + ); + }); + + it('handles getBranchBase returning undefined gracefully', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.getBranchBase = vi.fn().mockResolvedValue(undefined); + gitService.exec = vi.fn().mockResolvedValue(''); + + const result = await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(result).toEqual([]); + expect(gitService.exec).toHaveBeenCalledWith( + repo.rootUri, + expect.not.arrayContaining(['--merge-base']), + ); + }); + + it('handles getBranchBase throwing an error gracefully', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.getBranchBase = vi.fn().mockRejectedValue(new Error('branch not found')); + gitService.exec = vi.fn().mockResolvedValue(''); + + const result = await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(result).toEqual([]); + expect(logService.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to resolve base branch'), + ); + }); + + it('does not call getBranchBase when headCommitHash is undefined', async () => { + const repo = createMockRepoContext({ headCommitHash: undefined }); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.getBranchBase = vi.fn(); + gitService.exec = vi.fn().mockResolvedValue(''); + + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(gitService.getBranchBase).not.toHaveBeenCalled(); + }); + }); + + describe('dispose', () => { + it('clears the cache on dispose', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.exec = vi.fn().mockResolvedValue(''); + + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + service.dispose(); + + gitService.exec = vi.fn().mockResolvedValue(''); + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(gitService.exec).toHaveBeenCalledTimes(1); + }); + }); +}); From fa68d148563d441999a50f72414caa7c53aaf1b9 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Wed, 22 Apr 2026 17:03:17 -0700 Subject: [PATCH 2/3] docs --- extensions/copilot/src/extension/chatSessions/claude/AGENTS.md | 2 ++ .../extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md | 1 + 2 files changed, 3 insertions(+) diff --git a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md index 9e8ba850f03bf..fa726f71d883d 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md +++ b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md @@ -214,6 +214,8 @@ In multi-root and empty workspaces, a folder picker option appears in the chat s ### Key Files - **`common/claudeFolderInfo.ts`**: `ClaudeFolderInfo` interface +- **`../../chatSessions/common/claudeWorkspaceFolderService.ts`**: `IClaudeWorkspaceFolderService` interface — computes git diff changes for session items +- **`../../chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts`**: Implementation — diffs the session's branch against its base branch, caches results, and maps changes to `ChatSessionChangedFile[]` for display in the Sessions view - **`../../chatSessions/vscode-node/claudeChatSessionContentProvider.ts`**: Folder resolution, picker options, and handler integration - **`../../chatSessions/vscode-node/folderRepositoryManagerImpl.ts`**: `FolderRepositoryManager` (abstract base) with `ClaudeFolderRepositoryManager` subclass — the Claude subclass does not depend on `ICopilotCLISessionService` (CopilotCLI has its own subclass `CopilotCLIFolderRepositoryManager`) - **`node/claudeCodeAgent.ts`**: Consumes `ClaudeFolderInfo` in `ClaudeCodeSession._startSession()` diff --git a/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md b/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md index 669f49ac5a375..d2cb58a4587c2 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md +++ b/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md @@ -225,6 +225,7 @@ Each session in the list displays: | **Blue dot** | Indicates an unread or recently active session | | **Status icon** | Shows whether the session is completed, in progress, needs input, or failed | | **Folder badge** | In multi-root or empty workspaces, shows which folder the session ran in | +| **Change stats** | Shows lines added and removed (e.g., `+584 -17`) — a quick summary of the session's code impact, computed by diffing the session's branch against its base branch | Sessions are sorted by recency — the most recent session appears at the top. In the dedicated sidebar, they're also grouped by time period. From 7cf968caa715822c6ac584b757c0b86a927f2c9c Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Wed, 22 Apr 2026 17:21:58 -0700 Subject: [PATCH 3/3] feedback --- .../claudeChatSessionContentProvider.ts | 17 +++++++++---- .../claudeWorkspaceFolderServiceImpl.ts | 24 ++++++++++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index 15288851c0aaf..56bdd49a90938 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -126,9 +126,9 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco }); const prompt = request.prompt; - this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.InProgress, prompt); + await this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.InProgress, prompt); const result = await this.claudeAgentManager.handleRequest(effectiveSessionId, request, context, stream, token, isNewSession, yieldRequested); - this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.Completed, prompt); + await this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.Completed, prompt); // Clear usage handler after request completes this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, undefined); @@ -685,9 +685,16 @@ export class ClaudeChatSessionItemController extends Disposable { private async _refreshItems(token: vscode.CancellationToken): Promise { const sessions = await this._claudeCodeSessionService.getAllSessions(token); const results = await Promise.allSettled(sessions.map(session => this._createClaudeChatSessionItem(session))); - const items = results - .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') - .map(r => r.value); + const items: vscode.ChatSessionItem[] = []; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === 'fulfilled') { + items.push(result.value); + } else { + const session = sessions[i]; + this._logService.warn(`Failed to create Claude chat session item for ${session.id} (${session.label}) ${result.reason}`); + } + } items.push(...this._inProgressItems.values()); this._controller.items.replace(items); } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts index 338ee8e235063..8d403f7c6bdb2 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts @@ -27,6 +27,7 @@ export class ClaudeWorkspaceFolderService extends Disposable implements IClaudeW declare _serviceBrand: undefined; private readonly _cache = new Map(); + private readonly _inflight = new Map>(); constructor( @IGitService private readonly _gitService: IGitService, @@ -39,6 +40,7 @@ export class ClaudeWorkspaceFolderService extends Disposable implements IClaudeW override dispose(): void { this._cache.clear(); + this._inflight.clear(); super.dispose(); } @@ -57,6 +59,26 @@ export class ClaudeWorkspaceFolderService extends Disposable implements IClaudeW } } + const existing = this._inflight.get(cacheKey); + if (existing) { + return existing; + } + + const promise = this._computeAndCacheChanges(cacheKey, cwd, gitBranch, gitBaseBranch); + this._inflight.set(cacheKey, promise); + try { + return await promise; + } finally { + this._inflight.delete(cacheKey); + } + } + + private async _computeAndCacheChanges( + cacheKey: string, + cwd: string, + gitBranch: string | undefined, + gitBaseBranch: string | undefined, + ): Promise { const result = await this.computeRepositoryChanges(cwd, gitBranch, gitBaseBranch); if (!result) { return []; @@ -121,7 +143,7 @@ export class ClaudeWorkspaceFolderService extends Disposable implements IClaudeW const mergeBaseArg = resolvedBaseBranchName ? ['--merge-base', resolvedBaseBranchName] - : []; + : ['HEAD']; if (hasUntrackedChanges) { // Tracked + untracked changes