Scope chat input history per session

This commit is contained in:
Federico Brancasi
2026-06-01 15:50:27 +02:00
parent 9629924031
commit 1faec7cd38
12 changed files with 286 additions and 18 deletions
+2
View File
@@ -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.
@@ -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<IConfigurationRegistry>(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."),
},
},
});
@@ -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...'),
}));
@@ -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<void>;
canSendRequest: IObservable<boolean>;
loading: IObservable<boolean>;
historyKey?: IObservable<string | undefined>;
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<boolean>(AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING) !== false ? historyKey : undefined);
}
// --- Rendering ---
render(parent: HTMLElement, root: HTMLElement): void {
@@ -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,
}));
@@ -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<IChatWidget>();
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<boolean>(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));
}
}
@@ -84,6 +84,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa
private readonly _onDidToggleSessionStickiness = this._register(new Emitter<IToggleSessionStickinessEvent>());
readonly onDidToggleSessionStickiness: Event<IToggleSessionStickinessEvent> = 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<void>());
readonly onDidChangeSessionTypes: Event<void> = 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).
@@ -181,6 +181,9 @@ export interface ISessionsManagementService {
/** Fires after a session's stickiness was toggled via {@link toggleSessionStickiness}. */
readonly onDidToggleSessionStickiness: Event<IToggleSessionStickinessEvent>;
/** Fires when a provider replaces a temporary session with its committed session. */
readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }>;
// -- Active Session --
/**
@@ -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<string, ISession>();
private _openedResource: URI | undefined;
@@ -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;
@@ -39,15 +39,16 @@ export interface IChatWidgetHistoryService {
readonly onDidChangeHistory: Event<ChatHistoryChange>;
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);
}
}
}
@@ -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']);
});
});