From 1faec7cd38c23aa346f559ffd0fd1bf7cabc3c5c Mon Sep 17 00:00:00 2001 From: Federico Brancasi Date: Mon, 1 Jun 2026 15:50:27 +0200 Subject: [PATCH] Scope chat input history per session --- src/vs/sessions/SESSIONS.md | 2 + .../contrib/chat/browser/chat.contribution.ts | 8 ++ .../chat/browser/newChatInSessionWidget.ts | 1 + .../contrib/chat/browser/newChatInput.ts | 14 +++ .../contrib/chat/browser/newChatWidget.ts | 1 + .../sessionsChatHistoryContribution.ts | 106 ++++++++++++++++++ .../browser/sessionsManagementService.ts | 9 +- .../sessions/common/sessionsManagement.ts | 3 + .../test/browser/sessionNavigation.test.ts | 1 + .../browser/widget/input/chatInputPart.ts | 4 + .../common/widget/chatWidgetHistoryService.ts | 74 +++++++++--- .../widget/chatWidgetHistoryService.test.ts | 81 +++++++++++++ 12 files changed, 286 insertions(+), 18 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/sessionsChatHistoryContribution.ts diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index f672039e676..0b822ac7bc5 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -97,6 +97,8 @@ Session-level properties are derived from chats: The active session (`IActiveSession`) extends `ISession` with an `activeChat` observable that tracks which chat the user is viewing. +Chat input history in the Agents Window is scoped by `ISession.sessionId`. Pressing Up/Down in a chat input only navigates prompts previously submitted in the same session, including across multiple chats in that session. Users can disable `chat.agentSessions.scopedInputHistory` to restore shared input history across sessions. When a provider replaces a temporary untitled session with a committed session after the first send, history is moved from the temporary session id to the committed session id. + ### Workspaces and Folders Each session operates on an **`ISessionWorkspace`** containing one or more **`ISessionFolder`** instances. Folders encapsulate a working directory and optional git repository information (`ISessionGitRepository`), including branch state, upstream tracking, and GitHub PR info. diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 20cb0e32d3c..1f441004b68 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -33,6 +33,7 @@ import { AccessibleViewRegistry } from '../../../../platform/accessibility/brows import { SessionsChatAccessibilityHelp } from './sessionsChatAccessibilityHelp.js'; import { SessionsOpenerParticipantContribution } from './sessionsOpenerParticipant.js'; import { WorktreeCreatedTaskDispatcher, AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING } from './worktreeCreatedTaskDispatcher.js'; +import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING, SessionsChatHistoryContribution } from './sessionsChatHistoryContribution.js'; import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorAreaFocusContext } from '../../../../workbench/common/contextkeys.js'; @@ -78,6 +79,7 @@ registerAction2(BranchChatSessionAction); // register workbench contributions registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionsOpenerParticipantContribution.ID, SessionsOpenerParticipantContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(SessionsChatHistoryContribution.ID, SessionsChatHistoryContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(RegisterDefaultSessionTaskRunnersContribution.ID, RegisterDefaultSessionTaskRunnersContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(WorktreeCreatedTaskDispatcher.ID, WorktreeCreatedTaskDispatcher, WorkbenchPhase.AfterRestored); @@ -101,5 +103,11 @@ Registry.as(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, description: localize('chat.agentHost.runWorktreeCreatedTasks', "Whether to automatically run tasks tagged with `\"runOptions\": { \"runOn\": \"worktreeCreated\" }` when a new agent host session worktree is created. Manual `Run Task` invocations are unaffected."), }, + [AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING]: { + type: 'boolean', + default: true, + scope: ConfigurationScope.APPLICATION, + description: localize('chat.agentSessions.scopedInputHistory', "Controls whether chat input history in the Agents Window is scoped to the current session. Disable this to use shared input history across sessions."), + }, }, }); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts index 52143170c30..97c65ad64a5 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts @@ -54,6 +54,7 @@ export class NewChatInSessionWidget extends Disposable { sendRequest: async ({ query, attachments }) => this._send(query, attachments), canSendRequest, loading, + historyKey: derived(reader => this.sessionsManagementService.activeSession.read(reader)?.sessionId), minEditorHeight: 64, placeholder: localize('newChatInSessionPlaceholder', 'Ask a follow-up question or start a new topic within this session...'), })); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 5c0076d566f..d0b0eee40fe 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -56,6 +56,7 @@ import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } import { autorun, IObservable } from '../../../../base/common/observable.js'; import { ChatInputNotificationWidget } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; +import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistoryContribution.js'; const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; @@ -159,6 +160,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation sendRequest: (request: INewChatInputSendRequest) => Promise; canSendRequest: IObservable; loading: IObservable; + historyKey?: IObservable; minEditorHeight?: number; placeholder?: string; renderSessionTypePickerInControls?: boolean; @@ -179,6 +181,14 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation [INewChatModelPickerService, new NewChatModelPickerService()], ))); this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); + if (this.options.historyKey) { + this._register(autorun(reader => this._setHistoryKey(this.options.historyKey?.read(reader)))); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING)) { + this._setHistoryKey(this.options.historyKey?.get()); + } + })); + } this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); // Always use the mobile-aware picker. Its overrides bail to the // desktop behavior when `isPhoneLayout()` is false, so picking @@ -199,6 +209,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation })); } + private _setHistoryKey(historyKey: string | undefined): void { + this._history.setHistoryKey(this.configurationService.getValue(AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING) !== false ? historyKey : undefined); + } + // --- Rendering --- render(parent: HTMLElement, root: HTMLElement): void { diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index b8721683f45..863cdb8d856 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -77,6 +77,7 @@ export class NewChatWidget extends Disposable { sendRequest: async ({ query, attachments, background }) => this._send(query, attachments, background), canSendRequest, loading, + historyKey: derived(reader => this.sessionsManagementService.activeSession.read(reader)?.sessionId), renderSessionTypePickerInControls: false, supportsBackground: true, })); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatHistoryContribution.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatHistoryContribution.ts new file mode 100644 index 00000000000..9eab6f738b7 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatHistoryContribution.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IChatWidget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IChatWidgetHistoryService } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISession } from '../../../services/sessions/common/session.js'; + +export const AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING = 'chat.agentSessions.scopedInputHistory'; + +export class SessionsChatHistoryContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.chatHistory'; + + private readonly _registeredWidgets = new Set(); + private readonly _widgetDisposables = this._register(new DisposableStore()); + + constructor( + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatWidgetHistoryService private readonly chatWidgetHistoryService: IChatWidgetHistoryService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(); + + for (const widget of this.chatWidgetService.getAllWidgets()) { + this._registerWidget(widget); + } + + this._register(this.chatWidgetService.onDidAddWidget(widget => this._registerWidget(widget))); + this._register(this.sessionsManagementService.onDidChangeSessions(() => this._applyHistoryKeys())); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING)) { + this._applyHistoryKeys(); + } + })); + + this._register(this.sessionsManagementService.onDidReplaceSession(e => { + this.chatWidgetHistoryService.moveHistory(ChatAgentLocation.Chat, e.from.sessionId, e.to.sessionId); + this._applyHistoryKeys(); + })); + + this._register(autorun(reader => { + this.sessionsManagementService.activeSession.read(reader)?.activeChat.read(reader); + this._applyHistoryKeys(); + })); + } + + private _registerWidget(widget: IChatWidget): void { + if (this._registeredWidgets.has(widget)) { + return; + } + + this._registeredWidgets.add(widget); + this._widgetDisposables.add(widget.onDidChangeViewModel(() => this._applyHistoryKey(widget))); + this._applyHistoryKey(widget); + } + + private _applyHistoryKeys(): void { + for (const widget of this.chatWidgetService.getAllWidgets()) { + this._applyHistoryKey(widget); + } + } + + private _applyHistoryKey(widget: IChatWidget): void { + const sessionResource = widget.viewModel?.sessionResource; + widget.inputPart.setHistoryKey(this._useScopedInputHistory() && sessionResource ? this._getHistoryKey(sessionResource) : undefined); + } + + private _useScopedInputHistory(): boolean { + return this.configurationService.getValue(AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING) !== false; + } + + private _getHistoryKey(sessionResource: URI): string | undefined { + const activeSession = this.sessionsManagementService.activeSession.get(); + if (activeSession && this._matchesSession(activeSession, sessionResource)) { + return activeSession.sessionId; + } + + for (const session of this.sessionsManagementService.getSessions()) { + if (this._matchesSession(session, sessionResource)) { + return session.sessionId; + } + } + + return undefined; + } + + private _matchesSession(session: ISession, sessionResource: URI): boolean { + if (this.uriIdentityService.extUri.isEqual(session.resource, sessionResource)) { + return true; + } + + return session.chats.get().some(chat => this.uriIdentityService.extUri.isEqual(chat.resource, sessionResource)); + } +} \ No newline at end of file diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 331a37dd867..660dcbd9585 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -84,6 +84,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _onDidToggleSessionStickiness = this._register(new Emitter()); readonly onDidToggleSessionStickiness: Event = this._onDidToggleSessionStickiness.event; + private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); + readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; + private readonly _onDidChangeSessionTypes = this._register(new Emitter()); readonly onDidChangeSessionTypes: Event = this._onDidChangeSessionTypes.event; @@ -294,7 +297,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const disposables = new DisposableStore(); disposables.add(provider.onDidChangeSessions(e => this.onDidChangeSessionsFromSessionsProviders(e))); if (provider.onDidReplaceSession) { - disposables.add(provider.onDidReplaceSession(e => this.onDidReplaceSession(e.from, e.to))); + disposables.add(provider.onDidReplaceSession(e => this._handleDidReplaceSession(e.from, e.to))); } if (provider.onDidChangeSessionTypes) { disposables.add(provider.onDidChangeSessionTypes(() => this._updateSessionTypes())); @@ -303,9 +306,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } - private onDidReplaceSession(from: ISession, to: ISession): void { + private _handleDidReplaceSession(from: ISession, to: ISession): void { this._visibility.updateSession(from, to); - + this._onDidReplaceSession.fire({ from, to }); // Always fire the change event so the SessionsList refreshes even when // the user navigated to a different session while the new one was // being created (which is how duplicate rows appeared in the list). diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 1a23fea9234..640044ee63d 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -181,6 +181,9 @@ export interface ISessionsManagementService { /** Fires after a session's stickiness was toggled via {@link toggleSessionStickiness}. */ readonly onDidToggleSessionStickiness: Event; + /** Fires when a provider replaces a temporary session with its committed session. */ + readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }>; + // -- Active Session -- /** diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index a01b666d40b..83e5fbe6d4b 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -96,6 +96,7 @@ class MockSessionStore implements ISessionsManagementService { readonly onDidDeleteChat = Event.None; readonly onDidRenameChat = Event.None; readonly onDidToggleSessionStickiness = Event.None; + readonly onDidReplaceSession = Event.None; private readonly _sessions = new Map(); private _openedResource: URI | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index c37b51642d4..b4ec6923b20 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -378,6 +378,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._inputEditor; } + setHistoryKey(historyKey: string | undefined): void { + this.history.setHistoryKey(historyKey); + } + readonly dnd: ChatDragAndDrop; private history: ChatHistoryNavigator; diff --git a/src/vs/workbench/contrib/chat/common/widget/chatWidgetHistoryService.ts b/src/vs/workbench/contrib/chat/common/widget/chatWidgetHistoryService.ts index 9f511624920..0453d6246c1 100644 --- a/src/vs/workbench/contrib/chat/common/widget/chatWidgetHistoryService.ts +++ b/src/vs/workbench/contrib/chat/common/widget/chatWidgetHistoryService.ts @@ -39,15 +39,16 @@ export interface IChatWidgetHistoryService { readonly onDidChangeHistory: Event; clearHistory(): void; - getHistory(location: ChatAgentLocation): readonly IChatModelInputState[]; - append(location: ChatAgentLocation, history: IChatModelInputState): void; + getHistory(location: ChatAgentLocation, historyKey?: string): readonly IChatModelInputState[]; + append(location: ChatAgentLocation, history: IChatModelInputState, historyKey?: string): void; + moveHistory(location: ChatAgentLocation, fromHistoryKey: string, toHistoryKey: string): void; } interface IChatHistory { history?: { [providerId: string]: IChatModelInputState[] }; } -export type ChatHistoryChange = { kind: 'append'; entry: IChatModelInputState } | { kind: 'clear' }; +export type ChatHistoryChange = { kind: 'append'; location: ChatAgentLocation; historyKey: string | undefined; entry: IChatModelInputState } | { kind: 'move'; location: ChatAgentLocation; fromHistoryKey: string; toHistoryKey: string } | { kind: 'clear' }; export const ChatInputHistoryMaxEntries = 40; @@ -78,8 +79,8 @@ export class ChatWidgetHistoryService extends Disposable implements IChatWidgetH })); } - getHistory(location: ChatAgentLocation): IChatModelInputState[] { - const key = this.getKey(location); + getHistory(location: ChatAgentLocation, historyKey?: string): IChatModelInputState[] { + const key = this.getKey(location, historyKey); const history = this.viewState.history?.[key] ?? []; return history.map(entry => this.migrateHistoryEntry(entry)); } @@ -133,18 +134,39 @@ export class ChatWidgetHistoryService extends Disposable implements IChatWidgetH }; } - private getKey(location: ChatAgentLocation): string { + private getKey(location: ChatAgentLocation, historyKey?: string): string { // Preserve history for panel by continuing to use the same old provider id. Use the location as a key for other chat locations. - return location === ChatAgentLocation.Chat ? CHAT_PROVIDER_ID : location; + const locationKey = location === ChatAgentLocation.Chat ? CHAT_PROVIDER_ID : location; + return historyKey === undefined ? locationKey : `${locationKey}:${historyKey}`; } - append(location: ChatAgentLocation, history: IChatModelInputState): void { + append(location: ChatAgentLocation, history: IChatModelInputState, historyKey?: string): void { this.viewState.history ??= {}; - const key = this.getKey(location); - this.viewState.history[key] = this.getHistory(location).concat(history).slice(-ChatInputHistoryMaxEntries); + const key = this.getKey(location, historyKey); + this.viewState.history[key] = this.getHistory(location, historyKey).concat(history).slice(-ChatInputHistoryMaxEntries); this.changed = true; - this._onDidChangeHistory.fire({ kind: 'append', entry: history }); + this._onDidChangeHistory.fire({ kind: 'append', location, historyKey, entry: history }); + } + + moveHistory(location: ChatAgentLocation, fromHistoryKey: string, toHistoryKey: string): void { + if (fromHistoryKey === toHistoryKey) { + return; + } + + const fromHistory = this.getHistory(location, fromHistoryKey); + if (fromHistory.length === 0) { + return; + } + + this.viewState.history ??= {}; + + const fromKey = this.getKey(location, fromHistoryKey); + const toKey = this.getKey(location, toHistoryKey); + this.viewState.history[toKey] = this.getHistory(location, toHistoryKey).concat(fromHistory).slice(-ChatInputHistoryMaxEntries); + delete this.viewState.history[fromKey]; + this.changed = true; + this._onDidChangeHistory.fire({ kind: 'move', location, fromHistoryKey, toHistoryKey }); } clearHistory(): void { @@ -161,9 +183,10 @@ export class ChatHistoryNavigator extends Disposable { private _currentIndex: number; private _history: readonly IChatModelInputState[]; private _overlay: (IChatModelInputState | undefined)[] = []; + private _historyKey: string | undefined; public get values() { - return this.chatWidgetHistoryService.getHistory(this.location); + return this.chatWidgetHistoryService.getHistory(this.location, this._historyKey); } constructor( @@ -171,13 +194,16 @@ export class ChatHistoryNavigator extends Disposable { @IChatWidgetHistoryService private readonly chatWidgetHistoryService: IChatWidgetHistoryService ) { super(); - this._history = this.chatWidgetHistoryService.getHistory(this.location); + this._history = this.chatWidgetHistoryService.getHistory(this.location, this._historyKey); this._currentIndex = this._history.length; this._register(this.chatWidgetHistoryService.onDidChangeHistory(e => { if (e.kind === 'append') { + if (e.location !== this.location || e.historyKey !== this._historyKey) { + return; + } const prevLength = this._history.length; - this._history = this.chatWidgetHistoryService.getHistory(this.location); + this._history = this.chatWidgetHistoryService.getHistory(this.location, this._historyKey); const newLength = this._history.length; // If this append operation adjusted all history entries back, move our index back too @@ -194,10 +220,28 @@ export class ChatHistoryNavigator extends Disposable { this._history = []; this._currentIndex = 0; this._overlay = []; + } else if (e.kind === 'move') { + if (e.location !== this.location || (e.fromHistoryKey !== this._historyKey && e.toHistoryKey !== this._historyKey)) { + return; + } + this._history = this.chatWidgetHistoryService.getHistory(this.location, this._historyKey); + this._currentIndex = this._history.length; + this._overlay = []; } })); } + public setHistoryKey(historyKey: string | undefined) { + if (this._historyKey === historyKey) { + return; + } + + this._historyKey = historyKey; + this._history = this.chatWidgetHistoryService.getHistory(this.location, this._historyKey); + this._currentIndex = this._history.length; + this._overlay = []; + } + public isAtEnd() { return this._currentIndex === Math.max(this._history.length, this._overlay.length); } @@ -242,7 +286,7 @@ export class ChatHistoryNavigator extends Disposable { this._currentIndex = this._history.length; if (!entriesEqual(this._history.at(-1), entry)) { - this.chatWidgetHistoryService.append(this.location, entry); + this.chatWidgetHistoryService.append(this.location, entry, this._historyKey); } } } diff --git a/src/vs/workbench/contrib/chat/test/common/widget/chatWidgetHistoryService.test.ts b/src/vs/workbench/contrib/chat/test/common/widget/chatWidgetHistoryService.test.ts index 9b8c79ba4cb..01d80fb6dfa 100644 --- a/src/vs/workbench/contrib/chat/test/common/widget/chatWidgetHistoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/widget/chatWidgetHistoryService.test.ts @@ -72,6 +72,39 @@ suite('ChatWidgetHistoryService', () => { assert.strictEqual(terminalHistory[0].inputText, 'terminal query'); }); + test('should maintain separate history per history key', () => { + const historyService = createHistoryService(); + historyService.append(ChatAgentLocation.Chat, createInputState('global query')); + historyService.append(ChatAgentLocation.Chat, createInputState('session 1 query'), 'session-1'); + historyService.append(ChatAgentLocation.Chat, createInputState('session 2 query'), 'session-2'); + + assert.deepStrictEqual({ + global: historyService.getHistory(ChatAgentLocation.Chat).map(entry => entry.inputText), + session1: historyService.getHistory(ChatAgentLocation.Chat, 'session-1').map(entry => entry.inputText), + session2: historyService.getHistory(ChatAgentLocation.Chat, 'session-2').map(entry => entry.inputText), + }, { + global: ['global query'], + session1: ['session 1 query'], + session2: ['session 2 query'], + }); + }); + + test('should move history between history keys', () => { + const historyService = createHistoryService(); + historyService.append(ChatAgentLocation.Chat, createInputState('committed query'), 'committed-session'); + historyService.append(ChatAgentLocation.Chat, createInputState('untitled query'), 'untitled-session'); + + historyService.moveHistory(ChatAgentLocation.Chat, 'untitled-session', 'committed-session'); + + assert.deepStrictEqual({ + untitled: historyService.getHistory(ChatAgentLocation.Chat, 'untitled-session').map(entry => entry.inputText), + committed: historyService.getHistory(ChatAgentLocation.Chat, 'committed-session').map(entry => entry.inputText), + }, { + untitled: [], + committed: ['committed query', 'untitled query'], + }); + }); + test('should limit history to max entries', () => { const historyService = createHistoryService(); for (let i = 0; i < ChatInputHistoryMaxEntries + 10; i++) { @@ -410,4 +443,52 @@ suite('ChatHistoryNavigator', () => { assert.strictEqual(nav2.current()?.inputText, 'query 1'); assert.strictEqual(nav2.values.length, 4); }); + + test('should keep concurrent navigators separated by history key', () => { + const instantiationService = testDisposables.add(new TestInstantiationService()); + const storageService = testDisposables.add(new TestStorageService()); + instantiationService.stub(IStorageService, storageService); + + const historyService = testDisposables.add(instantiationService.createInstance(ChatWidgetHistoryService)); + instantiationService.stub(IChatWidgetHistoryService, historyService); + + const nav1 = testDisposables.add(instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); + const nav2 = testDisposables.add(instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); + nav1.setHistoryKey('session-1'); + nav2.setHistoryKey('session-2'); + + nav1.append(createInputState('session 1 query 1')); + nav1.append(createInputState('session 1 query 2')); + nav2.append(createInputState('session 2 query')); + + nav1.previous(); + nav2.append(createInputState('session 2 query 2')); + + assert.deepStrictEqual({ + nav1Current: nav1.current()?.inputText, + nav1Values: nav1.values.map(entry => entry.inputText), + nav2Values: nav2.values.map(entry => entry.inputText), + }, { + nav1Current: 'session 1 query 2', + nav1Values: ['session 1 query 1', 'session 1 query 2'], + nav2Values: ['session 2 query', 'session 2 query 2'], + }); + }); + + test('should update navigator when scoped history moves', () => { + const instantiationService = testDisposables.add(new TestInstantiationService()); + const storageService = testDisposables.add(new TestStorageService()); + instantiationService.stub(IStorageService, storageService); + + const historyService = testDisposables.add(instantiationService.createInstance(ChatWidgetHistoryService)); + instantiationService.stub(IChatWidgetHistoryService, historyService); + + const nav = testDisposables.add(instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); + nav.setHistoryKey('committed-session'); + + historyService.append(ChatAgentLocation.Chat, createInputState('untitled query'), 'untitled-session'); + historyService.moveHistory(ChatAgentLocation.Chat, 'untitled-session', 'committed-session'); + + assert.deepStrictEqual(nav.values.map(entry => entry.inputText), ['untitled query']); + }); });