diff --git a/src/extension/chatSessions/vscode-node/chatSessions.ts b/src/extension/chatSessions/vscode-node/chatSessions.ts index ad69e529b8..219cbf1a8e 100644 --- a/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -87,10 +87,10 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib )); const sessionItemProvider = this._register(claudeAgentInstaService.createInstance(ClaudeChatSessionItemProvider)); - this._register(vscode.chat.registerChatSessionItemProvider(ClaudeChatSessionItemProvider.claudeSessionType, sessionItemProvider)); const claudeAgentManager = this._register(claudeAgentInstaService.createInstance(ClaudeAgentManager)); const chatSessionContentProvider = this._register(claudeAgentInstaService.createInstance(ClaudeChatSessionContentProvider)); + chatSessionContentProvider.setSessionIdResolver(resource => sessionItemProvider.getSessionId(resource)); const slashCommandService = this._register(claudeAgentInstaService.createInstance(ClaudeSlashCommandService)); const claudeChatSessionParticipant = new ClaudeChatSessionParticipant(ClaudeChatSessionItemProvider.claudeSessionType, claudeAgentManager, sessionItemProvider, chatSessionContentProvider, slashCommandService); const chatParticipant = vscode.chat.createChatParticipant(ClaudeChatSessionItemProvider.claudeSessionType, claudeChatSessionParticipant.createHandler()); diff --git a/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index 9c04a3be4a..e934802041 100644 --- a/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -36,6 +36,8 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco // Track the last known option values for each session to detect actual changes private readonly _lastKnownOptions = new Map(); + private _sessionIdResolver: ((resource: vscode.Uri) => string | undefined) | undefined; + constructor( @IClaudeCodeSessionService private readonly sessionService: IClaudeCodeSessionService, @IClaudeCodeModels private readonly claudeCodeModels: IClaudeCodeModels, @@ -82,6 +84,30 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco super.dispose(); } + /** + * Sets the resolver function for mapping resources to Claude session IDs. + * This is needed to handle overridden resources (e.g., untitled sessions). + */ + public setSessionIdResolver(resolver: (resource: vscode.Uri) => string | undefined): void { + this._sessionIdResolver = resolver; + } + + /** + * Resolves a resource to a Claude session ID. + * Uses the custom resolver if set, otherwise falls back to ClaudeSessionUri.getId. + */ + private _resolveSessionId(resource: vscode.Uri): string | undefined { + if (this._sessionIdResolver) { + return this._sessionIdResolver(resource); + } + // Fallback to direct URI parsing + try { + return ClaudeSessionUri.getId(resource); + } catch { + return undefined; + } + } + /** * Gets the model ID for a session, delegating to state service */ @@ -134,7 +160,10 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco } async provideHandleOptionsChange(resource: vscode.Uri, updates: ReadonlyArray, _token: vscode.CancellationToken): Promise { - const sessionId = ClaudeSessionUri.getId(resource); + const sessionId = this._resolveSessionId(resource); + if (!sessionId) { + return; + } for (const update of updates) { if (update.optionId === MODELS_OPTION_ID) { // Update last known first so the event listener won't fire back to UI @@ -150,7 +179,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco } async provideChatSessionContent(sessionResource: vscode.Uri, token: vscode.CancellationToken): Promise { - const sessionId = ClaudeSessionUri.getId(sessionResource); + const sessionId = this._resolveSessionId(sessionResource); const existingSession = await this.sessionService.getSession(sessionResource, token); const toolContext = this._createToolContext(); const history = existingSession ? @@ -158,8 +187,8 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco []; // Get model and permission mode from state service (queries session if active) - const model = await this.sessionStateService.getModelIdForSession(sessionId); - const permissionMode = this.sessionStateService.getPermissionModeForSession(sessionId); + const model = sessionId ? await this.sessionStateService.getModelIdForSession(sessionId) : undefined; + const permissionMode = sessionId ? this.sessionStateService.getPermissionModeForSession(sessionId) : 'default'; const options: Record = {}; if (model) { diff --git a/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts b/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts index 5f3c1cf7ee..720ad7b46a 100644 --- a/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts +++ b/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts @@ -4,52 +4,102 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { Emitter, Event } from '../../../util/vs/base/common/event'; +import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { IClaudeCodeSessionService } from '../../agents/claude/node/claudeCodeSessionService'; /** - * Chat session item provider for Claude Code. + * Chat session item controller for Claude Code. * Reads sessions from ~/.claude/projects//, where each file name is a session id (GUID). */ -export class ClaudeChatSessionItemProvider extends Disposable implements vscode.ChatSessionItemProvider { +export class ClaudeChatSessionItemProvider extends Disposable { public static claudeSessionType = 'claude-code'; - private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); - public readonly onDidChangeChatSessionItems: Event = this._onDidChangeChatSessionItems.event; + private readonly controller: vscode.ChatSessionItemController; - private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>()); - public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event; + /** + * Maps Claude session IDs to the original chat session resource. + * Used to preserve the link between an untitled session and its Claude-backed session. + */ + private readonly sessionResourceOverrides = new Map(); constructor( @IClaudeCodeSessionService private readonly claudeCodeSessionService: IClaudeCodeSessionService ) { super(); + + this.controller = vscode.chat.createChatSessionItemController( + ClaudeChatSessionItemProvider.claudeSessionType, + // refreshHandler returns immediately - it never triggers work that would cause a loop. + // Items are already in the collection when VS Code reads them. + () => this.refresh() + ); + this._register(this.controller); } - public refresh(): void { - this._onDidChangeChatSessionItems.fire(); + /** + * Adds a single session item to the collection. + * Use this instead of refresh() when you know the specific session that was created. + */ + public addSession(sessionId: string, label: string, timestamp: Date, originalResource?: vscode.Uri): void { + if (originalResource) { + this.sessionResourceOverrides.set(sessionId, originalResource); + } + + const resource = originalResource ?? ClaudeSessionUri.forSessionId(sessionId); + const item = this.controller.createChatSessionItem(resource, label); + item.tooltip = `Claude Code session: ${label}`; + item.timing = { + created: timestamp.getTime(), + startTime: timestamp.getTime(), + }; + item.iconPath = new vscode.ThemeIcon('star-add'); + + this.controller.items.add(item); } - public swap(original: vscode.ChatSessionItem, modified: vscode.ChatSessionItem): void { - this._onDidCommitChatSessionItem.fire({ original, modified }); + /** + * Gets the Claude session ID for a given resource. + * Handles both direct Claude session URIs and overridden resources. + */ + public getSessionId(resource: vscode.Uri): string | undefined { + // First check if this is a direct Claude session URI + if (resource.scheme === ClaudeChatSessionItemProvider.claudeSessionType) { + return resource.path.slice(1); + } + + // Otherwise, look up in the overrides map (reverse lookup) + for (const [sessionId, overrideResource] of this.sessionResourceOverrides) { + if (overrideResource.toString() === resource.toString()) { + return sessionId; + } + } + + return undefined; } - public async provideChatSessionItems(token: vscode.CancellationToken): Promise { - const sessions = await this.claudeCodeSessionService.getAllSessions(token); - const diskSessions = sessions.map(session => ({ - resource: ClaudeSessionUri.forSessionId(session.id), - label: session.label, - tooltip: `Claude Code session: ${session.label}`, - timing: { + public async refresh(): Promise { + const sessions = await this.claudeCodeSessionService.getAllSessions(CancellationToken.None); + const items = sessions.map(session => { + // Use the overridden resource if available, otherwise use the Claude session URI + const resource = this.sessionResourceOverrides.get(session.id) + ?? ClaudeSessionUri.forSessionId(session.id); + + const item = this.controller.createChatSessionItem( + resource, + session.label + ); + item.tooltip = `Claude Code session: ${session.label}`; + item.timing = { created: session.timestamp.getTime(), startTime: session.timestamp.getTime(), - }, - iconPath: new vscode.ThemeIcon('star-add') - } satisfies vscode.ChatSessionItem)); + }; + item.iconPath = new vscode.ThemeIcon('star-add'); + return item; + }); - return diskSessions; + this.controller.items.replace(items); } } diff --git a/src/extension/chatSessions/vscode-node/claudeChatSessionParticipant.ts b/src/extension/chatSessions/vscode-node/claudeChatSessionParticipant.ts index 419b91ab19..2576f7acf8 100644 --- a/src/extension/chatSessions/vscode-node/claudeChatSessionParticipant.ts +++ b/src/extension/chatSessions/vscode-node/claudeChatSessionParticipant.ts @@ -8,7 +8,7 @@ import { ChatExtendedRequestHandler } from 'vscode'; import { ClaudeAgentManager } from '../../agents/claude/node/claudeCodeAgent'; import { IClaudeSlashCommandService } from '../../agents/claude/vscode-node/claudeSlashCommandService'; import { ClaudeChatSessionContentProvider } from './claudeChatSessionContentProvider'; -import { ClaudeChatSessionItemProvider, ClaudeSessionUri } from './claudeChatSessionItemProvider'; +import { ClaudeChatSessionItemProvider } from './claudeChatSessionItemProvider'; // Import the tool permission handlers import { PermissionMode } from '@anthropic-ai/claude-agent-sdk'; @@ -44,19 +44,21 @@ export class ClaudeChatSessionParticipant { }; const { chatSessionContext } = context; if (chatSessionContext) { - const sessionId = ClaudeSessionUri.getId(chatSessionContext.chatSessionItem.resource); - const modelId = await this.contentProvider.getModelIdForSession(sessionId); - const permissionMode = this.contentProvider.getPermissionModeForSession(sessionId); + const sessionId = this.sessionItemProvider.getSessionId(chatSessionContext.chatSessionItem.resource); + const modelId = sessionId ? await this.contentProvider.getModelIdForSession(sessionId) : undefined; + const permissionMode = sessionId ? this.contentProvider.getPermissionModeForSession(sessionId) : undefined; - if (chatSessionContext.isUntitled) { + if (chatSessionContext.isUntitled || !sessionId) { /* New, empty session */ const claudeSessionId = await create(modelId, permissionMode); if (claudeSessionId) { - // Tell UI to replace with claude-backed session - this.sessionItemProvider.swap(chatSessionContext.chatSessionItem, { - resource: ClaudeSessionUri.forSessionId(claudeSessionId), - label: request.prompt ?? 'Claude Code' - }); + // Add the new session directly to the controller + this.sessionItemProvider.addSession( + claudeSessionId, + request.prompt, + new Date(), + chatSessionContext.chatSessionItem.resource + ); } return {}; }