diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index fdc5b2ba20817..20350b9d8894e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -87,6 +87,7 @@ export const CHAT_CATEGORY = localize2('chat.category', 'Chat'); export const ACTION_ID_NEW_CHAT = `workbench.action.chat.newChat`; export const ACTION_ID_NEW_EDIT_SESSION = `workbench.action.chat.newEditSession`; export const CHAT_OPEN_ACTION_ID = 'workbench.action.chat.open'; +export const CHAT_OPEN_HISTORICAL_SESSION_ACTION_ID = 'workbench.action.chat.openHistoricalSession'; export const CHAT_SETUP_ACTION_ID = 'workbench.action.chat.triggerSetup'; const TOGGLE_CHAT_ACTION_ID = 'workbench.action.chat.toggle'; const CHAT_CLEAR_HISTORY_ACTION_ID = 'workbench.action.chat.clearHistory'; @@ -579,7 +580,7 @@ export function registerChatActions() { separator, { label: i.title, - description: i.isActive ? `(${localize('currentChatLabel', 'current')})` : '', + description: i.isActive ? `(${i.createdOnBranch} - ${localize('currentChatLabel', 'current')})` : `${i.createdOnBranch}`, chat: i, buttons: i.isActive ? [renameButton] : [ renameButton, @@ -1232,7 +1233,7 @@ export function registerChatActions() { const suggestCtrl = SuggestController.get(widget.inputEditor); if (suggestCtrl) { const curText = widget.inputEditor.getValue(); - const newValue = curText ? `@ ${curText}` : '@'; + const newValue = curText ? `@${curText}` : '@'; if (!curText.startsWith('@')) { widget.inputEditor.setValue(newValue); } @@ -1402,7 +1403,7 @@ export function registerChatActions() { override async run(accessor: ServicesAccessor): Promise { const extensionsWorkbenchService = accessor.get(IExtensionsWorkbenchService); - extensionsWorkbenchService.openSearch(`@feature:${CopilotUsageExtensionFeatureId}`); + extensionsWorkbenchService.openSearch(`@feature: ${CopilotUsageExtensionFeatureId}`); } }); @@ -1984,3 +1985,42 @@ registerAction2(class EditToolApproval extends Action2 { } } }); + +registerAction2(class OpenHistoricalChatSessionAction extends Action2 { + constructor() { + super({ + id: CHAT_OPEN_HISTORICAL_SESSION_ACTION_ID, + title: localize2('openHistoricalChatSession', "Open Historical Chat Session"), + category: CHAT_CATEGORY, + f1: true, + precondition: ChatContextKeys.enabled + }); + } + + async run(accessor: ServicesAccessor, options?: { sessionId?: string }): Promise { + const viewsService = accessor.get(IViewsService); + const editorService = accessor.get(IEditorService); + + if (!options?.sessionId) { + console.log('No sessionId provided to openHistoricalSession command'); + return; + } + + try { + const sessionId = options.sessionId; + console.log(`Opening historical chat session with sessionId: ${sessionId}`); + + // Try to open in chat view first + const view = await viewsService.openView(ChatViewId); + if (view) { + await view.loadSession(sessionId); + } else { + // Fallback to opening in editor + const options: IChatEditorOptions = { target: { sessionId }, pinned: true }; + await editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options }, ACTIVE_GROUP); + } + } catch (error) { + console.error('Failed to open historical chat session:', error); + } + } +}); diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index d86965601cb75..c29e6c3e87161 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -1153,6 +1153,10 @@ export interface ISerializableChatData3 extends Omit model.requestInProgressObs.read(reader)); }); + + // Listen for branch changes + this._register(this.gitStatus.onChangedBranch(async branch => { + console.log('Branch changed to:', branch); + const history = await this.getHistory(); + const sessionId = history.find(h => h.lastUsedOnBranch === branch)?.sessionId; + if (sessionId) { + this.commandService.executeCommand('workbench.action.chat.openHistoricalSession', { sessionId }); + } else { + this.commandService.executeCommand('workbench.action.chat.newChat'); + } + })); } isEnabled(location: ChatAgentLocation): boolean { @@ -303,6 +319,8 @@ export class ChatService extends Disposable implements IChatService { title, lastMessageDate: session.lastMessageDate, isActive: true, + createdOnBranch: session.createdOnBranch, + lastUsedOnBranch: session.lastUsedOnBranch, } satisfies IChatDetail; }); @@ -331,12 +349,22 @@ export class ChatService extends Disposable implements IChatService { private _startSession(someSessionHistory: IExportableChatData | ISerializableChatData | undefined, location: ChatAgentLocation, isGlobalEditingSession: boolean, token: CancellationToken, inputType?: string): ChatModel { const model = this.instantiationService.createInstance(ChatModel, someSessionHistory, { initialLocation: location, inputType }); + // Initialize branch metadata on creation + const branch = this.gitStatus.getCurrentBranch(); + if (branch) { + if (!model.createdOnBranch) { + model.createdOnBranch = branch; + } + model.lastUsedOnBranch = branch; + } if (location === ChatAgentLocation.Chat) { model.startEditingSession(isGlobalEditingSession); } this._sessionModels.set(model.sessionId, model); this.initializeSession(model, token); + // Persist immediately to capture branch info + this._chatSessionStore.storeSessions([model]); return model; } @@ -349,6 +377,19 @@ export class ChatService extends Disposable implements IChatService { this.activateDefaultAgent(model.initialLocation).catch(e => this.logService.error(e)); } + /** + * Update session metadata to reflect that it has been actively used (selected, restored, or loaded). + * Captures the current branch (best effort) for lastUsedOnBranch and persists the session metadata. + */ + private _markSessionUsed(model: ChatModel): void { + const branch = this.gitStatus.getCurrentBranch(); + if (branch) { + model.lastUsedOnBranch = branch; + } + // Persist metadata update only (non-blocking) + this._chatSessionStore.storeSessions([model]); + } + async activateDefaultAgent(location: ChatAgentLocation): Promise { await this.extensionService.whenInstalledExtensionsRegistered(); @@ -382,6 +423,7 @@ export class ChatService extends Disposable implements IChatService { this.trace('getOrRestoreSession', `sessionId: ${sessionId}`); const model = this._sessionModels.get(sessionId); if (model) { + this._markSessionUsed(model); return model; } @@ -403,6 +445,7 @@ export class ChatService extends Disposable implements IChatService { this._transferredSessionData = undefined; } + this._markSessionUsed(session); return session; } @@ -443,7 +486,9 @@ export class ChatService extends Disposable implements IChatService { } loadSessionFromContent(data: IExportableChatData | ISerializableChatData): IChatModel | undefined { - return this._startSession(data, data.initialLocation ?? ChatAgentLocation.Chat, true, CancellationToken.None); + const model = this._startSession(data, data.initialLocation ?? ChatAgentLocation.Chat, true, CancellationToken.None); + this._markSessionUsed(model); + return model; } async loadSessionForResource(resource: URI, location: ChatAgentLocation, token: CancellationToken): Promise { @@ -466,6 +511,7 @@ export class ChatService extends Disposable implements IChatService { const content = await this.chatSessionService.provideChatSessionContent(chatSessionType, parsed.sessionId, CancellationToken.None); const model = this._startSession(undefined, location, true, CancellationToken.None, chatSessionType); + this._markSessionUsed(model); if (!this._contentProviderSessionModels.has(chatSessionType)) { this._contentProviderSessionModels.set(chatSessionType, new Map()); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/chatSessionStore.ts index a424fb36806b5..1d90ff7d33126 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionStore.ts @@ -135,7 +135,7 @@ export class ChatSessionStore extends Disposable { await this.fileService.writeFile(storageLocation, VSBuffer.fromString(content)); // Write succeeded, update index - index.entries[session.sessionId] = getSessionMetadata(session); + index.entries[session.sessionId] = getSessionMetadata(session, index.entries[session.sessionId]); } catch (e) { this.reportError('sessionWrite', 'Error writing chat session', e); } @@ -381,6 +381,7 @@ export class ChatSessionStore extends Disposable { public getChatStorageFolder(): URI { return this.storageRoot; } + } interface IChatSessionEntryMetadata { @@ -389,6 +390,10 @@ interface IChatSessionEntryMetadata { lastMessageDate: number; isImported?: boolean; initialLocation?: ChatAgentLocation; + /** Git branch name at time the session was first persisted (best effort). */ + createdOnBranch?: string; + /** Git branch name at time the session was last written (best effort). */ + lastUsedOnBranch?: string; /** * This only exists because the migrated data from the storage service had empty sessions persisted, and it's impossible to know which ones are @@ -440,7 +445,7 @@ function isChatSessionIndex(data: unknown): data is IChatSessionIndexData { return true; } -function getSessionMetadata(session: ChatModel | ISerializableChatData): IChatSessionEntryMetadata { +function getSessionMetadata(session: ChatModel | ISerializableChatData, existingMetadata: IChatSessionEntryMetadata | undefined): IChatSessionEntryMetadata { const title = session instanceof ChatModel ? session.customTitle || (session.getRequests().length > 0 ? ChatModel.getDefaultTitle(session.getRequests()) : '') : session.customTitle ?? (session.requests.length > 0 ? ChatModel.getDefaultTitle(session.requests) : ''); @@ -451,7 +456,9 @@ function getSessionMetadata(session: ChatModel | ISerializableChatData): IChatSe lastMessageDate: session.lastMessageDate, isImported: session.isImported, initialLocation: session.initialLocation, - isEmpty: session instanceof ChatModel ? session.getRequests().length === 0 : session.requests.length === 0 + isEmpty: session instanceof ChatModel ? session.getRequests().length === 0 : session.requests.length === 0, + createdOnBranch: session instanceof ChatModel ? (session.createdOnBranch ?? existingMetadata?.createdOnBranch) : (session as ISerializableChatData).createdOnBranch ?? existingMetadata?.createdOnBranch, + lastUsedOnBranch: session instanceof ChatModel ? (session.lastUsedOnBranch ?? existingMetadata?.lastUsedOnBranch) : (session as ISerializableChatData).lastUsedOnBranch ?? existingMetadata?.lastUsedOnBranch, }; } diff --git a/src/vs/workbench/contrib/chat/common/gitStatusService.ts b/src/vs/workbench/contrib/chat/common/gitStatusService.ts new file mode 100644 index 0000000000000..60763532d0e2d --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/gitStatusService.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { autorun, runOnChange } from '../../../../base/common/observable.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ISCMRepository, ISCMService } from '../../scm/common/scm.js'; +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; + +export const IGitStatus = createDecorator('gitStatus'); + +export interface IGitStatus { + readonly _serviceBrand: undefined; + /** + * Best-effort current branch name. Undefined when: + * - No repositories + * - Multiple repositories (ambiguous) + * - Provider has no history (branch concept unavailable) + */ + getCurrentBranch(): string | undefined; + + /** + * Event that fires when the current branch changes + */ + readonly onChangedBranch: Event; +} + +class GitStatusService extends Disposable implements IGitStatus { + declare readonly _serviceBrand: undefined; + + private readonly _onChangedBranch = this._register(new Emitter()); + readonly onChangedBranch = this._onChangedBranch.event; + + constructor( + @ISCMService private readonly scmService: ISCMService, + ) { + super(); + this._setupBranchChangeListener(); + } + + private _setupBranchChangeListener(): void { + // Listen for new repositories being added + this._register(this.scmService.onDidAddRepository(repo => { + this._setupRepositoryListener(repo); + })); + + // Handle existing repositories + for (const repo of this.scmService.repositories) { + this._setupRepositoryListener(repo); + } + } + + private _setupRepositoryListener(repo: ISCMRepository): void { + // Use autorun to react to changes in the historyProvider observable + this._register(autorun(reader => { + /** @description GitStatusService.historyProviderAutorun */ + const historyProvider = repo.provider.historyProvider.read(reader); + + if (historyProvider?.historyItemRef) { + // Set up listener for current history item ref changes (HEAD movement) + return runOnChange(historyProvider.historyItemRef, () => { + // Fire event when the current branch reference changes + const currentBranch = this.getCurrentBranch(); + this._onChangedBranch.fire(currentBranch); + }); + } + return undefined; + })); + } + + getCurrentBranch(): string | undefined { + const repos = Array.from(this.scmService.repositories); + if (repos.length !== 1) { + return undefined; // Avoid ambiguity with multiple repos + } + + const repo = repos[0]; + const historyProvider = repo.provider.historyProvider.get(); + const ref = historyProvider?.historyItemRef.get(); + return ref?.name; + } + + +} + +registerSingleton(IGitStatus, GitStatusService, InstantiationType.Delayed);