diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..6eb442a1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,11 @@ +{ + "name": "Default Linux Universal", + "image": "mcr.microsoft.com/devcontainers/javascript-node:22", + "features": { + "ghcr.io/devcontainer-community/devcontainer-features/bun.sh:1": {} + }, + "runArgs": [ + "--memory=4g", + "--memory-swap=4g" + ] +} \ No newline at end of file diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 94f24ce9..2159047d 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -41,6 +41,51 @@ describe("prompt-input history", () => { expect(dedupedComments).toBe(commentsOnly) }) + const filePrompt = (path: string, selection?: { startLine: number; startChar: number; endLine: number; endChar: number }): Prompt => [ + { type: "file", path, content: `@${path}`, start: 0, end: path.length + 1, selection }, + ] + const agentPrompt = (name: string): Prompt => [{ type: "agent", name, content: `@${name}`, start: 0, end: name.length + 1 }] + const imagePrompt = (id: string): Prompt => [ + { type: "image", id, filename: "img.png", mime: "image/png", blob: { id, url: `blob:${id}` } }, + ] + + test("prependHistoryEntry deduplicates file parts only when path and selection match", () => { + const selection = { startLine: 1, startChar: 0, endLine: 2, endChar: 3 } + const withFile = prependHistoryEntry([], filePrompt("src/a.ts", selection)) + expect(withFile).toHaveLength(1) + + const sameFile = prependHistoryEntry(withFile, filePrompt("src/a.ts", { ...selection })) + expect(sameFile).toBe(withFile) + + const differentPath = prependHistoryEntry(withFile, filePrompt("src/b.ts", selection)) + expect(differentPath).toHaveLength(2) + + const differentSelection = prependHistoryEntry(withFile, filePrompt("src/a.ts", { ...selection, endChar: 9 })) + expect(differentSelection).toHaveLength(2) + + const noSelection = prependHistoryEntry(withFile, filePrompt("src/a.ts", undefined)) + expect(noSelection).toHaveLength(2) + }) + + test("prependHistoryEntry deduplicates agent and image parts by identity", () => { + const withAgent = prependHistoryEntry([], agentPrompt("reviewer")) + expect(prependHistoryEntry(withAgent, agentPrompt("reviewer"))).toBe(withAgent) + expect(prependHistoryEntry(withAgent, agentPrompt("other"))).toHaveLength(2) + + const withImage = prependHistoryEntry([], imagePrompt("img-1")) + expect(prependHistoryEntry(withImage, imagePrompt("img-1"))).toBe(withImage) + expect(prependHistoryEntry(withImage, imagePrompt("img-2"))).toHaveLength(2) + }) + + test("prependHistoryEntry treats mismatched part types and comment fields as distinct entries", () => { + const withText = prependHistoryEntry([], text("hello")) + expect(prependHistoryEntry(withText, filePrompt("src/a.ts", undefined))).toHaveLength(2) + + const withComment = prependHistoryEntry([], text("hello"), [comment("c1")]) + const differentCommentText = prependHistoryEntry(withComment, text("hello"), [comment("c1", "different")]) + expect(differentCommentText).toHaveLength(2) + }) + test("navigatePromptHistory restores saved prompt when moving down from newest", () => { const entries = [text("third"), text("second"), text("first")] const up = navigatePromptHistory({ diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index 79e8abc0..3dff7910 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -112,39 +112,37 @@ function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryC ) } +type PromptPart = Prompt[number] +type FilePromptPart = Extract + +function isFileSelectionEqual(a: FilePromptPart["selection"], b: FilePromptPart["selection"]) { + if (!a && !b) return true + if (!a || !b) return false + return a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar +} + +const PART_COMPARATORS: Record boolean> = { + text: (a, b) => a.type === "text" && b.type === "text" && a.content === b.content, + file: (a, b) => + a.type === "file" && b.type === "file" && a.path === b.path && isFileSelectionEqual(a.selection, b.selection), + agent: (a, b) => a.type === "agent" && b.type === "agent" && a.name === b.name, + image: (a, b) => a.type === "image" && b.type === "image" && a.id === b.id, +} + +function arePromptPartsEqual(partA: PromptPart, partB: PromptPart): boolean { + if (partA.type !== partB.type) return false + return PART_COMPARATORS[partA.type](partA, partB) +} + function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) { const entryA = normalizePromptHistoryEntry(promptA) const entryB = normalizePromptHistoryEntry(promptB) if (entryA.prompt.length !== entryB.prompt.length) return false - for (let i = 0; i < entryA.prompt.length; i++) { - const partA = entryA.prompt[i] - const partB = entryB.prompt[i] - if (partA.type !== partB.type) return false - if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false - if (partA.type === "file") { - if (partA.path !== (partB.type === "file" ? partB.path : "")) return false - const a = partA.selection - const b = partB.type === "file" ? partB.selection : undefined - const sameSelection = - (!a && !b) || - (!!a && - !!b && - a.startLine === b.startLine && - a.startChar === b.startChar && - a.endLine === b.endLine && - a.endChar === b.endChar) - if (!sameSelection) return false - } - if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false - if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false - } if (entryA.comments.length !== entryB.comments.length) return false - for (let i = 0; i < entryA.comments.length; i++) { - const commentA = entryA.comments[i] - const commentB = entryB.comments[i] - if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false - } - return true + return ( + entryA.prompt.every((part, i) => arePromptPartsEqual(part, entryB.prompt[i])) && + entryA.comments.every((comment, i) => isCommentEqual(comment, entryB.comments[i])) + ) } type HistoryNavInput = {