diff --git a/package.json b/package.json index 4ae605a3d..15d86e0de 100644 --- a/package.json +++ b/package.json @@ -1800,6 +1800,11 @@ "icon": "$(refresh)", "category": "Claude Code" }, + { + "command": "github.copilot.claude.sessions.create.worktree", + "title": "%github.copilot.command.claude.session.create.worktree%", + "category": "Claude Code" + }, { "command": "github.copilot.chat.replay", "title": "Start Chat Replay", @@ -3644,6 +3649,12 @@ "group": "z_chat@1", "when": "config.git.enabled && !git.missing && !isInDiffEditor && !isMergeEditor && resource in git.mergeChanges && git.activeResourceHasMergeConflicts" } + ], + "chat/chatSessions": [ + { + "command": "github.copilot.claude.sessions.create.worktree", + "group": "submenu" + } ] }, "icons": { diff --git a/package.nls.json b/package.nls.json index da2b64ce7..615da9f96 100644 --- a/package.nls.json +++ b/package.nls.json @@ -31,6 +31,7 @@ "github.copilot.command.sendChatFeedback": "Send Chat Feedback", "github.copilot.command.buildLocalWorkspaceIndex": "Build Local Workspace Index", "github.copilot.command.buildRemoteWorkspaceIndex": "Build Remote Workspace Index", + "github.copilot.command.claude.session.create.worktree": "Create Claude Code Session in a Worktree", "github.copilot.viewsWelcome.signIn": { "message": "Sign in to enable features powered by GitHub Copilot.\n\n[Sign in](command:workbench.action.chat.triggerSetupForceSignIn)", "comment": [ diff --git a/src/extension/agents/claude/node/claudeCodeAgent.ts b/src/extension/agents/claude/node/claudeCodeAgent.ts index 0dbeddb26..5db391648 100644 --- a/src/extension/agents/claude/node/claudeCodeAgent.ts +++ b/src/extension/agents/claude/node/claudeCodeAgent.ts @@ -60,7 +60,7 @@ export class ClaudeAgentManager extends Disposable { session = this._sessions.get(claudeSessionId)!; } else { this.logService.trace(`[ClaudeAgentManager] Creating Claude session for sessionId=${sessionIdForLog}.`); - const newSession = this.instantiationService.createInstance(ClaudeCodeSession, serverConfig, claudeSessionId); + const newSession = this.instantiationService.createInstance(ClaudeCodeSession, serverConfig, claudeSessionId, _context.chatSessionContext?.chatSessionItem.metadata); if (newSession.sessionId) { this._sessions.set(newSession.sessionId, newSession); } @@ -157,13 +157,14 @@ export class ClaudeCodeSession extends Disposable { constructor( private readonly serverConfig: ILanguageModelServerConfig, public sessionId: string | undefined, + private readonly metadata: Record | undefined, @ILogService private readonly logService: ILogService, @IConfigurationService private readonly configService: IConfigurationService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IEnvService private readonly envService: IEnvService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IToolsService private readonly toolsService: IToolsService, - @IClaudeCodeSdkService private readonly claudeCodeService: IClaudeCodeSdkService + @IClaudeCodeSdkService private readonly claudeCodeService: IClaudeCodeSdkService, ) { super(); } @@ -239,7 +240,7 @@ export class ClaudeCodeSession extends Disposable { this.logService.trace(`appRoot: ${this.envService.appRoot}`); const pathSep = isWindows ? ';' : ':'; const options: Options = { - cwd: this.workspaceService.getWorkspaceFolders().at(0)?.fsPath, + cwd: this.metadata?.workingDirectory || this.workspaceService.getWorkspaceFolders().at(0)?.fsPath, abortController: this._abortController, executable: process.execPath as 'node', // get it to fork the EH node process env: { diff --git a/src/extension/agents/claude/node/claudeCodeSessionService.ts b/src/extension/agents/claude/node/claudeCodeSessionService.ts index a8ffd4083..0267639ab 100644 --- a/src/extension/agents/claude/node/claudeCodeSessionService.ts +++ b/src/extension/agents/claude/node/claudeCodeSessionService.ts @@ -34,12 +34,17 @@ type StoredSDKMessage = SDKMessage & { readonly timestamp: Date; } +type ClaudeSessionURI = URI & { + isAdditionalDirectory: boolean; +} + export const IClaudeCodeSessionService = createServiceIdentifier('IClaudeCodeSessionService'); export interface IClaudeCodeSessionService { readonly _serviceBrand: undefined; - getAllSessions(token: CancellationToken): Promise; + getAllSessions(token: CancellationToken): Promise; getSession(sessionId: string, token: CancellationToken): Promise; + getSessionsLocationPaths(): Promise; } export class ClaudeCodeSessionService implements IClaudeCodeSessionService { @@ -56,6 +61,34 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { @INativeEnvService private readonly _nativeEnvService: INativeEnvService ) { } + async getSessionsLocationPaths(): Promise { + const folders = this._workspace.getWorkspaceFolders(); + const result: ClaudeSessionURI[] = []; + + for (const folderUri of folders) { + const slug = this._computeFolderSlug(folderUri); + const projectsDir = URI.joinPath(this._nativeEnvService.userHome, '.claude', 'projects'); + try { + const entries = await this._fileSystem.readDirectory(projectsDir); + const matchingDirs = entries + .filter(([name, type]: [string, FileType]) => type === FileType.Directory && name.startsWith(slug)) + .map(([name]: [string, FileType]) => { + const uri = URI.joinPath(projectsDir, name); + return Object.assign( + uri, + { isAdditionalDirectory: uri.fsPath !== URI.joinPath(this._nativeEnvService.userHome, '.claude', 'projects', slug).fsPath } + ); + }); + + result.push(...matchingDirs); + } catch (e) { + this._logService.warn(`Failed to read .claude/projects directory: ${e}`); + result.push(Object.assign(URI.joinPath(projectsDir, slug), { isAdditionalDirectory: false })); + } + } + return result; + } + /** * Collect messages from all sessions in all workspace folders. * - Read all .jsonl files in the .claude/projects/ dir @@ -64,29 +97,26 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { * - Build message chains from leaf nodes * - These are the complete "sessions" that can be resumed */ - async getAllSessions(token: CancellationToken): Promise { - const folders = this._workspace.getWorkspaceFolders(); - const items: IClaudeCodeSession[] = []; + async getAllSessions(token: CancellationToken): Promise { + const folders = await this.getSessionsLocationPaths(); + const items: (IClaudeCodeSession & { workingDirectory?: string })[] = []; for (const folderUri of folders) { if (token.isCancellationRequested) { return items; } - const slug = this._computeFolderSlug(folderUri); - const projectDirUri = URI.joinPath(this._nativeEnvService.userHome, '.claude', 'projects', slug); - // Check if we can use cached data - const cachedSessions = await this._getCachedSessionsIfValid(projectDirUri, token); + const cachedSessions = await this._getCachedSessionsIfValid(folderUri, token); if (cachedSessions) { - items.push(...cachedSessions); + items.push(...cachedSessions.map(session => Object.assign(session, { workingDirectory: folderUri.isAdditionalDirectory ? folderUri.fsPath : undefined }))); continue; } // Cache miss or invalid - reload from disk - const freshSessions = await this._loadSessionsFromDisk(projectDirUri, token); - this._sessionCache.set(projectDirUri, freshSessions); - items.push(...freshSessions); + const freshSessions = await this._loadSessionsFromDisk(folderUri, token); + this._sessionCache.set(folderUri, freshSessions); + items.push(...freshSessions.map(session => Object.assign(session, { workingDirectory: folderUri.isAdditionalDirectory ? folderUri.fsPath : undefined }))); } return items; @@ -158,7 +188,7 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { /** * Load sessions from disk and update file modification time tracking */ - private async _loadSessionsFromDisk(projectDirUri: URI, token: CancellationToken): Promise { + private async _loadSessionsFromDisk(projectDirUri: ClaudeSessionURI, token: CancellationToken): Promise { let entries: [string, FileType][] = []; try { entries = await this._fileSystem.readDirectory(projectDirUri); @@ -167,7 +197,7 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { return []; } - const fileTasks: Promise<{ messages: Map; summaries: Map; fileUri: URI }>[] = []; + const fileTasks: Promise<{ messages: Map; summaries: Map; fileUri: ClaudeSessionURI }>[] = []; for (const [name, type] of entries) { if (type !== FileType.File) { continue; @@ -183,7 +213,7 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { } const fileUri = URI.joinPath(projectDirUri, name); - fileTasks.push(this._getMessagesFromSessionWithUri(fileUri, token)); + fileTasks.push(this._getMessagesFromSessionWithUri(Object.assign(fileUri, { isAdditionalDirectory: projectDirUri.isAdditionalDirectory }), token)); } const results = await Promise.allSettled(fileTasks); @@ -283,7 +313,7 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { /** * Wrapper for _getMessagesFromSession that includes the fileUri in the result */ - private async _getMessagesFromSessionWithUri(fileUri: URI, token: CancellationToken): Promise<{ messages: Map; summaries: Map; fileUri: URI }> { + private async _getMessagesFromSessionWithUri(fileUri: ClaudeSessionURI, token: CancellationToken): Promise<{ messages: Map; summaries: Map; fileUri: ClaudeSessionURI }> { const result = await this._getMessagesFromSession(fileUri, token); return { ...result, fileUri }; } diff --git a/src/extension/agents/claude/node/test/claudeCodeAgent.spec.ts b/src/extension/agents/claude/node/test/claudeCodeAgent.spec.ts index 6ac3eddb3..bba34e0cd 100644 --- a/src/extension/agents/claude/node/test/claudeCodeAgent.spec.ts +++ b/src/extension/agents/claude/node/test/claudeCodeAgent.spec.ts @@ -80,7 +80,7 @@ describe('ClaudeCodeSession', () => { it('processes a single request correctly', async () => { const serverConfig = { port: 8080, nonce: 'test-nonce' }; - const session = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'test-session')); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'test-session', undefined)); const stream = new MockChatResponseStream(); await session.invoke('Hello', {} as vscode.ChatParticipantToolToken, stream, CancellationToken.None); @@ -90,7 +90,7 @@ describe('ClaudeCodeSession', () => { it('queues multiple requests and processes them sequentially', async () => { const serverConfig = { port: 8080, nonce: 'test-nonce' }; - const session = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'test-session')); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'test-session', undefined)); const stream1 = new MockChatResponseStream(); const stream2 = new MockChatResponseStream(); @@ -109,7 +109,7 @@ describe('ClaudeCodeSession', () => { it('cancels pending requests when cancelled', async () => { const serverConfig = { port: 8080, nonce: 'test-nonce' }; - const session = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'test-session')); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'test-session', undefined)); const stream = new MockChatResponseStream(); const source = new CancellationTokenSource(); source.cancel(); @@ -119,7 +119,7 @@ describe('ClaudeCodeSession', () => { it('cleans up resources when disposed', async () => { const serverConfig = { port: 8080, nonce: 'test-nonce' }; - const session = instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'test-session'); + const session = instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'test-session', undefined); // Dispose the session immediately session.dispose(); @@ -132,8 +132,8 @@ describe('ClaudeCodeSession', () => { it('handles multiple sessions with different session IDs', async () => { const serverConfig = { port: 8080, nonce: 'test-nonce' }; - const session1 = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'session-1')); - const session2 = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'session-2')); + const session1 = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'session-1', undefined)); + const session2 = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, 'session-2', undefined)); expect(session1.sessionId).toBe('session-1'); expect(session2.sessionId).toBe('session-2'); diff --git a/src/extension/chatSessions/vscode-node/chatSessions.ts b/src/extension/chatSessions/vscode-node/chatSessions.ts index dbb9bf443..f2bee71eb 100644 --- a/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -37,6 +37,14 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib this._register(vscode.commands.registerCommand('github.copilot.claude.sessions.refresh', () => { sessionItemProvider.refresh(); })); + this._register(vscode.commands.registerCommand('github.copilot.claude.sessions.create.worktree', async () => { + const workingDirectory = await vscode.commands.executeCommand('git.createWorktreeWithDefaults'); + const metadata: Record = {}; + if (workingDirectory) { + metadata.workingDirectory = workingDirectory; + } + await vscode.commands.executeCommand('workbench.action.chat.openNewSessionEditorWithMetadata', this.sessionType, metadata); + })); const claudeAgentManager = this._register(claudeAgentInstaService.createInstance(ClaudeAgentManager)); const chatSessionContentProvider = claudeAgentInstaService.createInstance(ClaudeChatSessionContentProvider); @@ -56,7 +64,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const claudeSessionId = await create(); if (claudeSessionId) { // Tell UI to replace with claude-backed session - sessionItemProvider.swap(chatSessionContext.chatSessionItem, { id: claudeSessionId, label: request.prompt ?? 'Claude Code' }); + sessionItemProvider.swap(chatSessionContext.chatSessionItem, { id: claudeSessionId, label: request.prompt ?? 'Claude Code', metadata: context.chatSessionContext?.chatSessionItem.metadata }); } return {}; } diff --git a/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts b/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts index 9f9267d4f..41eed70c4 100644 --- a/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts +++ b/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts @@ -50,7 +50,11 @@ export class ClaudeChatSessionItemProvider extends Disposable implements vscode. timing: { startTime: session.timestamp.getTime() }, - iconPath: new vscode.ThemeIcon('star-add') + iconPath: new vscode.ThemeIcon('star-add'), + metadata: session.workingDirectory ? { + workingDirectory: session.workingDirectory + } : undefined + } satisfies vscode.ChatSessionItem)); // return [...newSessions, ...diskSessions]; diff --git a/src/extension/vscode.proposed.chatSessionsProvider.d.ts b/src/extension/vscode.proposed.chatSessionsProvider.d.ts index 05b2b054a..cf9c87cae 100644 --- a/src/extension/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/extension/vscode.proposed.chatSessionsProvider.d.ts @@ -127,6 +127,11 @@ declare module 'vscode' { */ deletions: number; }; + + /** + * Optional metadata associated to the chat session item + */ + metadata?: Record; } export interface ChatSession {