From e4c6f4baa177f79fd7e7894892063cddaf6cbb7b Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 14 Apr 2026 18:46:21 +1000 Subject: [PATCH] Refactor Copilot CLI session management and improve folder handling - Mark `onDidChangeSessions` as deprecated in `ICopilotCLISessionService`. - Enhance session deletion logic in `ChatSessionMetadataStore`. - Update `CopilotCLIChatSessionInitializer` to support new branch creation. - Refine session option group handling in `SessionOptionGroupBuilder`, ensuring previous selections persist. - Adjust tests to validate new folder handling and session state persistence. --- .../node/copilotcliSessionService.ts | 3 + .../chatSessionMetadataStoreImpl.ts | 1 + .../copilotCLIChatSessionInitializer.ts | 2 +- .../vscode-node/copilotCLIChatSessions.ts | 62 ++++------ .../folderRepositoryManagerImpl.ts | 2 +- .../vscode-node/sessionOptionGroupBuilder.ts | 45 ++++--- .../test/sessionOptionGroupBuilder.spec.ts | 110 +++++++++++++----- 7 files changed, 135 insertions(+), 90 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index d6839eb6c4bc9..6a650901b70de 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -78,6 +78,9 @@ export type ICreateSessionOptions = ISessionOptions & { sessionId?: string }; export interface ICopilotCLISessionService { readonly _serviceBrand: undefined; + /** + * @deprecated Kept only for non-controller API + */ onDidChangeSessions: Event; onDidDeleteSession: Event; onDidChangeSession: Event; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts index fe553737a70cc..12f3f1d0b3f90 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts @@ -158,6 +158,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession await this.writeToGlobalStorage(data); } try { + await this.fileSystemService.delete(this.getMetadataFileUri(sessionId)); await this.fileSystemService.delete(this.getRequestMappingFileUri(sessionId)); } catch { // File may not exist, ignore. diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts index 6a917a064f80c..c1a589347c268 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts @@ -162,7 +162,7 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI } } else { // No chat session context (e.g., delegation) - initialize with active repository - folderInfo = await this.folderRepositoryManager.initializeFolderRepository(undefined, { stream, toolInvocationToken, isolation: options?.isolation, folder }, token); + folderInfo = await this.folderRepositoryManager.initializeFolderRepository(undefined, { stream, toolInvocationToken, isolation: options?.isolation, folder, newBranch: options?.newBranch }, token); } if (folderInfo.trusted === false || folderInfo.cancelled) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 28e313294ecab..234c12b2ab1d5 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -52,33 +52,6 @@ import { IPullRequestDetectionService } from './pullRequestDetectionService'; import { getSelectedSessionOptions, ISessionOptionGroupBuilder, OPEN_REPOSITORY_COMMAND_ID, toRepositoryOptionItem, toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; import { ISessionRequestLifecycle } from './sessionRequestLifecycle'; -/** - * ODO: - * 3. Verify all command handlers do the exact same thing - * 6. Is chatSessionContext?.initialSessionOptions still valid with new API - * 7. Validated selected MRU item - * 8. We shouldn't have to pass model information into CLISession class, and then update sdk with the model info. Instead when we call get/create session, we should be able to pass the model info there and update the SDK session accordingly. - * This makes it unnecessary to pass model information. - * 2. Behavioral Change: trusted flag no longer unlocks dropdowns on trust failure -In the old code, when sessionResult.trusted === false, there was a call to this.unlockRepoOptionForSession(context, token) to reset dropdown selections. The new code at copilotCLIChatSessions.ts:634 simply returns {} without any dropdown reset. However, lockRepoOptionForSession and unlockRepoOptionForSession were already dead code (commented out), so this is actually correct — removing a no-op. - * - * Cases to cover: - * 1. Hook up the dropdowns for empty workspace folders as well - * 2. In mult-root workspace we need to display workspace/worktree dropdown along with the repo dropdown - * 3. Temporarily lock/unlock dropdowns while creating session - * 4. Lock dropdowns when opening an existing session - * 5. Browse folders command in empty workspaces - * 6. Branch dropdown should only be displayed when we select a folder/repo thats a git repo. - * - * Test: - * 1. All of the above - * 2. Forking sessions - * 3. Steering messages - * 4. Queued messages - * 5. Selecting a new folder in browse folders command should end up with that folder in the dropdown. - * 6. Delegate from CLI to Cloud - * 7. Delegate from Local to CLI - */ export interface ICopilotCLIChatSessionItemProvider extends IDisposable { refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise; @@ -196,6 +169,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } } )); + const inputStateForNewSession = new ResourceMap>(); controller.newChatSessionItemHandler = async (context) => { const sessionId = this.sessionService.createNewSessionId(); const resource = SessionIdForCLI.getResource(sessionId); @@ -211,6 +185,8 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements controller.items.add(session); this.newSessions.set(resource, session); + const groups = await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(context.inputState); + inputStateForNewSession.set(resource, new WeakRef(controller.createChatSessionInputState(groups))); return session; }; if (this.configurationService.getConfig(ConfigKey.Advanced.CLIForkSessionsEnabled)) { @@ -273,7 +249,12 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements const groups = await this._optionGroupBuilder.buildExistingSessionInputStateGroups(sessionResource, token); return controller.createChatSessionInputState(groups); } else { - const groups = await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(context.previousInputState); + // Possible we've already handled the newChatSessionItemHandler for this same uri + // In which case the proper inputState would have been sent. + // There's a bug in core where after newChatSessionItemHandler is called, we get + // another call for getChatSessionInputState, but this time the previous input state is incorrect. + const previousInputState = sessionResource ? inputStateForNewSession.get(sessionResource)?.deref() : undefined; + const groups = await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(previousInputState); const state = controller.createChatSessionInputState(groups); // Only wire dynamic updates for new sessions (existing sessions are fully locked). // Note: don't use the getChatSessionInputState token here — it's a one-shot token @@ -309,7 +290,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } public async updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise { - this._optionGroupBuilder.setNewFolderForInputState(inputState, folderUri); await this._optionGroupBuilder.rebuildInputState(inputState, folderUri); } @@ -720,6 +700,15 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return { input, attachments }; } + private generateNewBranchName(request: vscode.ChatRequest, token: vscode.CancellationToken): Promise { + const requestTurn = new ChatRequestTurn2(request.prompt ?? '', request.command, [], '', [], [], undefined, undefined, undefined); + const fakeContext: vscode.ChatContext = { + history: [requestTurn], + yieldRequested: false, + }; + const branchNamePromise = (request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : Promise.resolve(undefined); + return branchNamePromise; + } private async handleRequestImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise { const { chatSessionContext } = context; const disposables = new DisposableStore(); @@ -752,12 +741,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return {}; } - const requestTurn = new ChatRequestTurn2(request.prompt ?? '', request.command, [], '', [], [], undefined, undefined, undefined); - const fakeContext: vscode.ChatContext = { - history: [requestTurn], - yieldRequested: false, - }; - const branchNamePromise = (isNewSession && request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : Promise.resolve(undefined); + const branchNamePromise = isNewSession ? this.generateNewBranchName(request, token) : Promise.resolve(undefined); if (isNewSession) { this._optionGroupBuilder.lockInputStateGroups(chatSessionContext.inputState); @@ -869,8 +853,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable { userPrompt = userPrompt || request.prompt; return summary ? `${userPrompt}\n${summary}` : userPrompt; })(); - - const { workspaceInfo, cancelled } = await this.sessionInitializer.initializeWorkingDirectory(undefined, { stream }, request.toolInvocationToken, token); + const branchNamePromise = this.generateNewBranchName(request, token); + const { workspaceInfo, cancelled } = await this.sessionInitializer.initializeWorkingDirectory(undefined, { stream, isolation: IsolationMode.Worktree, newBranch: branchNamePromise }, request.toolInvocationToken, token); if (cancelled || token.isCancellationRequested) { stream.markdown(l10n.t('Copilot CLI delegation cancelled.')); @@ -966,8 +950,8 @@ export function registerCLIChatCommands( ); if (result === deleteLabel) { - await copilotCLISessionService.deleteSession(id); await copilotCliWorkspaceSession.deleteTrackedWorkspaceFolder(id); + await copilotCLISessionService.deleteSession(id); if (worktreePath) { try { @@ -1256,7 +1240,7 @@ export function registerCLIChatCommands( })); const applyChanges = async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { - const resource = sessionItemOrResource instanceof vscode.Uri + const resource = isUri(sessionItemOrResource) ? sessionItemOrResource : sessionItemOrResource?.resource; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts index 460970dfbcee2..5a3438bed724a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts @@ -739,7 +739,7 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol deleteFromSource: moveOrCopyChanges === 'move', untracked: true }); - stream.markdown(l10n.t('Changes migrated to worktree.')); + stream.markdown(l10n.t('Changes migrated to worktree.\n')); } } catch (error) { // Continue even if migration fails diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts index 5204541e577a7..e854261148ba9 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts @@ -315,7 +315,6 @@ export function folderMRUToChatProviderOptions(mruItems: FolderRepositoryMRUEntr */ export interface ISessionOptionGroupBuilder { readonly _serviceBrand: undefined; - setNewFolderForInputState(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): void; provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined): Promise; buildBranchOptionGroup(branches: vscode.ChatSessionProviderOptionItem[], headBranchName: string | undefined, isolationEnabled: boolean, currentIsolation: IsolationMode | undefined, previousSelection: vscode.ChatSessionProviderOptionItem | undefined): vscode.ChatSessionProviderOptionGroup | undefined; handleInputStateChange(state: vscode.ChatSessionInputState): Promise; @@ -340,7 +339,7 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { declare readonly _serviceBrand: undefined; private readonly _getBranchOptionItemsForRepositorySequencer = new SequencerByKey(); private readonly _pendingBuildGroups = new WeakMap>(); - // Keeps track of the new folders selected by user, by using folder dialog to select a new folder. + // Keeps track of the new folders selected by user private readonly _inputStateNewFolders = new WeakMap(); constructor( @IGitService private readonly gitService: IGitService, @@ -354,9 +353,6 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { ) { } - setNewFolderForInputState(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): void { - this._inputStateNewFolders.set(inputState, folderUri); - } /** * Return the git repository for a URI only if the folder is trusted. * Untrusted folders are treated as non-git. @@ -409,27 +405,37 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { // For untitled workspaces, show last used repositories and "Open Repository..." command const repositories = await this.copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None); - const newFolder = previousInputState ? this._inputStateNewFolders.get(previousInputState) : undefined; items = folderMRUToChatProviderOptions(repositories); + const addFolderToList = async (uri: Uri) => { + const newFolderRepo = await this.getTrustedRepository(uri, true); + const newFolderItem = newFolderRepo + ? toRepositoryOptionItem(newFolderRepo.rootUri) + : toWorkspaceFolderOptionItem(uri, uri.path.split('/').pop() ?? uri.fsPath); + // Remove duplicate if already in the list, then add to top + items = items.filter(item => item.id !== newFolderItem.id); + items.unshift(newFolderItem); + }; + if (selectedFolderUri) { + await addFolderToList(selectedFolderUri); + } + const previouslySelectedUri = previouslySelected ? vscode.Uri.file(previouslySelected.id) : undefined; + if (previouslySelectedUri) { + await addFolderToList(previouslySelectedUri); + } + // Ensure previously selected folder is added back into the list of folders. + const newFolder = previousInputState ? this._inputStateNewFolders.get(previousInputState) : undefined; + if (newFolder) { + await addFolderToList(newFolder); + } const selectedFolderItem = selectedFolderUri ? items.find(i => i.id === selectedFolderUri.fsPath) : undefined; + const previouslySelectedItem = previouslySelected ? items.find(i => i.id === previouslySelected.id) : undefined; const selectedItem = selectedFolderItem - ?? (previouslySelected - ? items.find(i => i.id === previouslySelected.id) ?? items[0] - : items[0]); + ?? previouslySelectedItem ?? items[0]; if (selectedItem) { defaultRepoUri = vscode.Uri.file(selectedItem.id); } items.splice(MAX_MRU_ENTRIES); // Limit to max entries - if (newFolder) { - const newFolderRepo = await this.getTrustedRepository(newFolder, true); - const newFolderItem = newFolderRepo - ? toRepositoryOptionItem(newFolderRepo.rootUri) - : toWorkspaceFolderOptionItem(newFolder, newFolder.path.split('/').pop() ?? newFolder.fsPath); - // Remove duplicate if already in the list, then add to top - items = items.filter(item => item.id !== newFolderItem.id); - items.unshift(newFolderItem); - } // If user selected something from the list but it's not there anymore (perhaps its an item at the end of MRU). if (selectedItem && !items.some(item => item.id === selectedItem.id)) { items.push(selectedItem); @@ -546,6 +552,9 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { if (!optionGroupsEqual(state.groups, newGroups)) { state.groups = newGroups; } + if (selectedFolderUri) { + this._inputStateNewFolders.set(state, selectedFolderUri); + } } /** diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts index b76a7745b13d7..b36e9d044231b 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts @@ -788,16 +788,18 @@ describe('SessionOptionGroupBuilder', () => { expect(repoGroup!.selected).toBeUndefined(); }); - it('falls back to first item when previous selection is no longer in welcome view MRU', async () => { + it('preserves previous selection even when no longer in welcome view MRU', async () => { workspaceService = new NullWorkspaceService([]); builder = new SessionOptionGroupBuilder( gitService, configurationService, context, workspaceService, folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, ); const currentUri = URI.file('/current-repo'); + const removedUri = URI.file('/removed-repo'); folderMruService.getRecentlyUsedFolders.mockResolvedValue([ { folder: currentUri, repository: currentUri, lastAccessed: Date.now() }, ]); + gitService.getRepository.mockResolvedValue(undefined); const previousState: vscode.ChatSessionInputState = { onDidChange: Event.None, @@ -806,13 +808,15 @@ describe('SessionOptionGroupBuilder', () => { name: 'Folder', description: '', items: [], - selected: { id: URI.file('/removed-repo').fsPath, name: 'removed-repo' }, + selected: { id: removedUri.fsPath, name: 'removed-repo' }, }], }; const groups = await builder.provideChatSessionProviderOptionGroups(previousState); const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup!.selected?.id).toBe(currentUri.fsPath); + // Previous selection is re-resolved and added to the top + expect(repoGroup!.selected?.id).toBe(removedUri.fsPath); + expect(repoGroup!.items[0].id).toBe(removedUri.fsPath); }); it('adds new folder (git repo) to top of items in welcome view', async () => { @@ -829,13 +833,7 @@ describe('SessionOptionGroupBuilder', () => { const newRepo = makeRepo(newFolderUri.fsPath); gitService.getRepository.mockResolvedValue(newRepo); - const previousState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [], - }; - builder.setNewFolderForInputState(previousState, newFolderUri as any); - - const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const groups = await builder.provideChatSessionProviderOptionGroups(undefined, newFolderUri as any); const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); expect(repoGroup).toBeDefined(); expect(repoGroup!.items[0].id).toBe(newFolderUri.fsPath); @@ -854,13 +852,7 @@ describe('SessionOptionGroupBuilder', () => { const newFolderUri = URI.file('/new-plain-folder'); gitService.getRepository.mockResolvedValue(undefined); - const previousState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [], - }; - builder.setNewFolderForInputState(previousState, newFolderUri as any); - - const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const groups = await builder.provideChatSessionProviderOptionGroups(undefined, newFolderUri as any); const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); expect(repoGroup).toBeDefined(); expect(repoGroup!.items[0].id).toBe(newFolderUri.fsPath); @@ -879,13 +871,7 @@ describe('SessionOptionGroupBuilder', () => { const newRepo = makeRepo(sharedUri.fsPath); gitService.getRepository.mockResolvedValue(newRepo); - const previousState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [], - }; - builder.setNewFolderForInputState(previousState, sharedUri as any); - - const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const groups = await builder.provideChatSessionProviderOptionGroups(undefined, sharedUri as any); const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); expect(repoGroup).toBeDefined(); // Should not have duplicates @@ -911,13 +897,7 @@ describe('SessionOptionGroupBuilder', () => { ]); gitService.getRepository.mockResolvedValue(makeRepo(repoUri.fsPath)); - const previousState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [], - }; - builder.setNewFolderForInputState(previousState, repoUri as any); - - const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const groups = await builder.provideChatSessionProviderOptionGroups(undefined, repoUri as any); const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID)!; // Selected item must reference an object that is in the items list expect(repoGroup.items.some(i => i.id === repoGroup.selected?.id)).toBe(true); @@ -937,6 +917,40 @@ describe('SessionOptionGroupBuilder', () => { const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); expect(repoGroup!.items).toHaveLength(0); }); + + it('re-resolves previously selected folder as git repo when not in MRU', async () => { + // When the previous selection is not in the MRU list, the builder should + // look it up via getTrustedRepository and add it with the correct icon. + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const mruUri = URI.file('/current-repo'); + const prevUri = URI.file('/prev-repo'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([ + { folder: mruUri, repository: mruUri, lastAccessed: Date.now() }, + ]); + const prevRepo = makeRepo(prevUri.fsPath); + gitService.getRepository.mockResolvedValue(prevRepo); + + const previousState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [{ + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: prevUri.fsPath, name: 'prev-repo' }, + }], + }; + + const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup!.selected?.id).toBe(prevUri.fsPath); + // The previously selected item should be at the top + expect(repoGroup!.items[0].id).toBe(prevUri.fsPath); + }); }); describe('handleInputStateChange', () => { @@ -1519,6 +1533,40 @@ describe('SessionOptionGroupBuilder', () => { // Branch should not be shown for non-git folder expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined(); }); + + it('stores selectedFolderUri so it persists in subsequent rebuilds (welcome view)', async () => { + // In the welcome view, rebuildInputState with a selectedFolderUri should + // remember it so the next rebuild keeps the folder in the list. + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); + + const browsedUri = URI.file('/browsed-folder'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([]); + gitService.getRepository.mockResolvedValue(undefined); + + // Initial build — empty + const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: initialGroups, + }; + + // Simulate "Browse folders…" — rebuild with the browsed folder + await builder.rebuildInputState(state, browsedUri as any); + const repoGroup1 = state.groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup1!.items.some(i => i.id === browsedUri.fsPath)).toBe(true); + + // Second rebuild without selectedFolderUri — the browsed folder should persist + folderMruService.getRecentlyUsedFolders.mockResolvedValue([]); + await builder.rebuildInputState(state); + const repoGroup2 = state.groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup2!.items.some(i => i.id === browsedUri.fsPath)).toBe(true); + }); }); describe('lockInputStateGroups', () => {