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
93 changes: 87 additions & 6 deletions ai/src/utils/aider-diff-merger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,20 +248,58 @@ function parseBlocks(blockContent: string): Omit<AiderDiffBlock, "filePath">[] {
return blocks
}

/**
* Returns true if the line uses `...` as a code wildcard (not inside a comment).
* e.g. `cva(...)`, `foo(...)`, `{ ... }` — but NOT `// ... existing code ...`
*/
function isEllipsisWildcard(line: string): boolean {
const trimmed = line.trim()
if (
trimmed.startsWith("//") ||
trimmed.startsWith("/*") ||
trimmed.startsWith("*") ||
trimmed.startsWith("#")
) {
return false
}
return trimmed.includes("...")
}

/**
* Checks if a code line matches a search line that contains a `...` wildcard.
* Only the prefix (before `...`) is required. The suffix (after `...`) is
* checked when it appears on the same line but is optional for multi-line spans.
*/
function lineMatchesWithEllipsis(
codeLine: string,
searchLine: string,
): boolean {
const trimmedCode = codeLine.trim()
const trimmedSearch = searchLine.trim()

const ellipsisIdx = trimmedSearch.indexOf("...")
const prefix = trimmedSearch.substring(0, ellipsisIdx)
const suffix = trimmedSearch.substring(ellipsisIdx + 3)

if (!trimmedCode.startsWith(prefix)) return false
if (suffix.length > 0 && !trimmedCode.endsWith(suffix)) return false
return true
}

/**
* Finds the starting index of a block in the code
* Tries exact match first (including whitespace), then normalized match (ignoring leading whitespace)
* Tries exact match first (including whitespace), then normalized match (ignoring leading whitespace),
* then ellipsis-aware match (treating `...` in non-comment lines as wildcards).
* Returns -1 for empty search blocks (new files) instead of null
*/
function findBlockInCode(code: string, searchLines: string[]): number | null {
// Empty search block means it's a new file - return -1 as a special marker
if (searchLines.length === 0) {
return -1
}

const codeLines = code.split("\n")

// Try to find exact match first (including whitespace)
// Pass 1: exact match (including whitespace)
for (let i = 0; i <= codeLines.length - searchLines.length; i++) {
let match = true
for (let j = 0; j < searchLines.length; j++) {
Expand All @@ -275,8 +313,7 @@ function findBlockInCode(code: string, searchLines: string[]): number | null {
}
}

// If exact match fails, try normalized match (ignoring leading whitespace)
// This handles cases where indentation might differ slightly
// Pass 2: normalized match (ignoring leading/trailing whitespace)
for (let i = 0; i <= codeLines.length - searchLines.length; i++) {
let match = true
for (let j = 0; j < searchLines.length; j++) {
Expand All @@ -292,6 +329,28 @@ function findBlockInCode(code: string, searchLines: string[]): number | null {
}
}

// Pass 3: ellipsis-aware match — `...` in non-comment lines acts as a wildcard
const hasEllipsis = searchLines.some((l) => isEllipsisWildcard(l))
if (hasEllipsis) {
for (let i = 0; i <= codeLines.length - searchLines.length; i++) {
let match = true
for (let j = 0; j < searchLines.length; j++) {
if (isEllipsisWildcard(searchLines[j])) {
if (!lineMatchesWithEllipsis(codeLines[i + j], searchLines[j])) {
match = false
break
}
} else {
if (codeLines[i + j].trim() !== searchLines[j].trim()) {
match = false
break
}
}
}
if (match) return i
}
}

return null
}

Expand Down Expand Up @@ -356,6 +415,7 @@ export function mergeAiderDiff(
): string {
const blocks = parseAiderDiff(diffSnippet, filePath)


if (blocks.length === 0) {
return originalCode
}
Expand Down Expand Up @@ -390,10 +450,31 @@ export function mergeAiderDiff(
const codeLines = result.split("\n")
const matchedOriginalLines = codeLines.slice(searchStart, searchEnd)

// Expand `...` wildcards in replace lines with the original matched content
const expandedReplaceLines = block.replaceLines.map((replaceLine) => {
if (!isEllipsisWildcard(replaceLine)) return replaceLine

const replaceTrimmed = replaceLine.trim()
const replaceEllipsisIdx = replaceTrimmed.indexOf("...")
const replacePrefix = replaceTrimmed.substring(0, replaceEllipsisIdx)

for (let i = 0; i < block.searchLines.length; i++) {
if (!isEllipsisWildcard(block.searchLines[i])) continue
const searchTrimmed = block.searchLines[i].trim()
const searchEllipsisIdx = searchTrimmed.indexOf("...")
const searchPrefix = searchTrimmed.substring(0, searchEllipsisIdx)

if (replacePrefix === searchPrefix && i < matchedOriginalLines.length) {
return matchedOriginalLines[i]
}
}
return replaceLine
})

// Preserve indentation from original code for unchanged lines
const replacementLines = preserveIndentation(
matchedOriginalLines,
block.replaceLines,
expandedReplaceLines,
block.searchLines,
)
const replacement = replacementLines.join("\n")
Expand Down
5 changes: 4 additions & 1 deletion ai/src/utils/prompt-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,17 @@ MANDATORY Rules for code changes:
- Format using triple backticks with the appropriate language identifier
- CRITICAL: Always specify the complete file path relative to the project root
- For new files, add "(new file)" after the path
- Before any code block, include a line like "File: /path/to/file.ext" to indicate which file the code belongs to
- Before every code block, include a line that shows FULL Path to the file like "File: /path/to/file.ext" to indicate which file the code belongs to
- Keep responses brief and to the point
- Use aider diff format: \`<<<<<<< SEARCH\` / \`=======\` / \`>>>>>>> REPLACE\` blocks inside code blocks
- If multiple search/replace blocks are for the same file, group them in the same code block
- For NEW FILES: Use an empty SEARCH block (just \`<<<<<<< SEARCH\` followed immediately by \`=======\`) and put the entire file content in the REPLACE block

🚨 NEVER show complete files for EXISTING files. ALWAYS use "// ... existing code ..." comments for unchanged sections.
🚨 For NEW FILES: Show the complete file content in the REPLACE block with an empty SEARCH block.
🚨 NEVER use "..." to abbreviate actual code. Every code line in SEARCH/REPLACE blocks must be COMPLETE.
- "..." is ONLY allowed inside comment lines like "// ... existing code ..."
- Do NOT write abbreviated expressions like \`cva(...)\`, \`function(...)\`, \`<Component ...>\`, \`{...}\` — write the COMPLETE line of code or skip the unchanged region entirely with a comment.

Example format for additions:
File: /src/components/Button.tsx
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion web/components/project/chat/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ function ChatInputContextMenu() {
{files.map((file) => {
const imgSrc = `/icons/${getIconForFile(file.name)}`
const isSelected = codeContextTabs.some(
(tab) => tab.name === file.name,
(tab) => (tab.path ?? tab.name) === file.id,
)
return (
<CommandItem
Expand All @@ -634,6 +634,7 @@ function ChatInputContextMenu() {
id: file.id,
type: "code",
name: file.name,
path: file.id,
})}
>
<Image
Expand Down
51 changes: 39 additions & 12 deletions web/components/project/chat/components/generated-files-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,25 @@ export function GeneratedFilesPreview({
}>({ files: [], sourceKey: null })

React.useEffect(() => {
setExtracted(extractFilesFromMessages(messages))
const next = extractFilesFromMessages(messages)
setExtracted((prev) => {
if (prev.sourceKey !== next.sourceKey) return next
if (prev.files.length !== next.files.length) return next

for (let i = 0; i < prev.files.length; i++) {
const a = prev.files[i]
const b = next.files[i]
if (
a.path !== b.path ||
(a.code ?? "") !== (b.code ?? "") ||
!!a.isNew !== !!b.isNew
) {
return next
}
}

return prev
})
}, [messages])

const providedFiles = files ?? []
Expand Down Expand Up @@ -156,12 +174,15 @@ export function GeneratedFilesPreview({

React.useEffect(() => {
setMergeStatuses((prev) => {
const next: Record<string, MergeState> = {}
let changed = false
const next: Record<string, MergeState> = { ...prev }
generatedFiles.forEach((file) => {
// Only set to idle if it doesn't exist, preserve existing statuses
next[file.path] = prev[file.path] ?? { status: "idle" as const }
if (!next[file.path]) {
next[file.path] = { status: "idle" as const }
changed = true
}
})
return next
return changed ? next : prev
})
}, [generatedFiles])

Expand All @@ -177,7 +198,8 @@ export function GeneratedFilesPreview({
const currentPrecomputeMerge = precomputeMergeRef.current

// Collect all files to process first, then start ALL merges in parallel
const filesToProcess: Array<{ key: string; code: string }> = []
const filesToProcess: Array<{ key: string; code: string; isNew?: boolean }> =
[]

generatedFilesRef.current.forEach((file) => {
if (!file.code) return
Expand Down Expand Up @@ -213,14 +235,14 @@ export function GeneratedFilesPreview({

// Mark as processed immediately to prevent duplicates
processedFilesRef.current.add(key)
filesToProcess.push({ key, code: file.code })
filesToProcess.push({ key, code: file.code, isNew: file.isNew })
})

// Only proceed if there are files to process
if (filesToProcess.length === 0) return

// Start ALL merges in parallel (not sequential)
filesToProcess.forEach(({ key, code }) => {
filesToProcess.forEach(({ key, code, isNew }) => {
// Set pending status immediately
setMergeStatuses((prev) => ({
...prev,
Expand All @@ -230,6 +252,7 @@ export function GeneratedFilesPreview({
const mergePromise = currentPrecomputeMerge({
filePath: key,
code: code,
isNew,
})
mergeJobsRef.current.set(key, mergePromise)

Expand Down Expand Up @@ -360,7 +383,11 @@ export function GeneratedFilesPreview({

let job = mergeJobsRef.current.get(key)
if (!job && file.code && precomputeMerge) {
job = precomputeMerge({ filePath: key, code: file.code })
job = precomputeMerge({
filePath: key,
code: file.code,
isNew: file.isNew,
})
mergeJobsRef.current.set(key, job)
setMergeStatuses((prev) => ({
...prev,
Expand Down Expand Up @@ -467,10 +494,10 @@ export function GeneratedFilesPreview({
</div>
<div
className={cn(
"space-y-1 overflow-hidden transition-all ease-out",
"space-y-1 transition-all ease-out",
isOpen
? "max-h-48 opacity-100 duration-700"
: "max-h-0 opacity-0 duration-500",
? "max-h-36 overflow-y-auto opacity-100 duration-700"
: "max-h-0 overflow-hidden opacity-0 duration-500",
)}
>
{visibleFiles.map((file) => {
Expand Down
2 changes: 2 additions & 0 deletions web/components/project/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ function MainChatContent({

const wrappedOnApplyCode = React.useCallback(
async (code: string, language?: string): Promise<void> => {

if (onApplyCode) {
await onApplyCode(code, language, {
mergeStatuses,
Expand Down Expand Up @@ -323,6 +324,7 @@ function ChatContexts() {
start: selection.startLineNumber,
end: selection.endLineNumber,
},
path: activeTab.id,
})
}
},
Expand Down
Loading
Loading