improve checkpointing and add checkpoint hover on first request (#305572)

* improve checkpointing and add checkpoint hover on first request

* address comments
This commit is contained in:
Justin Chen
2026-03-27 07:03:12 -07:00
committed by GitHub
parent bdea2b4df8
commit f867ff35d3
6 changed files with 65 additions and 31 deletions

View File

@@ -28,6 +28,7 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb
import { IEditorPane } from '../../../../common/editor.js'; import { IEditorPane } from '../../../../common/editor.js';
import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js';
import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js';
import { IChatRequestVariableEntry, isImplicitVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isStringVariableEntry, isWorkspaceVariableEntry } from '../../common/attachments/chatVariableEntries.js';
import { isChatViewTitleActionContext } from '../../common/actions/chatActions.js'; import { isChatViewTitleActionContext } from '../../common/actions/chatActions.js';
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
import { applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingResourceContextKey, chatEditingWidgetFileStateContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingResourceContextKey, chatEditingWidgetFileStateContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js';
@@ -414,7 +415,20 @@ export class ViewAllSessionChangesAction extends Action2 {
} }
registerAction2(ViewAllSessionChangesAction); registerAction2(ViewAllSessionChangesAction);
async function restoreSnapshotWithConfirmationByRequestId(accessor: ServicesAccessor, sessionResource: URI, requestId: string): Promise<void> { function filterToUserAttachedContext(attachedContext: readonly IChatRequestVariableEntry[] | undefined): IChatRequestVariableEntry[] {
if (!attachedContext?.length) {
return [];
}
return attachedContext.filter(a =>
!isImplicitVariableEntry(a) &&
!isWorkspaceVariableEntry(a) &&
!isStringVariableEntry(a) &&
!(isPromptFileVariableEntry(a) && a.automaticallyAdded) &&
!(isPromptTextVariableEntry(a) && a.automaticallyAdded)
);
}
async function restoreSnapshotWithConfirmationByRequestId(accessor: ServicesAccessor, sessionResource: URI, requestId: string): Promise<boolean> {
const configurationService = accessor.get(IConfigurationService); const configurationService = accessor.get(IConfigurationService);
const dialogService = accessor.get(IDialogService); const dialogService = accessor.get(IDialogService);
const chatWidgetService = accessor.get(IChatWidgetService); const chatWidgetService = accessor.get(IChatWidgetService);
@@ -422,18 +436,18 @@ async function restoreSnapshotWithConfirmationByRequestId(accessor: ServicesAcce
const chatService = accessor.get(IChatService); const chatService = accessor.get(IChatService);
const chatModel = chatService.getSession(sessionResource); const chatModel = chatService.getSession(sessionResource);
if (!chatModel) { if (!chatModel) {
return; return false;
} }
const session = chatModel.editingSession; const session = chatModel.editingSession;
if (!session) { if (!session) {
return; return false;
} }
const chatRequests = chatModel.getRequests(); const chatRequests = chatModel.getRequests();
const itemIndex = chatRequests.findIndex(request => request.id === requestId); const itemIndex = chatRequests.findIndex(request => request.id === requestId);
if (itemIndex === -1) { if (itemIndex === -1) {
return; return false;
} }
const editsToUndo = chatRequests.length - itemIndex; const editsToUndo = chatRequests.length - itemIndex;
@@ -472,7 +486,7 @@ async function restoreSnapshotWithConfirmationByRequestId(accessor: ServicesAcce
if (!confirmation.confirmed) { if (!confirmation.confirmed) {
widget?.viewModel?.model.setCheckpoint(undefined); widget?.viewModel?.model.setCheckpoint(undefined);
return; return false;
} }
if (confirmation.checkboxChecked) { if (confirmation.checkboxChecked) {
@@ -482,17 +496,18 @@ async function restoreSnapshotWithConfirmationByRequestId(accessor: ServicesAcce
// Restore the snapshot to what it was before the request(s) that we deleted // Restore the snapshot to what it was before the request(s) that we deleted
const snapshotRequestId = chatRequests[itemIndex].id; const snapshotRequestId = chatRequests[itemIndex].id;
await session.restoreSnapshot(snapshotRequestId, undefined); await session.restoreSnapshot(snapshotRequestId, undefined);
return true;
} }
async function restoreSnapshotWithConfirmation(accessor: ServicesAccessor, item: ChatTreeItem): Promise<void> { async function restoreSnapshotWithConfirmation(accessor: ServicesAccessor, item: ChatTreeItem): Promise<boolean> {
const requestId = isRequestVM(item) ? item.id : const requestId = isRequestVM(item) ? item.id :
isResponseVM(item) ? item.requestId : undefined; isResponseVM(item) ? item.requestId : undefined;
if (!requestId) { if (!requestId) {
return; return false;
} }
await restoreSnapshotWithConfirmationByRequestId(accessor, item.sessionResource, requestId); return restoreSnapshotWithConfirmationByRequestId(accessor, item.sessionResource, requestId);
} }
registerAction2(class RemoveAction extends Action2 { registerAction2(class RemoveAction extends Action2 {
@@ -535,11 +550,15 @@ registerAction2(class RemoveAction extends Action2 {
return; return;
} }
await restoreSnapshotWithConfirmation(accessor, item); const confirmed = await restoreSnapshotWithConfirmation(accessor, item);
if (isRequestVM(item) && configurationService.getValue('chat.undoRequests.restoreInput')) { if (confirmed && isRequestVM(item) && configurationService.getValue('chat.undoRequests.restoreInput')) {
widget?.focusInput(); widget?.focusInput();
widget?.input.setValue(item.messageText, false); widget?.input.setValue(item.messageText, false);
const userAttachments = filterToUserAttachedContext(item.attachedContext);
if (userAttachments.length) {
await widget?.input.restoreAttachments(userAttachments);
}
} }
} }
}); });
@@ -583,13 +602,19 @@ registerAction2(class RestoreCheckpointAction extends Action2 {
return; return;
} }
const userAttachments = isRequestVM(item) ? filterToUserAttachedContext(item.attachedContext) : [];
if (isRequestVM(item)) { if (isRequestVM(item)) {
widget?.focusInput(); widget?.focusInput();
widget?.input.setValue(item.messageText, false); widget?.input.setValue(item.messageText, false);
} }
widget?.viewModel?.model.setCheckpoint(item.id); widget?.viewModel?.model.setCheckpoint(item.id);
await restoreSnapshotWithConfirmation(accessor, item); const confirmed = await restoreSnapshotWithConfirmation(accessor, item);
if (confirmed && userAttachments.length) {
await widget?.input.restoreAttachments(userAttachments);
}
} }
}); });

View File

@@ -16,6 +16,7 @@ import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidg
import { IAction } from '../../../../../../base/common/actions.js'; import { IAction } from '../../../../../../base/common/actions.js';
import { equals as arraysEqual } from '../../../../../../base/common/arrays.js'; import { equals as arraysEqual } from '../../../../../../base/common/arrays.js';
import { DeferredPromise, RunOnceScheduler } from '../../../../../../base/common/async.js'; import { DeferredPromise, RunOnceScheduler } from '../../../../../../base/common/async.js';
import { isDefined } from '../../../../../../base/common/types.js';
import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../../base/common/codicons.js'; import { Codicon } from '../../../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Emitter, Event } from '../../../../../../base/common/event.js';
@@ -1379,16 +1380,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this.navigateHistory(false); this.navigateHistory(false);
} }
private async navigateHistory(previous: boolean): Promise<void> { /**
const historyEntry = previous ? * Restores attachments to the input, re-fetching image binary data as needed.
this.history.previous() : this.history.next(); */
async restoreAttachments(attachments: readonly IChatRequestVariableEntry[]): Promise<void> {
let restored = [...attachments];
let historyAttachments = historyEntry?.attachments ?? []; if (restored.length > 0) {
restored = (await Promise.all(restored.map(async (attachment) => {
// Check for images in history to restore the value. if (isImageVariableEntry(attachment) && !attachment.value && attachment.references?.length && URI.isUri(attachment.references[0].reference)) {
if (historyAttachments.length > 0) {
historyAttachments = (await Promise.all(historyAttachments.map(async (attachment) => {
if (isImageVariableEntry(attachment) && attachment.references?.length && URI.isUri(attachment.references[0].reference)) {
const currReference = attachment.references[0].reference; const currReference = attachment.references[0].reference;
try { try {
const imageBinary = currReference.toString(true).startsWith('http') ? await this.sharedWebExtracterService.readImage(currReference, CancellationToken.None) : (await this.fileService.readFile(currReference)).value; const imageBinary = currReference.toString(true).startsWith('http') ? await this.sharedWebExtracterService.readImage(currReference, CancellationToken.None) : (await this.fileService.readFile(currReference)).value;
@@ -1396,7 +1396,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
return undefined; return undefined;
} }
const newAttachment = { ...attachment }; const newAttachment = { ...attachment };
newAttachment.value = (isImageVariableEntry(attachment) && attachment.isPasted) ? imageBinary.buffer : await resizeImage(imageBinary.buffer); // if pasted image, we do not need to resize. newAttachment.value = (isImageVariableEntry(attachment) && attachment.isPasted) ? imageBinary.buffer : await resizeImage(imageBinary.buffer);
return newAttachment; return newAttachment;
} catch (err) { } catch (err) {
this.logService.error('Failed to fetch and reference.', err); this.logService.error('Failed to fetch and reference.', err);
@@ -1404,10 +1404,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
} }
} }
return attachment; return attachment;
}))).filter(attachment => attachment !== undefined); }))).filter(isDefined);
} }
this._attachmentModel.clearAndSetContext(...historyAttachments); this._attachmentModel.clearAndSetContext(...restored);
}
private async navigateHistory(previous: boolean): Promise<void> {
const historyEntry = previous ?
this.history.previous() : this.history.next();
await this.restoreAttachments(historyEntry?.attachments ?? []);
const inputText = historyEntry?.inputText ?? ''; const inputText = historyEntry?.inputText ?? '';
const contribData = historyEntry?.contrib ?? {}; const contribData = historyEntry?.contrib ?? {};

View File

@@ -3235,10 +3235,6 @@ have to be updated for changes to the rules above, or to support more deeply nes
.request-hover { .request-hover {
top: 0px; top: 0px;
} }
.checkpoint-container {
display: none;
}
} }
.interactive-list > .monaco-list:focus > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row.focused.request { .interactive-list > .monaco-list:focus > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row.focused.request {

View File

@@ -1887,7 +1887,7 @@ class InputModel implements IInputModel {
return { return {
contrib: value.contrib, contrib: value.contrib,
attachments: persistableAttachments, attachments: persistableAttachments.map(IChatRequestVariableEntry.toExport),
mode: value.mode, mode: value.mode,
selectedModel: value.selectedModel ? { selectedModel: value.selectedModel ? {
identifier: value.selectedModel.identifier, identifier: value.selectedModel.identifier,
@@ -2179,7 +2179,7 @@ export class ChatModel extends Disposable implements IChatModel {
// Initialize input model from serialized data (undefined for new chats) // Initialize input model from serialized data (undefined for new chats)
const serializedInputState = initialModelProps.inputState || (isValidFullData && initialData.inputState ? initialData.inputState : undefined); const serializedInputState = initialModelProps.inputState || (isValidFullData && initialData.inputState ? initialData.inputState : undefined);
this.inputModel = new InputModel(serializedInputState && { this.inputModel = new InputModel(serializedInputState && {
attachments: serializedInputState.attachments, attachments: (serializedInputState.attachments ?? []).map(IChatRequestVariableEntry.fromExport),
mode: serializedInputState.mode, mode: serializedInputState.mode,
selectedModel: serializedInputState.selectedModel && { selectedModel: serializedInputState.selectedModel && {
identifier: serializedInputState.selectedModel.identifier, identifier: serializedInputState.selectedModel.identifier,

View File

@@ -9,6 +9,7 @@ import { equals as objectsEqual } from '../../../../../base/common/objects.js';
import { isEqual as _urisEqual } from '../../../../../base/common/resources.js'; import { isEqual as _urisEqual } from '../../../../../base/common/resources.js';
import { hasKey } from '../../../../../base/common/types.js'; import { hasKey } from '../../../../../base/common/types.js';
import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js';
import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js';
import { IChatMarkdownContent, ResponseModelState } from '../chatService/chatService.js'; import { IChatMarkdownContent, ResponseModelState } from '../chatService/chatService.js';
import { ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { ModifiedFileEntryState } from '../editing/chatEditingService.js';
import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js';
@@ -116,7 +117,7 @@ const agentEditedFileEventSchema = Adapt.object<IChatAgentEditedFileEvent, IChat
}); });
const chatVariableSchema = Adapt.object<IChatRequestVariableData, IChatRequestVariableData>({ const chatVariableSchema = Adapt.object<IChatRequestVariableData, IChatRequestVariableData>({
variables: Adapt.t(v => v.variables, Adapt.array(Adapt.value((a, b) => a.name === b.name))), variables: Adapt.t(v => v.variables.map(IChatRequestVariableEntry.toExport), Adapt.array(Adapt.value((a, b) => a.name === b.name))),
}); });
const requestSchema = Adapt.object<IChatRequestModel, ISerializableChatRequestData>({ const requestSchema = Adapt.object<IChatRequestModel, ISerializableChatRequestData>({
@@ -155,7 +156,7 @@ const requestSchema = Adapt.object<IChatRequestModel, ISerializableChatRequestDa
}); });
const inputStateSchema = Adapt.object<ISerializableChatModelInputState, ISerializableChatModelInputState>({ const inputStateSchema = Adapt.object<ISerializableChatModelInputState, ISerializableChatModelInputState>({
attachments: Adapt.v(i => i.attachments, objectsEqual), attachments: Adapt.v(i => i.attachments.map(IChatRequestVariableEntry.toExport), objectsEqual),
mode: Adapt.v(i => i.mode, (a, b) => a.id === b.id), mode: Adapt.v(i => i.mode, (a, b) => a.id === b.id),
selectedModel: Adapt.v(i => i.selectedModel, (a, b) => a?.identifier === b?.identifier), selectedModel: Adapt.v(i => i.selectedModel, (a, b) => a?.identifier === b?.identifier),
inputText: Adapt.v(i => i.inputText), inputText: Adapt.v(i => i.inputText),

View File

@@ -94,6 +94,7 @@ export interface IChatRequestViewModel {
readonly slashCommand: IChatAgentCommand | undefined; readonly slashCommand: IChatAgentCommand | undefined;
readonly agentOrSlashCommandDetected: boolean; readonly agentOrSlashCommandDetected: boolean;
readonly shouldBeBlocked: IObservable<boolean>; readonly shouldBeBlocked: IObservable<boolean>;
readonly attachedContext?: readonly IChatRequestVariableEntry[];
readonly modelId?: string; readonly modelId?: string;
readonly timestamp: number; readonly timestamp: number;
/** The kind of pending request, or undefined if not pending */ /** The kind of pending request, or undefined if not pending */
@@ -462,6 +463,10 @@ export class ChatRequestViewModel implements IChatRequestViewModel {
currentRenderedHeight: number | undefined; currentRenderedHeight: number | undefined;
get attachedContext() {
return this._model.attachedContext;
}
get modelId() { get modelId() {
return this._model.modelId; return this._model.modelId;
} }