Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
45 changes: 45 additions & 0 deletions packages/app/src/components/prompt-input/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
54 changes: 26 additions & 28 deletions packages/app/src/components/prompt-input/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,39 +112,37 @@ function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryC
)
}

type PromptPart = Prompt[number]
type FilePromptPart = Extract<PromptPart, { type: "file" }>

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<PromptPart["type"], (a: PromptPart, b: PromptPart) => 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 = {
Expand Down
Loading