Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(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(error as Error, `[Claude] Failed to recreate worktree for unarchived session ${sessionId}`);
}
}
}));

// #endregion

// #endregion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set<PermissionMode>(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk']);
Expand All @@ -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 {
Expand All @@ -57,18 +59,27 @@ 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,
@IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService,
@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));
Expand Down Expand Up @@ -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<ClaudeFolderInfo> {
// 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) {
Expand Down Expand Up @@ -159,6 +180,51 @@ 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<boolean> {
const isolationMode = this._controller.getMetadata(sessionId)?.isolationMode;
if (isolationMode !== IsolationMode.Worktree) {
return true;
}

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,
Comment on lines +205 to +209
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initializeFolderRepository can return { cancelled: true } (user canceled uncommitted-changes prompt) or trusted: false (trust denied). The current implementation ignores those signals and continues the request, which can run the Claude session in the wrong folder/workspace even though the user explicitly cancelled/denied trust. Consider checking folderInfo.cancelled / folderInfo.trusted === false here and propagating an abort signal back to createHandler() (e.g., return a boolean) so the request returns early without invoking the agent manager.

This issue also appears on line 299 of the same file.

Copilot uses AI. Check for mistakes.
isolation: IsolationMode.Worktree,
folder,
},
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

private _isEmptyWorkspace(): boolean {
Expand Down Expand Up @@ -239,6 +305,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) {
const shouldContinue = await this._initializeWorktreeForNewSession(effectiveSessionId, stream, request.toolInvocationToken, token);
if (token.isCancellationRequested || !shouldContinue) {
return {};
}
}

const modelId = parseClaudeModelId(request.model.id);
const permissionMode = this.getPermissionModeForSession(effectiveSessionId);
const folderInfo = await this.getFolderInfoForSession(effectiveSessionId);
Expand All @@ -258,6 +332,15 @@ 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 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
this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, undefined);

Expand Down Expand Up @@ -285,6 +368,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') },
],
}
];

Expand All @@ -311,6 +403,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
const newSessionOptions: Record<string, string | vscode.ChatSessionProviderOptionItem> = {};

newSessionOptions[PERMISSION_MODE_OPTION_ID] = this._lastUsedPermissionMode;
newSessionOptions[ISOLATION_MODE_OPTION_ID] = IsolationMode.Worktree;

if (workspaceFolders.length !== 1) {
const defaultFolder = await this._getDefaultFolder();
Expand All @@ -337,6 +430,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) {
Expand All @@ -356,6 +453,24 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
const options: Record<string, string | vscode.ChatSessionProviderOptionItem> = {};
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) {
Expand Down Expand Up @@ -404,6 +519,13 @@ export class ClaudeChatSessionItemController extends Disposable {
private readonly _inProgressItems = new Map<string, vscode.ChatSessionItem>();
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,
Expand Down Expand Up @@ -434,9 +556,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;
Expand Down Expand Up @@ -496,37 +623,41 @@ 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)) {
this._logService.warn(`Invalid permission mode "${candidate.metadata?.permissionMode}" found in metadata for session ${sessionId}. Falling back to default.`);
candidate.metadata = {
permissionMode: 'acceptEdits',
cwd: candidate.metadata?.cwd,
isolationMode: candidate.metadata?.isolationMode,
};
}
if (candidate.metadata?.cwd && !(URI.isUri(candidate.metadata.cwd))) {
this._logService.warn(`Invalid cwd "${candidate.metadata.cwd}" found in metadata for session ${sessionId}. Ignoring.`);
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,
};
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -71,6 +72,7 @@ beforeAll(() => {
refreshHandler: () => Promise.resolve(),
dispose: () => { },
onDidArchiveChatSessionItem: () => ({ dispose: () => { } }),
onDidChangeChatSessionItemState: Event.None,
};
},
};
Expand Down Expand Up @@ -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(),
Expand Down