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
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
7 changes: 4 additions & 3 deletions src/extension/agents/claude/node/claudeCodeAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -157,13 +157,14 @@ export class ClaudeCodeSession extends Disposable {
constructor(
private readonly serverConfig: ILanguageModelServerConfig,
public sessionId: string | undefined,
private readonly metadata: Record<string, string> | 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();
}
Expand Down Expand Up @@ -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: {
Expand Down
62 changes: 46 additions & 16 deletions src/extension/agents/claude/node/claudeCodeSessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,17 @@ type StoredSDKMessage = SDKMessage & {
readonly timestamp: Date;
}

type ClaudeSessionURI = URI & {
isAdditionalDirectory: boolean;
}

export const IClaudeCodeSessionService = createServiceIdentifier<IClaudeCodeSessionService>('IClaudeCodeSessionService');

export interface IClaudeCodeSessionService {
readonly _serviceBrand: undefined;
getAllSessions(token: CancellationToken): Promise<readonly IClaudeCodeSession[]>;
getAllSessions(token: CancellationToken): Promise<readonly (IClaudeCodeSession & { workingDirectory?: string })[]>;
getSession(sessionId: string, token: CancellationToken): Promise<IClaudeCodeSession | undefined>;
getSessionsLocationPaths(): Promise<URI[]>;
}

export class ClaudeCodeSessionService implements IClaudeCodeSessionService {
Expand All @@ -56,6 +61,34 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService {
@INativeEnvService private readonly _nativeEnvService: INativeEnvService
) { }

async getSessionsLocationPaths(): Promise<ClaudeSessionURI[]> {
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/<folder> dir
Expand All @@ -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<readonly IClaudeCodeSession[]> {
const folders = this._workspace.getWorkspaceFolders();
const items: IClaudeCodeSession[] = [];
async getAllSessions(token: CancellationToken): Promise<readonly (IClaudeCodeSession & { workingDirectory?: string })[]> {
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;
Expand Down Expand Up @@ -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<readonly IClaudeCodeSession[]> {
private async _loadSessionsFromDisk(projectDirUri: ClaudeSessionURI, token: CancellationToken): Promise<readonly IClaudeCodeSession[]> {
let entries: [string, FileType][] = [];
try {
entries = await this._fileSystem.readDirectory(projectDirUri);
Expand All @@ -167,7 +197,7 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService {
return [];
}

const fileTasks: Promise<{ messages: Map<string, StoredSDKMessage>; summaries: Map<string, SummaryEntry>; fileUri: URI }>[] = [];
const fileTasks: Promise<{ messages: Map<string, StoredSDKMessage>; summaries: Map<string, SummaryEntry>; fileUri: ClaudeSessionURI }>[] = [];
for (const [name, type] of entries) {
if (type !== FileType.File) {
continue;
Expand All @@ -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);
Expand Down Expand Up @@ -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<string, StoredSDKMessage>; summaries: Map<string, SummaryEntry>; fileUri: URI }> {
private async _getMessagesFromSessionWithUri(fileUri: ClaudeSessionURI, token: CancellationToken): Promise<{ messages: Map<string, StoredSDKMessage>; summaries: Map<string, SummaryEntry>; fileUri: ClaudeSessionURI }> {
const result = await this._getMessagesFromSession(fileUri, token);
return { ...result, fileUri };
}
Expand Down
12 changes: 6 additions & 6 deletions src/extension/agents/claude/node/test/claudeCodeAgent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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');
Expand Down
10 changes: 9 additions & 1 deletion src/extension/chatSessions/vscode-node/chatSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {};
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);
Expand All @@ -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 {};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
5 changes: 5 additions & 0 deletions src/extension/vscode.proposed.chatSessionsProvider.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ declare module 'vscode' {
*/
deletions: number;
};

/**
* Optional metadata associated to the chat session item
*/
metadata?: Record<string, string>;
}

export interface ChatSession {
Expand Down
Loading