Compare commits

...

8 Commits

Author SHA1 Message Date
Nate
a69daa3190 fix: lint err 2025-10-08 10:47:33 -07:00
Nate
d68d0f8686 improv: track profile type for autocomplete 2025-10-08 10:46:20 -07:00
Nate
758320a341 fix: reduce context lines 2025-10-08 10:45:29 -07:00
Nate
b13f89745d fix: next edit diff formatting 2025-10-08 10:45:28 -07:00
Nate
53b2a7d794 fix: correctly parse out backticks 2025-10-08 10:43:20 -07:00
Nate
2579dd5d4f fix: correctly parse out backticks 2025-10-08 10:42:53 -07:00
Nate
be306b8877 refactor: autocomplete feedback 2025-10-08 10:42:20 -07:00
Ting-Wai To
d6f3a90690 bump package.json 2025-10-08 10:29:21 -07:00
17 changed files with 286 additions and 155 deletions

View File

@@ -268,6 +268,8 @@ export class CompletionProvider {
gitRepo: await this.ide.getRepoName(helper.filepath),
uniqueId: await this.ide.getUniqueId(),
timestamp: new Date().toISOString(),
profileType:
this.configHandler.currentProfile?.profileDescription.profileType,
...helper.options,
};

View File

@@ -54,7 +54,7 @@ function isBlank(completion: string): boolean {
/**
* Removes markdown code block delimiters from completion.
* Removes the first line if it starts with "```" and the last line if it is exactly "```".
* Removes the first line if it contains only backticks and the last line if it contains only backticks.
*/
function removeBackticks(completion: string): string {
const lines = completion.split("\n");
@@ -66,14 +66,18 @@ function removeBackticks(completion: string): string {
let startIdx = 0;
let endIdx = lines.length;
// Remove first line if it starts with ```
if (lines[0].trimStart().startsWith("```")) {
// Remove first line if it contains only backticks (one or more)
const firstLineTrimmed = lines[0].trim();
if (firstLineTrimmed.length > 0 && /^`+$/.test(firstLineTrimmed)) {
startIdx = 1;
}
// Remove last line if it is exactly ```
if (lines.length > startIdx && lines[lines.length - 1].trim() === "```") {
endIdx = lines.length - 1;
// Remove last line if it contains only backticks (one or more)
if (lines.length > startIdx) {
const lastLineTrimmed = lines[lines.length - 1].trim();
if (lastLineTrimmed.length > 0 && /^`+$/.test(lastLineTrimmed)) {
endIdx = lines.length - 1;
}
}
// If we removed lines, return the modified completion

View File

@@ -119,6 +119,7 @@ export class AutocompleteLoggingService {
time: restOfOutcome.time,
useRecentlyEdited: restOfOutcome.useRecentlyEdited,
numLines: restOfOutcome.numLines,
profileType: restOfOutcome.profileType,
};
outcome.enabledStaticContextualization

View File

@@ -43,4 +43,5 @@ export interface AutocompleteOutcome extends TabAutocompleteOptions {
uniqueId: string;
timestamp: string;
enabledStaticContextualization?: boolean;
profileType?: "local" | "platform" | "control-plane";
}

View File

@@ -537,12 +537,19 @@ export class ConfigHandler {
// Track config loading telemetry
const endTime = performance.now();
const duration = endTime - startTime;
void Telemetry.capture("config_reload", {
const profileDescription = this.currentProfile.profileDescription;
const telemetryData: Record<string, any> = {
duration,
reason,
totalConfigLoads: this.totalConfigReloads,
configLoadInterrupted,
});
profileType: profileDescription.profileType,
isPersonalOrg: this.currentOrg?.id === this.PERSONAL_ORG_DESC.id,
errorCount: errors.length,
};
void Telemetry.capture("config_reload", telemetryData);
return {
config,

View File

@@ -95,9 +95,6 @@ export class NextEditLoggingService {
outcome.accepted = true;
outcome.aborted = false;
this.logNextEditOutcome(outcome);
if (outcome.requestId) {
void this.logAcceptReject(outcome.requestId, true);
}
this._outcomes.delete(completionId);
return outcome;
}
@@ -116,9 +113,6 @@ export class NextEditLoggingService {
outcome.accepted = false;
outcome.aborted = false;
this.logNextEditOutcome(outcome);
if (outcome.requestId) {
void this.logAcceptReject(outcome.requestId, false);
}
this._outcomes.delete(completionId);
return outcome;
}
@@ -151,9 +145,6 @@ export class NextEditLoggingService {
outcome.accepted = false;
outcome.aborted = false;
this.logNextEditOutcome(outcome);
if (outcome.requestId) {
void this.logAcceptReject(outcome.requestId, false);
}
this._logRejectionTimeouts.delete(completionId);
this._outcomes.delete(completionId);
}, COUNT_COMPLETION_REJECTED_AFTER);
@@ -197,44 +188,15 @@ export class NextEditLoggingService {
this._logRejectionTimeouts.delete(completionId);
}
// If we have the full outcome, log it as aborted.
// Only log if the completion was displayed to the user.
// This aligns with Autocomplete behavior and prevents logging
// of cancelled requests that never reached the user.
if (this._outcomes.has(completionId)) {
const outcome = this._outcomes.get(completionId)!;
outcome.accepted = false;
// outcome.accepted = false;
outcome.aborted = true;
this.logNextEditOutcome(outcome);
this._outcomes.delete(completionId);
} else {
// Log minimal abort event for requests that never got displayed.
const pendingData = this._pendingCompletions.get(completionId);
const minimalAbortOutcome: Partial<NextEditOutcome> = {
completionId,
accepted: false,
aborted: true,
timestamp: Date.now(),
uniqueId: completionId,
elapsed: pendingData ? Date.now() - pendingData.startTime : 0,
modelName: pendingData?.modelName || "unknown",
modelProvider: pendingData?.modelProvider || "unknown",
fileUri: pendingData?.filepath || "unknown",
// Empty/default values for fields we don't have.
completion: "",
prompt: "",
userEdits: "",
userExcerpts: "",
originalEditableRange: "",
workspaceDirUri: "",
cursorPosition: { line: -1, character: -1 },
finalCursorPosition: { line: -1, character: -1 },
editableRegionStartLine: -1,
editableRegionEndLine: -1,
diffLines: [],
completionOptions: {},
};
this.logNextEditOutcome(minimalAbortOutcome as NextEditOutcome);
}
// Clean up.
@@ -255,6 +217,9 @@ export class NextEditLoggingService {
});
// const { prompt, completion, prefix, suffix, ...restOfOutcome } = outcome;
if (outcome.requestId && outcome.accepted !== undefined) {
void this.logAcceptReject(outcome.requestId, outcome.accepted);
}
void Telemetry.capture("nextEditOutcome", outcome, true);
}
@@ -268,7 +233,7 @@ export class NextEditLoggingService {
}
const controlPlaneEnv = getControlPlaneEnvSync("production");
await fetchwithRequestOptions(
const resp = await fetchwithRequestOptions(
new URL("model-proxy/v1/feedback", controlPlaneEnv.CONTROL_PLANE_URL),
{
method: "POST",

View File

@@ -140,7 +140,7 @@ export class PrefetchQueue {
const count = Math.min(3, this.processedQueue.length);
const firstThree = this.processedQueue.slice(0, count);
firstThree.forEach((item, index) => {
console.log(
console.debug(
`Item ${index + 1}: ${item.location.range.start.line} to ${item.location.range.end.line}`,
);
});

View File

@@ -8,6 +8,7 @@ import { ContextRetrievalService } from "../autocomplete/context/ContextRetrieva
import { BracketMatchingService } from "../autocomplete/filtering/BracketMatchingService.js";
import { CompletionStreamer } from "../autocomplete/generation/CompletionStreamer.js";
import { postprocessCompletion } from "../autocomplete/postprocessing/index.js";
import { shouldPrefilter } from "../autocomplete/prefiltering/index.js";
import { getAllSnippetsWithoutRace } from "../autocomplete/snippets/index.js";
import { AutocompleteCodeSnippet } from "../autocomplete/snippets/types.js";
@@ -415,6 +416,7 @@ export class NextEditProvider {
filePath: helper.filepath,
diffType: DiffFormatType.Unified,
contextLines: 3,
workspaceDir: workspaceDirs[0], // Use first workspace directory
}),
};
@@ -468,33 +470,53 @@ export class NextEditProvider {
}
// Extract completion using model-specific logic.
const nextCompletion = this.modelProvider.extractCompletion(msg.content);
let nextCompletion = this.modelProvider.extractCompletion(msg.content);
// Postprocess the completion (same as autocomplete).
const postprocessed = postprocessCompletion({
completion: nextCompletion,
llm,
prefix: helper.prunedPrefix,
suffix: helper.prunedSuffix,
});
// Return early if postprocessing filtered out the completion.
if (!postprocessed) {
return undefined;
}
nextCompletion = postprocessed;
let outcome: NextEditOutcome | undefined;
// Handle based on diff type.
const profileType =
this.configHandler.currentProfile?.profileDescription.profileType;
if (opts?.usingFullFileDiff === false || !opts?.usingFullFileDiff) {
outcome = await this.modelProvider.handlePartialFileDiff(
outcome = await this.modelProvider.handlePartialFileDiff({
helper,
editableRegionStartLine,
editableRegionEndLine,
startTime,
llm,
nextCompletion,
this.promptMetadata!,
this.ide,
);
promptMetadata: this.promptMetadata!,
ide: this.ide,
profileType,
});
} else {
outcome = await this.modelProvider.handleFullFileDiff(
outcome = await this.modelProvider.handleFullFileDiff({
helper,
editableRegionStartLine,
editableRegionEndLine,
startTime,
llm,
nextCompletion,
this.promptMetadata!,
this.ide,
);
promptMetadata: this.promptMetadata!,
ide: this.ide,
profileType,
});
}
if (outcome) {

View File

@@ -1,4 +1,5 @@
import { createPatch } from "diff";
import { getUriPathBasename } from "../../util/uri";
export enum DiffFormatType {
Unified = "unified",
@@ -18,6 +19,7 @@ export interface CreateDiffArgs {
filePath: string;
diffType: DiffFormatType;
contextLines: number;
workspaceDir?: string;
}
export const createDiff = ({
@@ -26,6 +28,7 @@ export const createDiff = ({
filePath,
diffType,
contextLines,
workspaceDir,
}: CreateDiffArgs) => {
switch (diffType) {
case DiffFormatType.Unified:
@@ -34,6 +37,7 @@ export const createDiff = ({
afterContent,
filePath,
contextLines,
workspaceDir,
);
case DiffFormatType.TokenLineDiff:
return createTokenLineDiff(beforeContent, afterContent, filePath);
@@ -46,6 +50,7 @@ const createUnifiedDiff = (
afterContent: string,
filePath: string,
contextLines: number,
workspaceDir?: string,
) => {
const normalizedBefore = beforeContent.endsWith("\n")
? beforeContent
@@ -54,12 +59,21 @@ const createUnifiedDiff = (
? afterContent
: afterContent + "\n";
// Use relative path if workspace directory is provided
let displayPath = filePath;
if (workspaceDir && filePath.startsWith(workspaceDir)) {
displayPath = filePath.slice(workspaceDir.length).replace(/^[\/]/, "");
} else if (workspaceDir) {
// Fallback to just the basename if we can't determine relative path
displayPath = getUriPathBasename(filePath);
}
const patch = createPatch(
filePath,
displayPath,
normalizedBefore,
normalizedAfter,
"before",
"after",
undefined,
undefined,
{ context: contextLines },
);

View File

@@ -127,6 +127,72 @@ describe("diffFormatting", () => {
expect(result).toContain("@@ -4,5 +4,5 @@");
});
it("should use relative path when workspaceDir is provided", () => {
const args: CreateDiffArgs = {
beforeContent,
afterContent,
filePath: "file:///workspace/project/src/test.js",
diffType: DiffFormatType.Unified,
contextLines: 3,
workspaceDir: "file:///workspace/project",
};
const result = createDiff(args);
expect(result).toContain("--- src/test.js");
expect(result).toContain("+++ src/test.js");
expect(result).not.toContain("file://");
expect(result).not.toContain("/workspace/project");
});
it("should handle workspaceDir with trailing slash", () => {
const args: CreateDiffArgs = {
beforeContent,
afterContent,
filePath: "file:///workspace/project/src/test.js",
diffType: DiffFormatType.Unified,
contextLines: 3,
workspaceDir: "file:///workspace/project/",
};
const result = createDiff(args);
expect(result).toContain("--- src/test.js");
expect(result).toContain("+++ src/test.js");
});
it("should fallback to basename when path not in workspace", () => {
const args: CreateDiffArgs = {
beforeContent,
afterContent,
filePath: "file:///other/location/test.js",
diffType: DiffFormatType.Unified,
contextLines: 3,
workspaceDir: "file:///workspace/project",
};
const result = createDiff(args);
expect(result).toContain("--- test.js");
expect(result).toContain("+++ test.js");
});
it("should use full path when no workspaceDir provided", () => {
const fullPath = "file:///workspace/project/src/test.js";
const args: CreateDiffArgs = {
beforeContent,
afterContent,
filePath: fullPath,
diffType: DiffFormatType.Unified,
contextLines: 3,
};
const result = createDiff(args);
expect(result).toContain(`--- ${fullPath}`);
expect(result).toContain(`+++ ${fullPath}`);
});
});
describe("createBeforeAfterDiff", () => {

View File

@@ -140,6 +140,7 @@ export const processNextEditData = async ({
filePath: filePath,
diffType: DiffFormatType.Unified,
contextLines: 25, // storing many context lines for downstream trimming
workspaceDir: workspaceDir,
}),
fileUri: filePath,
workspaceUri: workspaceDir,

View File

@@ -14,16 +14,6 @@ export const processSmallEdit = async (
getDefsFromLspFunction: GetLspDefinitionsFunction,
ide: IDE,
) => {
NextEditProvider.getInstance().addDiffToContext(
createDiff({
beforeContent: beforeAfterdiff.beforeContent,
afterContent: beforeAfterdiff.afterContent,
filePath: beforeAfterdiff.filePath,
diffType: DiffFormatType.Unified,
contextLines: 5, // NOTE: This can change depending on experiments!
}),
);
// Get the current context data from the most recent message
const currentData = (EditAggregator.getInstance() as any)
.latestContextData || {
@@ -33,6 +23,17 @@ export const processSmallEdit = async (
recentlyVisitedRanges: [],
};
NextEditProvider.getInstance().addDiffToContext(
createDiff({
beforeContent: beforeAfterdiff.beforeContent,
afterContent: beforeAfterdiff.afterContent,
filePath: beforeAfterdiff.filePath,
diffType: DiffFormatType.Unified,
contextLines: 3, // NOTE: This can change depending on experiments!
workspaceDir: currentData.workspaceDir,
}),
);
void processNextEditData({
filePath: beforeAfterdiff.filePath,
beforeContent: beforeAfterdiff.beforeContent,

View File

@@ -47,16 +47,28 @@ export abstract class BaseNextEditModelProvider {
};
// Methods that can be used as default fallback.
public async handlePartialFileDiff(
helper: HelperVars,
editableRegionStartLine: number,
editableRegionEndLine: number,
startTime: number,
llm: ILLM,
nextCompletion: string,
promptMetadata: PromptMetadata,
ide: IDE,
): Promise<NextEditOutcome> {
public async handlePartialFileDiff(params: {
helper: HelperVars;
editableRegionStartLine: number;
editableRegionEndLine: number;
startTime: number;
llm: ILLM;
nextCompletion: string;
promptMetadata: PromptMetadata;
ide: IDE;
profileType?: "local" | "platform" | "control-plane";
}): Promise<NextEditOutcome> {
const {
helper,
editableRegionStartLine,
editableRegionEndLine,
startTime,
llm,
nextCompletion,
promptMetadata,
ide,
profileType,
} = params;
const oldEditRangeSlice = helper.fileContents
.split("\n")
.slice(editableRegionStartLine, editableRegionEndLine + 1)
@@ -83,21 +95,34 @@ export abstract class BaseNextEditModelProvider {
originalEditableRange: oldEditRangeSlice,
diffLines: [],
ide,
profileType,
});
return outcome;
}
public async handleFullFileDiff(
helper: HelperVars,
editableRegionStartLine: number,
editableRegionEndLine: number,
startTime: number,
llm: ILLM,
nextCompletion: string,
promptMetadata: PromptMetadata,
ide: IDE,
): Promise<NextEditOutcome | undefined> {
public async handleFullFileDiff(params: {
helper: HelperVars;
editableRegionStartLine: number;
editableRegionEndLine: number;
startTime: number;
llm: ILLM;
nextCompletion: string;
promptMetadata: PromptMetadata;
ide: IDE;
profileType?: "local" | "platform" | "control-plane";
}): Promise<NextEditOutcome | undefined> {
const {
helper,
editableRegionStartLine,
editableRegionEndLine,
startTime,
llm,
nextCompletion,
promptMetadata,
ide,
profileType,
} = params;
const fileSlice = helper.fileLines
.slice(editableRegionStartLine, editableRegionEndLine + 1)
.join("\n");
@@ -111,7 +136,7 @@ export abstract class BaseNextEditModelProvider {
const currentLine = helper.pos.line;
const prefetchQueue = PrefetchQueue.getInstance();
const cursorLocalDiffGroup = await this.processDiffGroups(
const cursorLocalDiffGroup = await this.processDiffGroups({
diffGroups,
currentLine,
helper,
@@ -120,19 +145,21 @@ export abstract class BaseNextEditModelProvider {
prefetchQueue,
promptMetadata,
ide,
);
profileType,
});
if (cursorLocalDiffGroup) {
return await this.createOutcomeFromDiffGroup(
cursorLocalDiffGroup,
return await this.createOutcomeFromDiffGroup({
diffGroup: cursorLocalDiffGroup,
helper,
startTime,
llm,
helper.input.completionId,
true,
completionId: helper.input.completionId,
isCurrentCursorGroup: true,
promptMetadata,
ide,
);
profileType,
});
}
return undefined;
@@ -141,23 +168,35 @@ export abstract class BaseNextEditModelProvider {
/**
* Process diff groups and find the one containing the cursor.
*/
private async processDiffGroups(
diffGroups: DiffGroup[],
currentLine: number,
helper: HelperVars,
startTime: number,
llm: ILLM,
prefetchQueue: PrefetchQueue,
promptMetadata: PromptMetadata,
ide: IDE,
): Promise<DiffGroup | undefined> {
private async processDiffGroups(params: {
diffGroups: DiffGroup[];
currentLine: number;
helper: HelperVars;
startTime: number;
llm: ILLM;
prefetchQueue: PrefetchQueue;
promptMetadata: PromptMetadata;
ide: IDE;
profileType?: "local" | "platform" | "control-plane";
}): Promise<DiffGroup | undefined> {
const {
diffGroups,
currentLine,
helper,
startTime,
llm,
prefetchQueue,
promptMetadata,
ide,
profileType,
} = params;
let cursorGroup: DiffGroup | undefined;
for (const group of diffGroups) {
if (currentLine >= group.startLine && currentLine <= group.endLine) {
cursorGroup = group;
} else {
await this.addDiffGroupToPrefetchQueue(
await this.addDiffGroupToPrefetchQueue({
group,
helper,
startTime,
@@ -165,22 +204,34 @@ export abstract class BaseNextEditModelProvider {
prefetchQueue,
promptMetadata,
ide,
);
profileType,
});
}
}
return cursorGroup;
}
private async addDiffGroupToPrefetchQueue(
group: DiffGroup,
helper: HelperVars,
startTime: number,
llm: ILLM,
prefetchQueue: PrefetchQueue,
promptMetadata: PromptMetadata,
ide: IDE,
): Promise<void> {
private async addDiffGroupToPrefetchQueue(params: {
group: DiffGroup;
helper: HelperVars;
startTime: number;
llm: ILLM;
prefetchQueue: PrefetchQueue;
promptMetadata: PromptMetadata;
ide: IDE;
profileType?: "local" | "platform" | "control-plane";
}): Promise<void> {
const {
group,
helper,
startTime,
llm,
prefetchQueue,
promptMetadata,
ide,
profileType,
} = params;
// Extract lines that are not old.
const groupContent = group.lines
.filter((l) => l.type !== "old")
@@ -223,6 +274,7 @@ export abstract class BaseNextEditModelProvider {
completionId: uuidv4(), // Generate a new ID for this prefetched item.
diffLines: group.lines,
ide,
profileType,
});
prefetchQueue.enqueueProcessed({
@@ -231,16 +283,28 @@ export abstract class BaseNextEditModelProvider {
});
}
private async createOutcomeFromDiffGroup(
diffGroup: DiffGroup,
helper: HelperVars,
startTime: number,
llm: ILLM,
completionId: string,
isCurrentCursorGroup: boolean,
promptMetadata: PromptMetadata,
ide: IDE,
): Promise<NextEditOutcome> {
private async createOutcomeFromDiffGroup(params: {
diffGroup: DiffGroup;
helper: HelperVars;
startTime: number;
llm: ILLM;
completionId: string;
isCurrentCursorGroup: boolean;
promptMetadata: PromptMetadata;
ide: IDE;
profileType?: "local" | "platform" | "control-plane";
}): Promise<NextEditOutcome> {
const {
diffGroup,
helper,
startTime,
llm,
completionId,
isCurrentCursorGroup,
promptMetadata,
ide,
profileType,
} = params;
const groupContent = diffGroup.lines
.filter((l) => l.type !== "old")
.map((l) => l.line)
@@ -278,6 +342,7 @@ export abstract class BaseNextEditModelProvider {
completionId,
diffLines: diffGroup.lines,
ide,
profileType,
});
return outcomeNext;
@@ -299,6 +364,7 @@ export abstract class BaseNextEditModelProvider {
completionId?: string;
diffLines: DiffLine[];
ide: IDE;
profileType?: "local" | "platform" | "control-plane";
}): Promise<NextEditOutcome> {
return {
elapsed: Date.now() - outcomeCtx.startTime,
@@ -325,6 +391,7 @@ export abstract class BaseNextEditModelProvider {
editableRegionStartLine: outcomeCtx.editableRegionStartLine,
editableRegionEndLine: outcomeCtx.editableRegionEndLine,
diffLines: outcomeCtx.diffLines,
profileType: outcomeCtx.profileType,
...outcomeCtx.helper.options,
};
}

View File

@@ -68,6 +68,7 @@ export interface NextEditOutcome extends TabAutocompleteOptions {
editableRegionStartLine: number;
editableRegionEndLine: number;
diffLines: DiffLine[];
profileType?: "local" | "platform" | "control-plane";
}
export interface PromptMetadata {

View File

@@ -2,7 +2,7 @@
"name": "continue",
"icon": "media/icon.png",
"author": "Continue Dev, Inc",
"version": "1.3.15",
"version": "1.2.8",
"repository": {
"type": "git",
"url": "https://github.com/continuedev/continue"

View File

@@ -135,7 +135,7 @@ export class GhostTextAcceptanceTracker {
);
if (wasGhostTextAccepted) {
console.log(
console.debug(
"GhostTextAcceptanceTracker: ghost text was accepted, preserving chain",
);
return true;

View File

@@ -3,9 +3,6 @@ import { AutocompleteCodeSnippet } from "core/autocomplete/snippets/types";
import { GetLspDefinitionsFunction } from "core/autocomplete/types";
import { ConfigHandler } from "core/config/ConfigHandler";
import { RecentlyEditedRange } from "core/nextEdit/types";
import { getContinueGlobalPath, isFileWithinFolder } from "core/util/paths";
import fs from "fs";
import { resolve } from "path";
import * as vscode from "vscode";
import { ContinueCompletionProvider } from "../autocomplete/completionProvider";
@@ -21,23 +18,6 @@ export const getBeforeCursorPos = (range: Range, activePos: Position) => {
}
};
const isEditLoggingAllowed = async (editedFileURI: string) => {
const globalContinuePath = getContinueGlobalPath();
const editLogDirsPath = resolve(globalContinuePath, ".editlogdirs");
try {
const fileContent = await fs.promises.readFile(editLogDirsPath, "utf-8");
const stringItems = fileContent
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
return stringItems.some((dir) => isFileWithinFolder(editedFileURI, dir));
} catch (error) {
return false;
}
};
const getWorkspaceDirUri = async (event: vscode.TextDocumentChangeEvent) => {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(
event.document.uri,
@@ -68,7 +48,6 @@ export const handleTextDocumentChange = async (
// Ensure that logging will only happen in the open-source continue repo
const workspaceDirUri = await getWorkspaceDirUri(event);
if (!workspaceDirUri) return;
if (!(await isEditLoggingAllowed(event.document.uri.toString()))) return;
const activeCursorPos = editor.selection.active;
const editActions: RangeInFileWithNextEditInfo[] = changes.map((change) => ({