From e3ca722ee9ed386d6eb12f41b164c7776c95386e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:06:56 +0000 Subject: [PATCH 1/4] Add worktree isolation support for Claude sessions Wire IChatSessionWorktreeService into ClaudeChatSessionContentProvider: - getFolderInfoForSession() checks for existing worktree properties - New sessions with worktree isolation call initializeFolderRepository() - handleRequestCompleted() auto-commits worktree changes after each turn - Add isolation mode option to session provider options UI - Wire archive/unarchive lifecycle hooks in chatSessions.ts - Update metadata to track isolationMode per session Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/67fcc2e4-9c3d-4fd5-bed6-fa5297bd5106 Co-authored-by: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> --- .../chatSessions/vscode-node/chatSessions.ts | 22 +++ .../claudeChatSessionContentProvider.ts | 128 +++++++++++++++++- .../claudeChatSessionContentProvider.spec.ts | 19 +++ 3 files changed, 165 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index b0c2872ffaa0e..33ecfaa42b9be 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -157,6 +157,28 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const claudeCustomizationProvider = this._register(claudeAgentInstaService.createInstance(ClaudeCustomizationProvider)); this._register(vscode.chat.registerChatSessionCustomizationProvider(ClaudeSessionUri.scheme, ClaudeCustomizationProvider.metadata, claudeCustomizationProvider)); + // Handle worktree cleanup/recreation when Claude session archive state changes + const claudeWorktreeService = claudeAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorktreeService)); + const claudeController = chatSessionContentProvider.controller; + this._register(claudeController.onDidChangeChatSessionItemState(async (item) => { + const sessionId = ClaudeSessionUri.getSessionId(item.resource); + if (item.archived) { + try { + const result = await claudeWorktreeService.cleanupWorktreeOnArchive(sessionId); + logService.trace(`[Claude] Worktree cleanup for session ${sessionId}: ${result.cleaned ? 'cleaned' : result.reason}`); + } catch (error) { + logService.error(`[Claude] Failed to cleanup worktree for archived session ${sessionId}:`, error); + } + } else { + try { + const result = await claudeWorktreeService.recreateWorktreeOnUnarchive(sessionId); + logService.trace(`[Claude] Worktree recreation for session ${sessionId}: ${result.recreated ? 'recreated' : result.reason}`); + } catch (error) { + logService.error(`[Claude] Failed to recreate worktree for unarchived session ${sessionId}:`, error); + } + } + })); + // #endregion // #endregion diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index e2d7378a1ac34..1c51d04007e24 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -27,7 +27,8 @@ import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateS import { IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCodeSessionService'; import { IClaudeCodeSessionInfo } from '../claude/node/sessionParser/claudeSessionSchema'; import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService'; -import { FolderRepositoryMRUEntry, IFolderRepositoryManager } from '../common/folderRepositoryManager'; +import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; +import { FolderRepositoryMRUEntry, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager'; import { buildChatHistory } from './chatHistoryBuilder'; const permissionModes: ReadonlySet = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk']); @@ -44,6 +45,7 @@ import '../claude/vscode-node/mcpServers/index'; const PERMISSION_MODE_OPTION_ID = 'permissionMode'; const FOLDER_OPTION_ID = 'folder'; +const ISOLATION_MODE_OPTION_ID = 'isolation'; const MAX_MRU_ENTRIES = 10; export class ClaudeChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider { @@ -57,6 +59,14 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco private _lastUsedPermissionMode: PermissionMode = 'acceptEdits'; private readonly _controller: ClaudeChatSessionItemController; + + /** + * Exposes the session item controller for lifecycle event subscription (e.g., archive/unarchive). + */ + get controller(): ClaudeChatSessionItemController { + return this._controller; + } + constructor( private readonly claudeAgentManager: ClaudeAgentManager, @IClaudeCodeSessionService private readonly sessionService: IClaudeCodeSessionService, @@ -64,11 +74,12 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco @IConfigurationService private readonly configurationService: IConfigurationService, @IClaudeSlashCommandService private readonly slashCommandService: IClaudeSlashCommandService, @IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager, + @IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @INativeEnvService private readonly envService: INativeEnvService, @IGitService gitService: IGitService, @IClaudeCodeSdkService sdkService: IClaudeCodeSdkService, - @ILogService logService: ILogService, + @ILogService private readonly logService: ILogService, ) { super(); this._controller = this._register(new ClaudeChatSessionItemController(sessionService, workspaceService, gitService, sdkService, logService)); @@ -110,11 +121,21 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco /** * Resolves the cwd and additionalDirectories for a session. * + * - If the session has a worktree, cwd is the worktree path * - Single-root workspace: cwd is the one folder, no additionalDirectories * - Multi-root workspace: cwd is the selected folder, additionalDirectories are the rest * - Empty workspace: cwd is the selected MRU folder, no additionalDirectories */ public async getFolderInfoForSession(sessionId: string): Promise { + // Check if this session has a worktree — use it as cwd if so + const worktreeProperties = await this.worktreeService.getWorktreeProperties(sessionId); + if (worktreeProperties) { + return { + cwd: worktreeProperties.worktreePath, + additionalDirectories: [], + }; + } + const workspaceFolders = this.workspaceService.getWorkspaceFolders(); if (workspaceFolders.length === 1) { @@ -159,6 +180,42 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco }; } + /** + * Initializes a worktree for a new Claude session if isolation mode is 'worktree'. + * This must be called during request handling (where stream and toolInvocationToken are available). + */ + private async _initializeWorktreeForNewSession( + sessionId: string, + stream: vscode.ChatResponseStream, + toolInvocationToken: vscode.ChatParticipantToolToken, + token: vscode.CancellationToken + ): Promise { + const isolationMode = this._controller.getMetadata(sessionId)?.isolationMode; + if (isolationMode !== IsolationMode.Worktree) { + return; + } + + const selectedFolder = this._controller.getMetadata(sessionId)?.cwd; + const workspaceFolders = this.workspaceService.getWorkspaceFolders(); + const folder = selectedFolder ?? (workspaceFolders.length === 1 ? workspaceFolders[0] : undefined); + + const folderInfo = await this.folderRepositoryManager.initializeFolderRepository( + sessionId, + { + stream, + toolInvocationToken, + isolation: IsolationMode.Worktree, + folder, + }, + token + ); + + if (folderInfo.worktreeProperties) { + await this.worktreeService.setWorktreeProperties(sessionId, folderInfo.worktreeProperties); + this.logService.info(`[Claude] Created worktree for session ${sessionId}: ${folderInfo.worktreeProperties.worktreePath}`); + } + } + // #region Folder Option Helpers private _isEmptyWorkspace(): boolean { @@ -239,6 +296,14 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco const existingSession = await this.sessionService.getSession(sessionUri, token); const isNewSession = !existingSession; + // Initialize worktree for new sessions with worktree isolation + if (isNewSession) { + await this._initializeWorktreeForNewSession(effectiveSessionId, stream, request.toolInvocationToken, token); + if (token.isCancellationRequested) { + return {}; + } + } + const modelId = parseClaudeModelId(request.model.id); const permissionMode = this.getPermissionModeForSession(effectiveSessionId); const folderInfo = await this.getFolderInfoForSession(effectiveSessionId); @@ -258,6 +323,13 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco const result = await this.claudeAgentManager.handleRequest(effectiveSessionId, request, context, stream, token, isNewSession, yieldRequested); this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.Completed, prompt); + // Auto-commit worktree changes after the turn completes + try { + await this.worktreeService.handleRequestCompleted(effectiveSessionId); + } catch (error) { + this.logService.warn(`[Claude] Failed to handle worktree request completion for session ${effectiveSessionId}: ${error}`); + } + // Clear usage handler after request completes this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, undefined); @@ -285,6 +357,15 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco name: l10n.t('Permission Mode'), description: l10n.t('Pick Permission Mode'), items: permissionModeItems, + }, + { + id: ISOLATION_MODE_OPTION_ID, + name: l10n.t('Isolation'), + description: l10n.t('Pick Isolation Mode'), + items: [ + { id: IsolationMode.Worktree, name: l10n.t('Worktree') }, + { id: IsolationMode.Workspace, name: l10n.t('Workspace') }, + ], } ]; @@ -311,6 +392,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco const newSessionOptions: Record = {}; newSessionOptions[PERMISSION_MODE_OPTION_ID] = this._lastUsedPermissionMode; + newSessionOptions[ISOLATION_MODE_OPTION_ID] = IsolationMode.Worktree; if (workspaceFolders.length !== 1) { const defaultFolder = await this._getDefaultFolder(); @@ -337,6 +419,10 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco } else if (update.optionId === FOLDER_OPTION_ID && typeof update.value === 'string') { this._controller.setMetadata(sessionId, { cwd: URI.file(update.value) }); hadUpdate = true; + } else if (update.optionId === ISOLATION_MODE_OPTION_ID && typeof update.value === 'string') { + const isolationMode = update.value === IsolationMode.Worktree ? IsolationMode.Worktree : IsolationMode.Workspace; + this._controller.setMetadata(sessionId, { isolationMode }); + hadUpdate = true; } } if (hadUpdate) { @@ -356,6 +442,24 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco const options: Record = {}; options[PERMISSION_MODE_OPTION_ID] = permissionMode; + // For existing sessions with worktree, lock the isolation option + const worktreeProperties = await this.worktreeService.getWorktreeProperties(sessionId); + if (existingSession && worktreeProperties) { + options[ISOLATION_MODE_OPTION_ID] = { + id: IsolationMode.Worktree, + name: l10n.t('Worktree'), + locked: true, + }; + } else if (existingSession) { + options[ISOLATION_MODE_OPTION_ID] = { + id: IsolationMode.Workspace, + name: l10n.t('Workspace'), + locked: true, + }; + } else { + options[ISOLATION_MODE_OPTION_ID] = IsolationMode.Worktree; + } + // Include folder option if applicable (multi-root or empty workspace) const workspaceFolders = this.workspaceService.getWorkspaceFolders(); if (workspaceFolders.length !== 1) { @@ -404,6 +508,13 @@ export class ClaudeChatSessionItemController extends Disposable { private readonly _inProgressItems = new Map(); private _showBadge: boolean; + /** + * Fired when an item's archived state changes. + */ + get onDidChangeChatSessionItemState() { + return this._controller.onDidChangeChatSessionItemState; + } + constructor( @IClaudeCodeSessionService private readonly _claudeCodeSessionService: IClaudeCodeSessionService, @IWorkspaceService private readonly _workspaceService: IWorkspaceService, @@ -434,9 +545,14 @@ export class ClaudeChatSessionItemController extends Disposable { : folderOptionValue?.id ? URI.file(folderOptionValue.id) : undefined; + const isolationOptionValue = context.sessionOptions?.find(o => o.optionId === ISOLATION_MODE_OPTION_ID)?.value; + const isolationMode = (typeof isolationOptionValue === 'string' ? isolationOptionValue : isolationOptionValue?.id) === IsolationMode.Worktree + ? IsolationMode.Worktree + : IsolationMode.Workspace; item.metadata = { permissionMode, cwd: folder, + isolationMode, }; this._controller.items.add(item); return item; @@ -496,18 +612,19 @@ export class ClaudeChatSessionItemController extends Disposable { })); } - setMetadata(sessionId: string, metadata: Partial<{ permissionMode: PermissionMode; cwd?: URI }>): void { + setMetadata(sessionId: string, metadata: Partial<{ permissionMode: PermissionMode; cwd?: URI; isolationMode?: IsolationMode }>): void { const item = this._controller.items.get(ClaudeSessionUri.forSessionId(sessionId)); if (item) { item.metadata = { ...item.metadata, permissionMode: metadata.permissionMode ?? item.metadata?.permissionMode, cwd: metadata.cwd ?? item.metadata?.cwd, + isolationMode: metadata.isolationMode ?? item.metadata?.isolationMode, }; } } - getMetadata(sessionId: string): { permissionMode?: PermissionMode; cwd?: URI } | undefined { + getMetadata(sessionId: string): { permissionMode?: PermissionMode; cwd?: URI; isolationMode?: IsolationMode } | undefined { const candidate = this._controller.items.get(ClaudeSessionUri.forSessionId(sessionId)); if (candidate) { if (candidate.metadata?.permissionMode !== undefined && !isPermissionMode(candidate.metadata.permissionMode)) { @@ -515,6 +632,7 @@ export class ClaudeChatSessionItemController extends Disposable { candidate.metadata = { permissionMode: 'acceptEdits', cwd: candidate.metadata?.cwd, + isolationMode: candidate.metadata?.isolationMode, }; } if (candidate.metadata?.cwd && !(URI.isUri(candidate.metadata.cwd))) { @@ -522,11 +640,13 @@ export class ClaudeChatSessionItemController extends Disposable { candidate.metadata = { permissionMode: candidate.metadata.permissionMode, cwd: undefined, + isolationMode: candidate.metadata?.isolationMode, }; } return { permissionMode: candidate.metadata?.permissionMode, cwd: candidate.metadata?.cwd, + isolationMode: candidate.metadata?.isolationMode, }; } } 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 4a5dfc8221cf5..ede8ea6c07bc2 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 @@ -30,6 +30,7 @@ import { IClaudeCodeSessionService } from '../../claude/node/sessionParser/claud import { IClaudeCodeSessionInfo } from '../../claude/node/sessionParser/claudeSessionSchema'; import { IClaudeSlashCommandService } from '../../claude/vscode-node/claudeSlashCommandService'; import { FolderRepositoryMRUEntry, IFolderRepositoryManager } from '../../common/folderRepositoryManager'; +import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService'; import { ClaudeChatSessionContentProvider, ClaudeChatSessionItemController } from '../claudeChatSessionContentProvider'; // Expose the most recently created items map so tests can inspect controller items. @@ -71,6 +72,7 @@ beforeAll(() => { refreshHandler: () => Promise.resolve(), dispose: () => { }, onDidArchiveChatSessionItem: () => ({ dispose: () => { } }), + onDidChangeChatSessionItemState: Event.None, }; }, }; @@ -184,6 +186,23 @@ function createProviderWithServices( tryHandleCommand: vi.fn().mockResolvedValue({ handled: false }), getRegisteredCommands: vi.fn().mockReturnValue([]), }); + serviceCollection.define(IChatSessionWorktreeService, { + _serviceBrand: undefined, + createWorktree: vi.fn().mockResolvedValue(undefined), + getWorktreeProperties: vi.fn().mockResolvedValue(undefined), + setWorktreeProperties: vi.fn().mockResolvedValue(undefined), + getWorktreePath: vi.fn().mockResolvedValue(undefined), + getWorktreeRepository: vi.fn().mockResolvedValue(undefined), + applyWorktreeChanges: vi.fn().mockResolvedValue(undefined), + handleRequestCompleted: vi.fn().mockResolvedValue(undefined), + handleRequestCompletedForWorktree: vi.fn().mockResolvedValue(undefined), + getAdditionalWorktreeProperties: vi.fn().mockResolvedValue([]), + setAdditionalWorktreeProperties: vi.fn().mockResolvedValue(undefined), + cleanupWorktreeOnArchive: vi.fn().mockResolvedValue({ cleaned: false }), + recreateWorktreeOnUnarchive: vi.fn().mockResolvedValue({ recreated: false }), + getSessionIdForWorktree: vi.fn().mockResolvedValue(undefined), + getWorktreeChanges: vi.fn().mockResolvedValue(undefined), + }); serviceCollection.define(IClaudeCodeSdkService, { _serviceBrand: undefined, query: vi.fn(), From 8d635ae92ce6a9269eb84a3ad52925badb56163e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:37:10 +0000 Subject: [PATCH 2/4] Address PR review feedback: fix error logging, check cancelled/trusted, gate worktree commit on cancellation - Fix logService.error argument order in chatSessions.ts lifecycle hooks - _initializeWorktreeForNewSession now returns false when user cancels or denies trust, and createHandler aborts the request accordingly - Gate handleRequestCompleted on !token.isCancellationRequested so worktree changes are not committed for cancelled turns - Use logService.error with proper Error object instead of string interpolation Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/394a2e27-d88d-4b5c-a9ce-ea9463e5a5a8 Co-authored-by: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> --- .../chatSessions/vscode-node/chatSessions.ts | 4 +-- .../claudeChatSessionContentProvider.ts | 29 +++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 33ecfaa42b9be..b612c7cc69060 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -167,14 +167,14 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const result = await claudeWorktreeService.cleanupWorktreeOnArchive(sessionId); logService.trace(`[Claude] Worktree cleanup for session ${sessionId}: ${result.cleaned ? 'cleaned' : result.reason}`); } catch (error) { - logService.error(`[Claude] Failed to cleanup worktree for archived session ${sessionId}:`, error); + logService.error(error as Error, `[Claude] Failed to cleanup worktree for archived session ${sessionId}`); } } else { try { const result = await claudeWorktreeService.recreateWorktreeOnUnarchive(sessionId); logService.trace(`[Claude] Worktree recreation for session ${sessionId}: ${result.recreated ? 'recreated' : result.reason}`); } catch (error) { - logService.error(`[Claude] Failed to recreate worktree for unarchived session ${sessionId}:`, error); + logService.error(error as Error, `[Claude] Failed to recreate worktree for unarchived session ${sessionId}`); } } })); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index 1c51d04007e24..93dfb62cc864d 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -183,16 +183,19 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco /** * Initializes a worktree for a new Claude session if isolation mode is 'worktree'. * This must be called during request handling (where stream and toolInvocationToken are available). + * + * @returns `true` if the request should continue, `false` if it should abort + * (e.g., user cancelled the uncommitted-changes prompt or denied trust). */ private async _initializeWorktreeForNewSession( sessionId: string, stream: vscode.ChatResponseStream, toolInvocationToken: vscode.ChatParticipantToolToken, token: vscode.CancellationToken - ): Promise { + ): Promise { const isolationMode = this._controller.getMetadata(sessionId)?.isolationMode; if (isolationMode !== IsolationMode.Worktree) { - return; + return true; } const selectedFolder = this._controller.getMetadata(sessionId)?.cwd; @@ -210,10 +213,16 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco token ); + if (folderInfo.cancelled || folderInfo.trusted === false) { + return false; + } + if (folderInfo.worktreeProperties) { await this.worktreeService.setWorktreeProperties(sessionId, folderInfo.worktreeProperties); this.logService.info(`[Claude] Created worktree for session ${sessionId}: ${folderInfo.worktreeProperties.worktreePath}`); } + + return true; } // #region Folder Option Helpers @@ -298,8 +307,8 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco // Initialize worktree for new sessions with worktree isolation if (isNewSession) { - await this._initializeWorktreeForNewSession(effectiveSessionId, stream, request.toolInvocationToken, token); - if (token.isCancellationRequested) { + const shouldContinue = await this._initializeWorktreeForNewSession(effectiveSessionId, stream, request.toolInvocationToken, token); + if (token.isCancellationRequested || !shouldContinue) { return {}; } } @@ -323,11 +332,13 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco const result = await this.claudeAgentManager.handleRequest(effectiveSessionId, request, context, stream, token, isNewSession, yieldRequested); this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.Completed, prompt); - // Auto-commit worktree changes after the turn completes - try { - await this.worktreeService.handleRequestCompleted(effectiveSessionId); - } catch (error) { - this.logService.warn(`[Claude] Failed to handle worktree request completion for session ${effectiveSessionId}: ${error}`); + // Auto-commit worktree changes after successful, non-cancelled turns + if (!token.isCancellationRequested) { + try { + await this.worktreeService.handleRequestCompleted(effectiveSessionId); + } catch (error) { + this.logService.error(error instanceof Error ? error : new Error(String(error)), `[Claude] Failed to handle worktree request completion for session ${effectiveSessionId}`); + } } // Clear usage handler after request completes From 7b747c0e94892604b80642055874ecb4c6cc147f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:50:48 +0000 Subject: [PATCH 3/4] Update ClaudeChatSessionContentProvider tests for new input state API - Add ISessionOptionGroupBuilder mock to test service setup - Add getChatSessionInputState/createChatSessionInputState to mock controller - Remove tests for deleted provideChatSessionProviderOptions/provideHandleOptionsChange - Replace provideHandleOptionsChange calls with direct setMetadata calls - Remove tests checking provideChatSessionContent options (no longer returned) - Add permission mode metadata tests using the new controller.setMetadata API - Remove unused MockClaudeSession interface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> --- .../claudeChatSessionContentProvider.spec.ts | 344 ++---------------- 1 file changed, 37 insertions(+), 307 deletions(-) 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 ede8ea6c07bc2..3cb212ae4b40b 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 @@ -31,6 +31,7 @@ import { IClaudeCodeSessionInfo } from '../../claude/node/sessionParser/claudeSe import { IClaudeSlashCommandService } from '../../claude/vscode-node/claudeSlashCommandService'; import { FolderRepositoryMRUEntry, IFolderRepositoryManager } from '../../common/folderRepositoryManager'; import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService'; +import { ISessionOptionGroupBuilder } from '../sessionOptionGroupBuilder'; import { ClaudeChatSessionContentProvider, ClaudeChatSessionItemController } from '../claudeChatSessionContentProvider'; // Expose the most recently created items map so tests can inspect controller items. @@ -69,6 +70,8 @@ beforeAll(() => { label, }), set forkHandler(handler: typeof lastForkHandler) { lastForkHandler = handler; }, + getChatSessionInputState: undefined as any, + createChatSessionInputState: (groups: vscode.ChatSessionProviderOptionGroup[]) => ({ groups, onDidChange: Event.None }), refreshHandler: () => Promise.resolve(), dispose: () => { }, onDidArchiveChatSessionItem: () => ({ dispose: () => { } }), @@ -78,16 +81,6 @@ beforeAll(() => { }; }); -// Mock types for testing -interface MockClaudeSession { - id: string; - messages: Array<{ - type: 'user' | 'assistant'; - message: Record; - }>; - subagents: Array; -} - class MockFolderRepositoryManager implements IFolderRepositoryManager { declare _serviceBrand: undefined; @@ -203,6 +196,19 @@ function createProviderWithServices( getSessionIdForWorktree: vi.fn().mockResolvedValue(undefined), getWorktreeChanges: vi.fn().mockResolvedValue(undefined), }); + serviceCollection.define(ISessionOptionGroupBuilder, { + _serviceBrand: undefined, + lockInputStateGroups: vi.fn(), + updateBranchInInputState: vi.fn(), + provideChatSessionProviderOptionGroups: vi.fn().mockResolvedValue([]), + buildExistingSessionInputStateGroups: vi.fn().mockResolvedValue([]), + handleInputStateChange: vi.fn().mockResolvedValue(undefined), + rebuildInputState: vi.fn().mockResolvedValue(undefined), + setNewFolderForInputState: vi.fn(), + getBranchOptionItemsForRepository: vi.fn().mockResolvedValue([]), + getRepositoryOptionItems: vi.fn().mockReturnValue([]), + buildBranchOptionGroup: vi.fn().mockReturnValue(undefined), + }); serviceCollection.define(IClaudeCodeSdkService, { _serviceBrand: undefined, query: vi.fn(), @@ -260,76 +266,16 @@ describe('ChatSessionContentProvider', () => { // #endregion - // #region newSessionOptions - - describe('newSessionOptions in provideChatSessionProviderOptions', () => { - it('falls back to acceptEdits for permission mode in newSessionOptions', async () => { - const options = await provider.provideChatSessionProviderOptions(); - expect(options.newSessionOptions!['permissionMode']).toBe('acceptEdits'); - }); - - it('uses last-used permission mode in newSessionOptions', async () => { - // Change permission mode on an existing session - seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - await provider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'permissionMode', value: 'plan' }], - CancellationToken.None, - ); - - const options = await provider.provideChatSessionProviderOptions(); - expect(options.newSessionOptions!['permissionMode']).toBe('plan'); - }); - - it('does not include folder in newSessionOptions for single-root workspace', async () => { - const options = await provider.provideChatSessionProviderOptions(); - expect(options.newSessionOptions!['folder']).toBeUndefined(); - }); - }); - - describe('newSessionOptions in multi-root workspace', () => { - const folderA = URI.file('/project-a'); - const folderB = URI.file('/project-b'); - let multiRootProvider: ClaudeChatSessionContentProvider; - - beforeEach(() => { - const mocks = createDefaultMocks(); - - const result = createProviderWithServices(store, [folderA, folderB], mocks); - multiRootProvider = result.provider; - }); - - it('includes default folder in newSessionOptions for multi-root workspace', async () => { - const options = await multiRootProvider.provideChatSessionProviderOptions(); - expect(options.newSessionOptions).toBeDefined(); - expect(options.newSessionOptions!['folder']).toBe(folderA.fsPath); - }); - }); - // #endregion // #region Folder Option Tests describe('folder option - single-root workspace', () => { - it('does NOT include folder option group when single-root workspace', async () => { - const options = await provider.provideChatSessionProviderOptions(); - const folderGroup = options.optionGroups?.find(g => g.id === 'folder'); - expect(folderGroup).toBeUndefined(); - }); - it('getFolderInfoForSession returns the one workspace folder as cwd', async () => { const folderInfo = await provider.getFolderInfoForSession('test-session'); expect(folderInfo.cwd).toBe(workspaceFolderUri.fsPath); expect(folderInfo.additionalDirectories).toEqual([]); }); - - it('does NOT include folder in provideChatSessionContent options', async () => { - vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); - const sessionUri = createClaudeSessionUri('test-session'); - const result = await provider.provideChatSessionContent(sessionUri, CancellationToken.None); - expect(result.options?.['folder']).toBeUndefined(); - }); }); describe('folder option - multi-root workspace', () => { @@ -348,101 +294,20 @@ describe('ChatSessionContentProvider', () => { multiRootProvider = result.provider; }); - it('includes folder option group with all workspace folders', async () => { - const options = await multiRootProvider.provideChatSessionProviderOptions(); - const folderGroup = options.optionGroups?.find(g => g.id === 'folder'); - - expect(folderGroup).toBeDefined(); - expect(folderGroup!.items).toHaveLength(3); - expect(folderGroup!.items.map(i => i.id)).toEqual([ - folderA.fsPath, - folderB.fsPath, - folderC.fsPath, - ]); - }); - it('defaults cwd to first workspace folder when no selection made', async () => { const folderInfo = await multiRootProvider.getFolderInfoForSession('test-session'); expect(folderInfo.cwd).toBe(folderA.fsPath); expect(folderInfo.additionalDirectories).toEqual([folderB.fsPath, folderC.fsPath]); }); - it('uses selected folder as cwd after provideHandleOptionsChange', async () => { + it('uses selected folder as cwd after setMetadata', async () => { seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - await multiRootProvider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'folder', value: folderB.fsPath }], - CancellationToken.None, - ); + multiRootProvider.controller.setMetadata('test-session', { cwd: folderB }); const folderInfo = await multiRootProvider.getFolderInfoForSession('test-session'); expect(folderInfo.cwd).toBe(folderB.fsPath); expect(folderInfo.additionalDirectories).toEqual([folderA.fsPath, folderC.fsPath]); }); - - it('includes default folder in provideChatSessionContent options for new session', async () => { - vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); - const sessionUri = createClaudeSessionUri('test-session'); - const result = await multiRootProvider.provideChatSessionContent(sessionUri, CancellationToken.None); - - // Should include folder option as string (not locked) for new sessions - expect(result.options?.['folder']).toBe(folderA.fsPath); - }); - - it('locks folder option for existing sessions', async () => { - const session: MockClaudeSession = { - id: 'test-session', - messages: [{ - type: 'user', - message: { role: 'user', content: 'Hello' }, - }], - subagents: [], - }; - vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any); - - const sessionUri = createClaudeSessionUri('test-session'); - const result = await multiRootProvider.provideChatSessionContent(sessionUri, CancellationToken.None); - - const folderOption = result.options?.['folder']; - expect(folderOption).toBeDefined(); - expect(typeof folderOption).toBe('object'); - expect((folderOption as vscode.ChatSessionProviderOptionItem).locked).toBe(true); - }); - - it('locked folder option preserves the selected folder, not the first one', async () => { - // Simulate user selecting folder B before the session is created - seedSessionItem('pre-created-session'); - const sessionUri = createClaudeSessionUri('pre-created-session'); - await multiRootProvider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'folder', value: folderB.fsPath }], - CancellationToken.None, - ); - - // Verify the selection took effect - const folderInfo = await multiRootProvider.getFolderInfoForSession('pre-created-session'); - expect(folderInfo.cwd).toBe(folderB.fsPath); - - // Now load the same session as an existing session - const session: MockClaudeSession = { - id: 'pre-created-session', - messages: [{ - type: 'user', - message: { role: 'user', content: 'Hello' }, - }], - subagents: [], - }; - vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any); - - const result = await multiRootProvider.provideChatSessionContent(sessionUri, CancellationToken.None); - - const folderOption = result.options?.['folder'] as vscode.ChatSessionProviderOptionItem; - expect(folderOption).toBeDefined(); - expect(folderOption.locked).toBe(true); - // Should show folder B (the selected folder), not folder A (the first) - expect(folderOption.id).toBe(folderB.fsPath); - }); }); describe('folder option - empty workspace', () => { @@ -458,31 +323,6 @@ describe('ChatSessionContentProvider', () => { emptyWorkspaceProvider = result.provider; }); - it('includes folder option group with MRU entries', async () => { - const mruFolder = URI.file('/recent/project'); - const mruRepo = URI.file('/recent/repo'); - mockFolderRepositoryManager.setMRUEntries([ - { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, - { folder: mruRepo, repository: mruRepo, lastAccessed: Date.now() - 1000 }, - ]); - - const options = await emptyWorkspaceProvider.provideChatSessionProviderOptions(); - const folderGroup = options.optionGroups?.find(g => g.id === 'folder'); - - expect(folderGroup).toBeDefined(); - expect(folderGroup!.items).toHaveLength(2); - expect(folderGroup!.items[0].id).toBe(mruFolder.fsPath); - expect(folderGroup!.items[1].id).toBe(mruRepo.fsPath); - }); - - it('shows empty folder options when no MRU entries', async () => { - const options = await emptyWorkspaceProvider.provideChatSessionProviderOptions(); - const folderGroup = options.optionGroups?.find(g => g.id === 'folder'); - - expect(folderGroup).toBeDefined(); - expect(folderGroup!.items).toHaveLength(0); - }); - it('getFolderInfoForSession uses MRU fallback when no selection', async () => { const mruFolder = URI.file('/recent/project'); mockFolderRepositoryManager.setMRUEntries([ @@ -508,12 +348,7 @@ describe('ChatSessionContentProvider', () => { ]); seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - await emptyWorkspaceProvider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'folder', value: selectedFolder.fsPath }], - CancellationToken.None, - ); + emptyWorkspaceProvider.controller.setMetadata('test-session', { cwd: selectedFolder }); const folderInfo = await emptyWorkspaceProvider.getFolderInfoForSession('test-session'); expect(folderInfo.cwd).toBe(selectedFolder.fsPath); @@ -522,136 +357,42 @@ describe('ChatSessionContentProvider', () => { // #endregion - // #region Option Change Local Storage + // #region Permission Mode Metadata - describe('provideHandleOptionsChange stores locally without updating session state', () => { - it('stores permission mode selection locally and does not update session state service', async () => { - seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - const mockSessionStateService = accessor.get(IClaudeSessionStateService); - const setPermissionSpy = vi.spyOn(mockSessionStateService, 'setPermissionModeForSession'); - - await provider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'permissionMode', value: 'plan' }], - CancellationToken.None - ); - - // Session state service should NOT have been called - expect(setPermissionSpy).not.toHaveBeenCalled(); - - // But getPermissionModeForSession should return the local selection - const permissionMode = provider.getPermissionModeForSession('test-session'); - expect(permissionMode).toBe('plan'); + describe('permission mode metadata', () => { + it('getPermissionModeForSession returns permission mode from metadata', () => { + seedSessionItem('test-session', { permissionMode: 'plan' }); + expect(provider.getPermissionModeForSession('test-session')).toBe('plan'); }); - it('local permission mode selection is used in provideChatSessionContent', async () => { - vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); - + it('getPermissionModeForSession falls back to session state service', () => { seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - - // Set a local permission mode selection - await provider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'permissionMode', value: 'plan' }], - CancellationToken.None - ); - - const result = await provider.provideChatSessionContent(sessionUri, CancellationToken.None); - expect(result.options?.['permissionMode']).toBe('plan'); + const permissionMode = provider.getPermissionModeForSession('test-session'); + expect(permissionMode).toBe('acceptEdits'); }); - it('local permission mode selection takes priority over session state service', async () => { - seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); + it('metadata permission mode takes priority over session state service', () => { + seedSessionItem('test-session', { permissionMode: 'plan' }); - // Set a value in the session state service directly + // Set a different value in the session state service directly const mockSessionStateService = accessor.get(IClaudeSessionStateService); mockSessionStateService.setPermissionModeForSession('test-session', 'acceptEdits'); - // Now set a different local selection - await provider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'permissionMode', value: 'plan' }], - CancellationToken.None - ); - - // Local selection should take priority + // Metadata should take priority const permissionMode = provider.getPermissionModeForSession('test-session'); expect(permissionMode).toBe('plan'); }); - it('ignores invalid permission mode values in provideHandleOptionsChange', async () => { - seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - - await provider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'permissionMode', value: 'not-a-real-mode' }], - CancellationToken.None, - ); - - // Should fall through to session state service default, not store the invalid value - const permissionMode = provider.getPermissionModeForSession('test-session'); - expect(permissionMode).not.toBe('not-a-real-mode'); - }); - - it('ignores empty permission mode value in provideHandleOptionsChange', async () => { - seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - - await provider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'permissionMode', value: '' }], - CancellationToken.None, - ); - - // Should not store empty string as permission mode - const permissionMode = provider.getPermissionModeForSession('test-session'); - expect(permissionMode).not.toBe(''); - }); - - it('accepts all valid permission modes in provideHandleOptionsChange', async () => { + it('accepts all valid permission modes via metadata', () => { const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk'] as const; for (const mode of validModes) { - seedSessionItem(`test-session-${mode}`); - const sessionUri = createClaudeSessionUri(`test-session-${mode}`); - await provider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'permissionMode', value: mode }], - CancellationToken.None, - ); + seedSessionItem(`test-session-${mode}`, { permissionMode: mode }); const permissionMode = provider.getPermissionModeForSession(`test-session-${mode}`); expect(permissionMode).toBe(mode); } }); - - it('does not update _lastUsedPermissionMode when invalid mode is provided', async () => { - // First set a valid mode - seedSessionItem('session-valid'); - const sessionUri1 = createClaudeSessionUri('session-valid'); - await provider.provideHandleOptionsChange( - sessionUri1, - [{ optionId: 'permissionMode', value: 'plan' }], - CancellationToken.None, - ); - - // Try to set an invalid mode on a different session - seedSessionItem('session-invalid'); - const sessionUri2 = createClaudeSessionUri('session-invalid'); - await provider.provideHandleOptionsChange( - sessionUri2, - [{ optionId: 'permissionMode', value: 'bogus' }], - CancellationToken.None, - ); - - // newSessionOptions should still reflect the last valid mode - const options = await provider.provideChatSessionProviderOptions(); - expect(options.newSessionOptions!['permissionMode']).toBe('plan'); - }); }); // #endregion @@ -725,14 +466,8 @@ describe('ChatSessionContentProvider', () => { it('does not overwrite permission mode if already set for the session', async () => { vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); - // Pre-set permission mode via provideHandleOptionsChange - seedSessionItem('pre-set-session'); - const sessionUri = createClaudeSessionUri('pre-set-session'); - await handlerProvider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'permissionMode', value: 'default' }], - CancellationToken.None, - ); + // Pre-set permission mode via setMetadata + seedSessionItem('pre-set-session', { permissionMode: 'default' }); const handler = handlerProvider.createHandler(); const context = createChatContext('pre-set-session'); @@ -816,14 +551,9 @@ describe('ChatSessionContentProvider', () => { it('does not overwrite folder if already set for the session', async () => { vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); - // Pre-set folder via provideHandleOptionsChange + // Pre-set folder via setMetadata seedSessionItem('pre-folder-session'); - const sessionUri = createClaudeSessionUri('pre-folder-session'); - await multiRootProvider.provideHandleOptionsChange( - sessionUri, - [{ optionId: 'folder', value: folderA.fsPath }], - CancellationToken.None, - ); + multiRootProvider.controller.setMetadata('pre-folder-session', { cwd: folderA }); const handler = multiRootProvider.createHandler(); const context = createChatContext('pre-folder-session'); From 50ca54f5a134235930415a130ca3328aedf857e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:52:28 +0000 Subject: [PATCH 4/4] Migrate Claude to getChatSessionInputState API: add isolation locking and branch picker Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/3202b2b1-8170-46d0-91c3-002d952deb64 Co-authored-by: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> --- .../chatSessions/vscode-node/chatSessions.ts | 2 + .../claudeChatSessionContentProvider.ts | 365 +++++++----------- 2 files changed, 152 insertions(+), 215 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index b612c7cc69060..1944ef8c3ba03 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -146,6 +146,8 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib [IFolderRepositoryManager, new SyncDescriptor(ClaudeFolderRepositoryManager)], [IChatPromptFileService, new SyncDescriptor(ChatPromptFileService)], [IClaudeRuntimeDataService, new SyncDescriptor(ClaudeRuntimeDataService)], + [IChatFolderMruService, new SyncDescriptor(CopilotCLIFolderMruService)], + [ISessionOptionGroupBuilder, new SyncDescriptor(SessionOptionGroupBuilder)], )); const claudeAgentManager = this._register(claudeAgentInstaService.createInstance(ClaudeAgentManager)); const claudeModels = claudeAgentInstaService.invokeFunction(accessor => accessor.get(IClaudeCodeModels)); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index 93dfb62cc864d..2ef47eea22adf 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -15,7 +15,6 @@ import { IWorkspaceService } from '../../../platform/workspace/common/workspaceS import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; -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 { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo'; @@ -28,8 +27,9 @@ import { IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCo import { IClaudeCodeSessionInfo } from '../claude/node/sessionParser/claudeSessionSchema'; import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService'; import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; -import { FolderRepositoryMRUEntry, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager'; +import { IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager'; import { buildChatHistory } from './chatHistoryBuilder'; +import { getSelectedSessionOptions, ISessionOptionGroupBuilder } from './sessionOptionGroupBuilder'; const permissionModes: ReadonlySet = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk']); @@ -44,9 +44,6 @@ import '../claude/vscode-node/toolPermissionHandlers/index'; import '../claude/vscode-node/mcpServers/index'; const PERMISSION_MODE_OPTION_ID = 'permissionMode'; -const FOLDER_OPTION_ID = 'folder'; -const ISOLATION_MODE_OPTION_ID = 'isolation'; -const MAX_MRU_ENTRIES = 10; export class ClaudeChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider { private readonly _onDidChangeChatSessionOptions = this._register(new Emitter()); @@ -75,9 +72,10 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco @IClaudeSlashCommandService private readonly slashCommandService: IClaudeSlashCommandService, @IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager, @IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService, + @ISessionOptionGroupBuilder private readonly _optionGroupBuilder: ISessionOptionGroupBuilder, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @INativeEnvService private readonly envService: INativeEnvService, - @IGitService gitService: IGitService, + @IGitService private readonly gitService: IGitService, @IClaudeCodeSdkService sdkService: IClaudeCodeSdkService, @ILogService private readonly logService: ILogService, ) { @@ -96,6 +94,9 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco this._onDidChangeChatSessionProviderOptions.fire(); })); + // Wire up the new input state API (dynamic dropdowns with locking support) + this._initializeInputState(); + // Listen for state changes and notify UI only if value actually changed from local selection this._register(this.sessionStateService.onDidChangeSessionState(e => { const updates: { optionId: string; value: string }[] = []; @@ -111,6 +112,97 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco })); } + // #region Input State (dynamic dropdown management) + + /** + * Wires up `getChatSessionInputState` on the underlying controller, + * delegating to `SessionOptionGroupBuilder` for dynamic dropdown groups + * (isolation, repository/folder, branch). This replaces the old + * `provideChatSessionProviderOptions` approach with proper support for + * locking dropdowns on first message and dynamic branch selection. + */ + private _initializeInputState(): void { + const newInputStates: WeakRef[] = []; + const controller = this._controller.rawController; + + controller.getChatSessionInputState = async (sessionResource, context, token) => { + const isExistingSession = sessionResource && await this.sessionService.getSession(sessionResource, token); + if (isExistingSession) { + const groups = await this._optionGroupBuilder.buildExistingSessionInputStateGroups(sessionResource, token); + // Add permission mode group for existing sessions + this._addPermissionModeGroup(groups, sessionResource, true); + return controller.createChatSessionInputState(groups); + } else { + const groups = await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(context.previousInputState); + // Add permission mode group for new sessions + this._addPermissionModeGroup(groups, undefined, false); + const state = controller.createChatSessionInputState(groups); + // Only wire dynamic updates for new sessions (existing sessions are fully locked). + newInputStates.push(new WeakRef(state)); + state.onDidChange(() => { + void this._optionGroupBuilder.handleInputStateChange(state); + }); + return state; + } + }; + + // Refresh new-session dropdown groups when git or workspace state changes + const refreshActiveInputState = () => { + // Sweep stale WeakRefs before iterating + for (let i = newInputStates.length - 1; i >= 0; i--) { + if (!newInputStates[i].deref()) { + newInputStates.splice(i, 1); + } + } + for (const weakRef of newInputStates) { + const state = weakRef.deref(); + if (state) { + void this._optionGroupBuilder.rebuildInputState(state); + } + } + }; + this._register(this.gitService.onDidFinishInitialization(refreshActiveInputState)); + this._register(this.gitService.onDidOpenRepository(refreshActiveInputState)); + this._register(this.gitService.onDidCloseRepository(refreshActiveInputState)); + this._register(this.workspaceService.onDidChangeWorkspaceFolders(refreshActiveInputState)); + } + + /** + * Add the permission mode option group to an existing set of groups. + * Permission mode is Claude-specific and not managed by SessionOptionGroupBuilder. + */ + private _addPermissionModeGroup(groups: vscode.ChatSessionProviderOptionGroup[], sessionResource: vscode.Uri | undefined, locked: boolean): void { + const permissionModeItems: vscode.ChatSessionProviderOptionItem[] = [ + { id: 'default', name: l10n.t('Ask before edits'), icon: new vscode.ThemeIcon('shield') }, + { id: 'acceptEdits', name: l10n.t('Edit automatically'), icon: new vscode.ThemeIcon('edit') }, + { id: 'plan', name: l10n.t('Plan mode'), icon: new vscode.ThemeIcon('lightbulb') }, + ]; + if (this.configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions)) { + permissionModeItems.push({ id: 'bypassPermissions', name: l10n.t('Bypass all permissions'), icon: new vscode.ThemeIcon('warning') }); + } + + let selectedMode: string; + if (sessionResource) { + const sessionId = ClaudeSessionUri.getSessionId(sessionResource); + selectedMode = this.getPermissionModeForSession(sessionId); + } else { + selectedMode = this._lastUsedPermissionMode; + } + + const selectedItem = permissionModeItems.find(item => item.id === selectedMode) ?? permissionModeItems[0]; + const selected = locked ? { ...selectedItem, locked: true } : selectedItem; + + groups.unshift({ + id: PERMISSION_MODE_OPTION_ID, + name: l10n.t('Permission Mode'), + description: l10n.t('Pick Permission Mode'), + items: locked ? permissionModeItems.map(item => ({ ...item, locked: true })) : permissionModeItems, + selected, + }); + } + + // #endregion + /** * Gets the permission mode for a session */ @@ -225,56 +317,6 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco return true; } - // #region Folder Option Helpers - - private _isEmptyWorkspace(): boolean { - return this.workspaceService.getWorkspaceFolders().length === 0; - } - - private async _getFolderOptionItems(): Promise { - const workspaceFolders = this.workspaceService.getWorkspaceFolders(); - - if (this._isEmptyWorkspace()) { - const mruEntries = await this.folderRepositoryManager.getFolderMRU(); - return mruToFolderOptionItems(mruEntries).slice(0, MAX_MRU_ENTRIES); - } - - return workspaceFolders.map(folder => ({ - id: folder.fsPath, - name: this.workspaceService.getWorkspaceFolderName(folder), - icon: new vscode.ThemeIcon('folder'), - })); - } - - private async _getDefaultFolderForSession(sessionId: string): Promise { - // Check in-memory selection first - const selected = this._controller.getMetadata(sessionId)?.cwd; - if (selected) { - return selected; - } - - const defaultFolder = await this._getDefaultFolder(); - if (defaultFolder) { - this._controller.setMetadata(sessionId, { cwd: defaultFolder }); - } - return defaultFolder; - } - - private async _getDefaultFolder(): Promise { - const workspaceFolders = this.workspaceService.getWorkspaceFolders(); - if (workspaceFolders.length > 0) { - return workspaceFolders[0]; - } - - const mru = await this.folderRepositoryManager.getFolderMRU(); - if (mru.length > 0) { - return mru[0].folder; - } - - // No suitable default folder found - return undefined; - } - // #endregion // #region Chat Participant Handler @@ -305,14 +347,47 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco const existingSession = await this.sessionService.getSession(sessionUri, token); const isNewSession = !existingSession; + // Lock all dropdown groups on first message (prevents changing isolation/branch/folder mid-session) + if (isNewSession) { + this._optionGroupBuilder.lockInputStateGroups(chatSessionContext.inputState); + } + + // Read selected options from input state (new API) + const selectedOptions = getSelectedSessionOptions(chatSessionContext.inputState); + const selectedPermissionMode = this._getPermissionModeFromInputState(chatSessionContext.inputState); + + // Store selected options in metadata for the session + if (isNewSession) { + const isolationMode = selectedOptions.isolation ?? IsolationMode.Worktree; + const folder = selectedOptions.folder; + this._controller.setMetadata(effectiveSessionId, { + isolationMode, + cwd: folder, + permissionMode: selectedPermissionMode, + }); + if (selectedPermissionMode) { + this._lastUsedPermissionMode = selectedPermissionMode; + } + } + // Initialize worktree for new sessions with worktree isolation if (isNewSession) { const shouldContinue = await this._initializeWorktreeForNewSession(effectiveSessionId, stream, request.toolInvocationToken, token); if (token.isCancellationRequested || !shouldContinue) { + // Unlock dropdowns so the user can adjust and retry + await this._optionGroupBuilder.rebuildInputState(chatSessionContext.inputState); return {}; } } + // Update branch in input state after worktree creation + if (isNewSession) { + const worktreeProperties = await this.worktreeService.getWorktreeProperties(effectiveSessionId); + if (worktreeProperties?.branchName) { + this._optionGroupBuilder.updateBranchInInputState(chatSessionContext.inputState, worktreeProperties.branchName); + } + } + const modelId = parseClaudeModelId(request.model.id); const permissionMode = this.getPermissionModeForSession(effectiveSessionId); const folderInfo = await this.getFolderInfoForSession(effectiveSessionId); @@ -348,168 +423,36 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco }; } - // #endregion - - async provideChatSessionProviderOptions(): Promise { - const permissionModeItems: vscode.ChatSessionProviderOptionItem[] = [ - { id: 'default', name: l10n.t('Ask before edits') }, - { id: 'acceptEdits', name: l10n.t('Edit automatically') }, - { id: 'plan', name: l10n.t('Plan mode') }, - ]; - - // Add bypass permissions option if enabled via setting - if (this.configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions)) { - permissionModeItems.push({ id: 'bypassPermissions', name: l10n.t('Bypass all permissions') }); - } - - const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [ - { - id: PERMISSION_MODE_OPTION_ID, - name: l10n.t('Permission Mode'), - description: l10n.t('Pick Permission Mode'), - items: permissionModeItems, - }, - { - id: ISOLATION_MODE_OPTION_ID, - name: l10n.t('Isolation'), - description: l10n.t('Pick Isolation Mode'), - items: [ - { id: IsolationMode.Worktree, name: l10n.t('Worktree') }, - { id: IsolationMode.Workspace, name: l10n.t('Workspace') }, - ], - } - ]; - - // Add folder option based on workspace type: - // - Single-root (1 folder): no folder option (implicit) - // - Multi-root (2+ folders): show workspace folders - // - Empty workspace (0 folders): show MRU folders + browse command - const workspaceFolders = this.workspaceService.getWorkspaceFolders(); - if (workspaceFolders.length !== 1) { - const folderItems = await this._getFolderOptionItems(); - const folderGroup: vscode.ChatSessionProviderOptionGroup = { - id: FOLDER_OPTION_ID, - name: l10n.t('Folder'), - description: l10n.t('Pick Folder'), - items: folderItems, - }; - optionGroups.unshift(folderGroup); - } - - return { optionGroups, newSessionOptions: await this._getNewSessionOptions(workspaceFolders) }; - } - - private async _getNewSessionOptions(workspaceFolders: readonly URI[]): Promise> { - const newSessionOptions: Record = {}; - - newSessionOptions[PERMISSION_MODE_OPTION_ID] = this._lastUsedPermissionMode; - newSessionOptions[ISOLATION_MODE_OPTION_ID] = IsolationMode.Worktree; - - if (workspaceFolders.length !== 1) { - const defaultFolder = await this._getDefaultFolder(); - if (defaultFolder) { - newSessionOptions[FOLDER_OPTION_ID] = defaultFolder.fsPath; - } + /** + * Read the selected permission mode from the input state groups. + */ + private _getPermissionModeFromInputState(inputState: vscode.ChatSessionInputState): PermissionMode | undefined { + const group = inputState.groups.find(g => g.id === PERMISSION_MODE_OPTION_ID); + const selectedId = group?.selected?.id; + if (selectedId && isPermissionMode(selectedId)) { + return selectedId; } - - return newSessionOptions; + return undefined; } - async provideHandleOptionsChange(resource: vscode.Uri, updates: ReadonlyArray, _token: vscode.CancellationToken): Promise { - const sessionId = ClaudeSessionUri.getSessionId(resource); - let hadUpdate = false; - for (const update of updates) { - if (update.optionId === PERMISSION_MODE_OPTION_ID) { - if (!update.value || !isPermissionMode(update.value)) { - continue; - } - // Store locally; committed to session state service when handling the next request - this._controller.setMetadata(sessionId, { permissionMode: update.value }); - this._lastUsedPermissionMode = update.value; - hadUpdate = true; - } else if (update.optionId === FOLDER_OPTION_ID && typeof update.value === 'string') { - this._controller.setMetadata(sessionId, { cwd: URI.file(update.value) }); - hadUpdate = true; - } else if (update.optionId === ISOLATION_MODE_OPTION_ID && typeof update.value === 'string') { - const isolationMode = update.value === IsolationMode.Worktree ? IsolationMode.Worktree : IsolationMode.Workspace; - this._controller.setMetadata(sessionId, { isolationMode }); - hadUpdate = true; - } - } - if (hadUpdate) { - this._onDidChangeChatSessionProviderOptions.fire(); - } - } + // #endregion async provideChatSessionContent(sessionResource: vscode.Uri, token: vscode.CancellationToken): Promise { - const sessionId = ClaudeSessionUri.getSessionId(sessionResource); const existingSession = await this.sessionService.getSession(sessionResource, token); const history = existingSession ? buildChatHistory(existingSession) : []; - const permissionMode = this.getPermissionModeForSession(sessionId); - - const options: Record = {}; - options[PERMISSION_MODE_OPTION_ID] = permissionMode; - - // For existing sessions with worktree, lock the isolation option - const worktreeProperties = await this.worktreeService.getWorktreeProperties(sessionId); - if (existingSession && worktreeProperties) { - options[ISOLATION_MODE_OPTION_ID] = { - id: IsolationMode.Worktree, - name: l10n.t('Worktree'), - locked: true, - }; - } else if (existingSession) { - options[ISOLATION_MODE_OPTION_ID] = { - id: IsolationMode.Workspace, - name: l10n.t('Workspace'), - locked: true, - }; - } else { - options[ISOLATION_MODE_OPTION_ID] = IsolationMode.Worktree; - } - - // Include folder option if applicable (multi-root or empty workspace) - const workspaceFolders = this.workspaceService.getWorkspaceFolders(); - if (workspaceFolders.length !== 1) { - const defaultFolder = await this._getDefaultFolderForSession(sessionId); - if (defaultFolder) { - // For existing sessions, lock the folder option - if (existingSession) { - options[FOLDER_OPTION_ID] = { - id: defaultFolder.fsPath, - name: this.workspaceService.getWorkspaceFolderName(defaultFolder) - || basename(defaultFolder), - icon: new vscode.ThemeIcon('folder'), - locked: true, - }; - } else { - options[FOLDER_OPTION_ID] = defaultFolder.fsPath; - } - } - } - return { title: existingSession?.label, history, activeResponseCallback: undefined, requestHandler: undefined, - options, }; } } -function mruToFolderOptionItems(mruItems: readonly FolderRepositoryMRUEntry[]): vscode.ChatSessionProviderOptionItem[] { - return mruItems.map(item => ({ - id: item.folder.fsPath, - name: basename(item.folder), - icon: new vscode.ThemeIcon(item.repository ? 'repo' : 'folder'), - })); -} - /** * Chat session item controller wrapper for Claude Agent. * Reads sessions from ~/.claude/projects//, where each file name is a session id (GUID). @@ -526,6 +469,13 @@ export class ClaudeChatSessionItemController extends Disposable { return this._controller.onDidChangeChatSessionItemState; } + /** + * Exposes the underlying controller for input state API wiring. + */ + get rawController(): vscode.ChatSessionItemController { + return this._controller; + } + constructor( @IClaudeCodeSessionService private readonly _claudeCodeSessionService: IClaudeCodeSessionService, @IWorkspaceService private readonly _workspaceService: IWorkspaceService, @@ -548,23 +498,8 @@ export class ClaudeChatSessionItemController extends Disposable { ); item.iconPath = new vscode.ThemeIcon('claude'); item.timing = { created: Date.now() }; - const permissionModeOptionValue = context.sessionOptions?.find(o => o.optionId === PERMISSION_MODE_OPTION_ID)?.value; - const permissionMode = typeof permissionModeOptionValue === 'string' ? permissionModeOptionValue : permissionModeOptionValue?.id; - const folderOptionValue = context.sessionOptions?.find(o => o.optionId === FOLDER_OPTION_ID)?.value; - const folder = typeof folderOptionValue === 'string' - ? URI.file(folderOptionValue) - : folderOptionValue?.id - ? URI.file(folderOptionValue.id) - : undefined; - const isolationOptionValue = context.sessionOptions?.find(o => o.optionId === ISOLATION_MODE_OPTION_ID)?.value; - const isolationMode = (typeof isolationOptionValue === 'string' ? isolationOptionValue : isolationOptionValue?.id) === IsolationMode.Worktree - ? IsolationMode.Worktree - : IsolationMode.Workspace; - item.metadata = { - permissionMode, - cwd: folder, - isolationMode, - }; + // Metadata (permissionMode, cwd, isolationMode) is set by the request handler + // when it reads from inputState — no need to read from sessionOptions here. this._controller.items.add(item); return item; };