- remember draft state in new chat (#298983)

- move changes action to title
This commit is contained in:
Sandeep Somavarapu
2026-03-03 16:58:46 +01:00
committed by GitHub
parent dce8b9367d
commit 1b92648d63
6 changed files with 193 additions and 160 deletions

View File

@@ -3,28 +3,19 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/changesViewActions.css';
import { $, reset } from '../../../../base/browser/dom.js';
import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
import { autorun, observableFromEvent } from '../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { localize, localize2 } from '../../../../nls.js';
import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { observableFromEvent } from '../../../../base/common/observable.js';
import { localize2 } from '../../../../nls.js';
import { Action2, IAction2Options, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
import { getAgentChangesSummary, hasValidDiff } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';
import { hasValidDiff } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
import { Menus } from '../../../browser/menus.js';
import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js';
import { CHANGES_VIEW_ID } from './changesView.js';
import { IAction } from '../../../../base/common/actions.js';
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js';
import { activeSessionHasChangesContextKey } from '../common/changes.js';
@@ -34,12 +25,6 @@ const openChangesViewActionOptions: IAction2Options = {
title: localize2('openChangesView', "Changes"),
icon: Codicon.diffMultiple,
f1: false,
menu: {
id: Menus.TitleBarSessionMenu,
group: 'navigation',
order: 1,
when: ContextKeyExpr.equals(activeSessionHasChangesContextKey.key, true),
},
};
class OpenChangesViewAction extends Action2 {
@@ -58,111 +43,17 @@ class OpenChangesViewAction extends Action2 {
registerAction2(OpenChangesViewAction);
/**
* Custom action view item that renders the changes summary as:
* [diff-icon] +insertions -deletions
*/
class ChangesActionViewItem extends BaseActionViewItem {
private _container: HTMLElement | undefined;
private readonly _renderDisposables = this._register(new DisposableStore());
constructor(
action: IAction,
options: IBaseActionViewItemOptions | undefined,
@ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService,
@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,
@IHoverService private readonly hoverService: IHoverService,
) {
super(undefined, action, options);
this._register(autorun(reader => {
this.sessionManagementService.activeSession.read(reader);
this._updateLabel();
}));
this._register(this.agentSessionsService.model.onDidChangeSessions(() => {
this._updateLabel();
}));
}
override render(container: HTMLElement): void {
super.render(container);
this._container = container;
container.classList.add('changes-action-view-item');
this._updateLabel();
}
private _updateLabel(): void {
if (!this._container) {
return;
}
this._renderDisposables.clear();
reset(this._container);
const activeSession = this.sessionManagementService.getActiveSession();
if (!activeSession) {
this._container.style.display = 'none';
return;
}
const agentSession = this.agentSessionsService.getSession(activeSession.resource);
const changes = agentSession?.changes;
if (!changes || !hasValidDiff(changes)) {
this._container.style.display = 'none';
return;
}
const summary = getAgentChangesSummary(changes);
if (!summary) {
this._container.style.display = 'none';
return;
}
this._container.style.display = '';
// Diff icon
const iconEl = $('span.changes-action-icon' + ThemeIcon.asCSSSelector(Codicon.diffMultiple));
this._container.appendChild(iconEl);
// Insertions
const addedEl = $('span.changes-action-added');
addedEl.textContent = `+${summary.insertions}`;
this._container.appendChild(addedEl);
// Deletions
const removedEl = $('span.changes-action-removed');
removedEl.textContent = `-${summary.deletions}`;
this._container.appendChild(removedEl);
// Hover
this._renderDisposables.add(this.hoverService.setupManagedHover(
getDefaultHoverDelegate('mouse'),
this._container,
localize('agentSessions.viewChanges', "View All Changes")
));
}
}
class ChangesViewActionsContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.changesViewActions';
constructor(
@IActionViewItemService actionViewItemService: IActionViewItemService,
@IInstantiationService instantiationService: IInstantiationService,
@IContextKeyService contextKeyService: IContextKeyService,
@ISessionsManagementService sessionManagementService: ISessionsManagementService,
@IAgentSessionsService agentSessionsService: IAgentSessionsService,
) {
super();
this._register(actionViewItemService.register(Menus.TitleBarSessionMenu, OpenChangesViewAction.ID, (action, options) => {
return instantiationService.createInstance(ChangesActionViewItem, action, options);
}));
// Bind context key: true when the active session has changes
const sessionsChanged = observableFromEvent(this, agentSessionsService.model.onDidChangeSessions, () => { });
this._register(bindContextKey(activeSessionHasChangesContextKey, contextKeyService, reader => {

View File

@@ -31,6 +31,7 @@ interface IBranchItem {
export class BranchPicker extends Disposable {
private _selectedBranch: string | undefined;
private _preferredBranch: string | undefined;
private _newSession: INewSession | undefined;
private _branches: string[] = [];
@@ -48,6 +49,13 @@ export class BranchPicker extends Disposable {
return this._selectedBranch;
}
/**
* Sets a preferred branch to select when branches are loaded.
*/
setPreferredBranch(branch: string | undefined): void {
this._preferredBranch = branch;
}
constructor(
@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,
) {
@@ -85,8 +93,11 @@ export class BranchPicker extends Disposable {
.filter((name): name is string => !!name)
.filter(name => !name.includes(COPILOT_WORKTREE_PATTERN));
// Select active branch, main, master, or the first branch by default
const defaultBranch = this._branches.find(b => b === repository.state.get().HEAD?.name)
// Select preferred branch (from draft), active branch, main, master, or the first branch
const preferred = this._preferredBranch;
this._preferredBranch = undefined;
const defaultBranch = (preferred ? this._branches.find(b => b === preferred) : undefined)
?? this._branches.find(b => b === repository.state.get().HEAD?.name)
?? this._branches.find(b => b === 'main')
?? this._branches.find(b => b === 'master')
?? this._branches[0];

View File

@@ -11,7 +11,7 @@ import { toAction } from '../../../../base/common/actions.js';
import { Emitter } from '../../../../base/common/event.js';
import { KeyCode } from '../../../../base/common/keyCodes.js';
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { observableValue } from '../../../../base/common/observable.js';
import { autorun, observableValue } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { CancellationTokenSource } from '../../../../base/common/cancellation.js';
import { Button } from '../../../../base/browser/ui/button/button.js';
@@ -56,7 +56,7 @@ import { NewChatContextAttachments } from './newChatContextAttachments.js';
import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js';
import { FolderPicker } from './folderPicker.js';
import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js';
import { IsolationModePicker, SessionTargetPicker } from './sessionTargetPicker.js';
import { IsolationMode, IsolationModePicker, SessionTargetPicker } from './sessionTargetPicker.js';
import { BranchPicker } from './branchPicker.js';
import { SyncIndicator } from './syncIndicator.js';
import { INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js';
@@ -75,6 +75,14 @@ const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState';
const MIN_EDITOR_HEIGHT = 50;
const MAX_EDITOR_HEIGHT = 200;
interface IDraftState extends IChatModelInputState {
target?: AgentSessionProviders;
isolationMode?: IsolationMode;
branch?: string;
folderUri?: string;
repo?: string;
}
// #region --- Chat Welcome Widget ---
/**
@@ -152,6 +160,16 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
// Slash commands
private _slashCommandHandler: SlashCommandHandler | undefined;
// Input state
private _draftState: IDraftState | undefined = {
inputText: '',
attachments: [],
mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent },
selectedModel: undefined,
selections: [],
contrib: {}
};
// Input history
private readonly _history: ChatHistoryNavigator;
private _historyNavigationBackwardsEnablement!: IHistoryNavigationContext['historyNavigationBackwardsEnablement'];
@@ -190,6 +208,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._isolationModePicker.setVisible(isLocal);
this._branchPicker.setVisible(isLocal);
this._syncIndicator.setVisible(isLocal);
this._updateDraftState();
this._focusEditor();
}));
@@ -200,21 +219,37 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._register(this._branchPicker.onDidChange((branch) => {
this._syncIndicator.setBranch(branch);
this._updateDraftState();
this._focusEditor();
}));
this._register(this._folderPicker.onDidSelectFolder(() => {
this._updateDraftState();
this._focusEditor();
}));
this._register(this._isolationModePicker.onDidChange(() => {
this._register(this._isolationModePicker.onDidChange((mode) => {
this._branchPicker.setVisible(mode === 'worktree');
this._syncIndicator.setVisible(mode === 'worktree');
this._updateDraftState();
this._focusEditor();
}));
this._register(this._repoPicker.onDidSelectRepo(() => {
this._updateDraftState();
}));
// When language models change (e.g., extension activates), reinitialize if no model selected
this._register(this.languageModelsService.onDidChangeLanguageModels(() => {
this._initDefaultModel();
}));
// Update input state when attachments or model change
this._register(this._contextAttachments.onDidChangeContext(() => this._updateDraftState()));
this._register(autorun(reader => {
this._currentLanguageModel.read(reader);
this._updateDraftState();
}));
}
// --- Rendering ---
@@ -261,11 +296,12 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._branchPicker.render(branchContainer);
this._syncIndicator.render(branchContainer);
// Set initial visibility based on default target
// Set initial visibility based on default target and isolation mode
const isLocal = this._targetPicker.selectedTarget === AgentSessionProviders.Background;
const isWorktree = this._isolationModePicker.isolationMode === 'worktree';
this._isolationModePicker.setVisible(isLocal);
this._branchPicker.setVisible(isLocal);
this._syncIndicator.setVisible(isLocal);
this._branchPicker.setVisible(isLocal && isWorktree);
this._syncIndicator.setVisible(isLocal && isWorktree);
// Render target buttons & extension pickers
this._renderOptionGroupPickers();
@@ -517,6 +553,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._slashCommandHandler = this._register(this.instantiationService.createInstance(SlashCommandHandler, this._editor));
this._register(this._editor.onDidChangeModelContent(() => {
this._updateDraftState();
this._updateSendButtonState();
}));
}
@@ -848,9 +885,8 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
if (this._history.isAtStart()) {
return;
}
const state = this._getInputState();
if (state.inputText || state.attachments.length) {
this._history.overlay(state);
if (this._draftState?.inputText || this._draftState?.attachments.length) {
this._history.overlay(this._draftState);
}
this._navigateHistory(true);
}
@@ -859,21 +895,26 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
if (this._history.isAtEnd()) {
return;
}
const state = this._getInputState();
if (state.inputText || state.attachments.length) {
this._history.overlay(state);
if (this._draftState?.inputText || this._draftState?.attachments.length) {
this._history.overlay(this._draftState);
}
this._navigateHistory(false);
}
private _getInputState(): IChatModelInputState {
return {
private _updateDraftState(): void {
const attachments = [...this._contextAttachments.attachments];
this._draftState = {
inputText: this._editor?.getModel()?.getValue() ?? '',
attachments: [...this._contextAttachments.attachments],
attachments,
mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent },
selectedModel: this._currentLanguageModel.get(),
selections: this._editor?.getSelections() ?? [],
contrib: {},
target: this._targetPicker.selectedTarget,
isolationMode: this._isolationModePicker.isolationMode,
branch: this._branchPicker.selectedBranch,
folderUri: this._folderPicker.selectedFolderUri?.toString(),
repo: this._repoPicker.selectedRepo,
};
}
@@ -932,7 +973,9 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._contextAttachments.attachments.length > 0 ? [...this._contextAttachments.attachments] : undefined
);
this._history.append(this._getInputState());
if (this._draftState) {
this._history.append(this._draftState);
}
this._clearDraftState();
this._sending = true;
@@ -940,22 +983,23 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._updateSendButtonState();
this._updateInputLoadingState();
this.sessionsManagementService.sendRequestForNewSession(
session.resource,
options?.openNewAfterSend ? { openNewSessionView: true } : undefined
).then(() => {
// Release ref without disposing - the service owns disposal
this._newSession.clearAndLeak();
try {
await this.sessionsManagementService.sendRequestForNewSession(
session.resource,
options?.openNewAfterSend ? { openNewSessionView: true } : undefined
);
this._newSessionListener.clear();
this._contextAttachments.clear();
}, e => {
} catch (e) {
this.logService.error('Failed to send request:', e);
}).finally(() => {
this._sending = false;
this._editor.updateOptions({ readOnly: false });
this._updateSendButtonState();
this._updateInputLoadingState();
});
}
this._sending = false;
this._editor.updateOptions({ readOnly: false });
this._updateSendButtonState();
this._updateInputLoadingState();
}
/**
@@ -1001,10 +1045,23 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._currentLanguageModel.set(model, undefined);
}
}
if (draft.isolationMode) {
this._isolationModePicker.setPreferredIsolationMode(draft.isolationMode);
this._isolationModePicker.setIsolationMode(draft.isolationMode);
}
if (draft.branch) {
this._branchPicker.setPreferredBranch(draft.branch);
}
if (draft.folderUri) {
try { this._folderPicker.setSelectedFolder(URI.parse(draft.folderUri)); } catch { /* ignore */ }
}
if (draft.repo) {
this._repoPicker.setSelectedRepo(draft.repo);
}
}
}
private _getDraftState(): (IChatModelInputState & { target?: AgentSessionProviders }) | undefined {
private _getDraftState(): IDraftState | undefined {
const raw = this.storageService.get(STORAGE_KEY_DRAFT_STATE, StorageScope.WORKSPACE);
if (!raw) {
return undefined;
@@ -1017,21 +1074,20 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
}
private _clearDraftState(): void {
this._draftState = undefined;
this.storageService.remove(STORAGE_KEY_DRAFT_STATE, StorageScope.WORKSPACE);
}
saveState(): void {
const inputState = this._getInputState();
const state = {
...inputState,
attachments: inputState.attachments.map(IChatRequestVariableEntry.toExport),
target: this._targetPicker.selectedTarget,
};
this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(state), StorageScope.WORKSPACE, StorageTarget.MACHINE);
if (this._draftState) {
const state = {
...this._draftState,
attachments: this._draftState.attachments.map(IChatRequestVariableEntry.toExport),
};
this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(state), StorageScope.WORKSPACE, StorageTarget.MACHINE);
}
}
// --- Layout ---
layout(_height: number, _width: number): void {
this._editor?.layout();
}
@@ -1118,6 +1174,11 @@ export class NewChatViewPane extends ViewPane {
override saveState(): void {
this._widget?.saveState();
}
override dispose(): void {
this._widget?.saveState();
super.dispose();
}
}
// #endregion

View File

@@ -137,6 +137,7 @@ export type IsolationMode = 'worktree' | 'workspace';
export class IsolationModePicker extends Disposable {
private _isolationMode: IsolationMode = 'worktree';
private _preferredIsolationMode: IsolationMode | undefined;
private _newSession: INewSession | undefined;
private _repository: IGitRepository | undefined;
@@ -171,7 +172,9 @@ export class IsolationModePicker extends Disposable {
setRepository(repository: IGitRepository | undefined): void {
this._repository = repository;
if (repository) {
this._setMode('worktree');
const preferred = this._preferredIsolationMode;
this._preferredIsolationMode = undefined;
this._setMode(preferred ?? 'worktree');
} else if (this._isolationMode === 'worktree') {
this._setMode('workspace');
}
@@ -207,6 +210,20 @@ export class IsolationModePicker extends Disposable {
}));
}
/**
* Sets a preferred isolation mode to apply when a repository is set.
*/
setPreferredIsolationMode(mode: IsolationMode): void {
this._preferredIsolationMode = mode;
}
/**
* Programmatically set the isolation mode.
*/
setIsolationMode(mode: IsolationMode): void {
this._setMode(mode);
}
/**
* Shows or hides the picker.
*/

View File

@@ -76,3 +76,19 @@
opacity: 0.5;
flex-shrink: 0;
}
/* Changes summary */
.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 3px;
}
.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-added {
color: var(--vscode-gitDecoration-addedResourceForeground);
}
.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-removed {
color: var(--vscode-gitDecoration-deletedResourceForeground);
}

View File

@@ -19,9 +19,8 @@ import { IMenuService, MenuId, MenuRegistry, SubmenuItemAction } from '../../../
import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
import { IMarshalledAgentSessionContext } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';
import { IMarshalledAgentSessionContext, getAgentChangesSummary, hasValidDiff } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';
import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { Menus } from '../../../browser/menus.js';
import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js';
import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';
@@ -128,9 +127,10 @@ export class SessionsTitleBarWidget extends BaseActionViewItem {
const label = this._getActiveSessionLabel();
const icon = this._getActiveSessionIcon();
const repoLabel = this._getRepositoryLabel();
const changesSummary = this._getChangesSummary();
// Build a render-state key from all displayed data
const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`;
const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${changesSummary?.insertions ?? ''}|${changesSummary?.deletions ?? ''}`;
// Skip re-render if state hasn't changed
if (this._lastRenderState === renderState) {
@@ -175,6 +175,25 @@ export class SessionsTitleBarWidget extends BaseActionViewItem {
centerGroup.appendChild(repoEl);
}
// Changes summary shown next to the repo
if (changesSummary) {
const separator2 = $('span.agent-sessions-titlebar-separator');
separator2.textContent = '\u00B7';
centerGroup.appendChild(separator2);
const changesEl = $('span.agent-sessions-titlebar-changes');
const addedEl = $('span.agent-sessions-titlebar-changes-added');
addedEl.textContent = `+${changesSummary.insertions}`;
changesEl.appendChild(addedEl);
const removedEl = $('span.agent-sessions-titlebar-changes-removed');
removedEl.textContent = `-${changesSummary.deletions}`;
changesEl.appendChild(removedEl);
centerGroup.appendChild(changesEl);
}
sessionPill.appendChild(centerGroup);
// Click handler on pill - show sessions picker
@@ -336,6 +355,24 @@ export class SessionsTitleBarWidget extends BaseActionViewItem {
menu.dispose();
}
/**
* Get the changes summary for the active session.
*/
private _getChangesSummary(): { insertions: number; deletions: number } | undefined {
const activeSession = this.activeSessionService.getActiveSession();
if (!activeSession) {
return undefined;
}
const agentSession = this.agentSessionsService.getSession(activeSession.resource);
const changes = agentSession?.changes;
if (!changes || !hasValidDiff(changes)) {
return undefined;
}
return getAgentChangesSummary(changes);
}
private _showSessionsPicker(): void {
const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined, {
overrideSessionOpen: (session, openOptions) => this.activeSessionService.openSession(session.resource, openOptions)