fix: handle multi-file tool diffs safely

Split multi-file patches before diff rendering
Fixed hook dependency lint warnings
This commit is contained in:
Bohdan Triapitsyn
2026-04-26 16:58:07 +03:00
parent 8f3707a5f4
commit 209ffc16e4
22 changed files with 116 additions and 57 deletions
@@ -2341,7 +2341,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.clipboardAttachFailed'));
}
}
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]);
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection, t]);
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
@@ -2650,7 +2650,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setPendingInputText(mentions.join(' '), 'append-inline');
toast.success(t('chat.chatInput.toast.addedFileMentions', { count: mentions.length }));
}, [normalizeDroppedPath, setPendingInputText, toProjectRelativeMentionPath]);
}, [normalizeDroppedPath, setPendingInputText, t, toProjectRelativeMentionPath]);
const handleDragEnter = (e: React.DragEvent) => {
if (!hasDraggedFiles(e.dataTransfer)) {
@@ -2897,7 +2897,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
cancelled = true;
if (unlisten) unlisten();
};
}, [addAttachedFile, normalizeDroppedPath]);
}, [addAttachedFile, normalizeDroppedPath, t]);
const fileInputRef = React.useRef<HTMLInputElement>(null);
@@ -2912,7 +2912,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed'));
}
}
}, [addAttachedFile]);
}, [addAttachedFile, t]);
const handleVSCodePickFiles = React.useCallback(async () => {
try {
@@ -2956,7 +2956,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
console.error('VS Code file pick failed', error);
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed'));
}
}, [attachFiles]);
}, [attachFiles, t]);
const handlePickLocalFiles = React.useCallback(() => {
if (isVSCodeRuntime()) {
@@ -3268,7 +3268,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(() => {
toast.error(t('chat.chatInput.toast.togglePermissionAutoAcceptFailed'));
});
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept, t]);
React.useEffect(() => {
const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
@@ -1010,7 +1010,7 @@ const AssistantMessageBody = React.memo(({
setIsSavingPlan(false);
}
},
[assistantPlanText, currentProjectRef]
[assistantPlanText, currentProjectRef, t]
);
const [isSharing, setIsSharing] = React.useState(false);
@@ -1123,7 +1123,7 @@ const AssistantMessageBody = React.memo(({
setIsSharing(false);
}
},
[messageId, isSharing]
[messageId, isSharing, t]
);
React.useEffect(() => {
@@ -534,7 +534,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
} finally {
setIsAddingToNotes(false);
}
}, [currentProjectRef, hideMenu, selectedText]);
}, [currentProjectRef, hideMenu, selectedText, t]);
if (!position.show) return null;
@@ -782,7 +782,7 @@ const MermaidPreviewDialog: React.FC<{
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed'));
});
}, [decodeDataUrl, normalizeFilePath, popup.mermaid]);
}, [decodeDataUrl, normalizeFilePath, popup.mermaid, t]);
React.useEffect(() => {
if (!popup.open || !popup.mermaid) {
@@ -1243,6 +1243,56 @@ type DiffPatchEntry = {
patch: string;
};
const hasUnifiedDiffHunk = (patch: string): boolean => /^@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@/m.test(patch);
const getUnifiedDiffPath = (patch: string, fallbackTitle: string): string => {
const plusHeader = patch.match(/^\+\+\+\s+(?:[ab]\/(.+)|(.+))$/m);
const rawPath = plusHeader?.[1] ?? plusHeader?.[2];
if (!rawPath || rawPath === '/dev/null') {
return fallbackTitle;
}
return rawPath;
};
const splitUnifiedDiffPatch = (patch: string): DiffPatchEntry[] => {
const normalized = patch.replace(/\r\n/g, '\n').trim();
if (!normalized) {
return [];
}
const lines = normalized.split('\n');
const starts: number[] = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? '';
const nextLine = lines[index + 1] ?? '';
const isUnifiedFileHeader = /^---\s+(?:[ab]\/|\/dev\/null|\/)/.test(line)
&& /^\+\+\+\s+(?:[ab]\/|\/dev\/null|\/)/.test(nextLine);
if (line.startsWith('diff --git ') || line.startsWith('Index: ') || isUnifiedFileHeader) {
starts.push(index);
}
}
const chunks = starts.length > 0
? starts.map((start, index) => lines.slice(start, starts[index + 1] ?? lines.length).join('\n').trim())
: [normalized];
return chunks
.map((chunk, index) => {
if (!hasUnifiedDiffHunk(chunk)) {
return null;
}
const title = getUnifiedDiffPath(chunk, `Diff ${index + 1}`);
return {
id: `${title}-${index}`,
title,
patch: chunk,
} satisfies DiffPatchEntry;
})
.filter((entry): entry is DiffPatchEntry => entry !== null);
};
const renderPathLikeGitChanges = (path: string, grow = true) => {
const lastSlash = path.lastIndexOf('/');
if (lastSlash === -1) {
@@ -1338,7 +1388,7 @@ const getDiffPatchEntries = (
const record = file as { relativePath?: unknown; filePath?: unknown; patch?: unknown; diff?: unknown };
const patch = getPatchText(record.patch) ?? getPatchText(record.diff) ?? '';
if (!patch) {
if (!patch || !hasUnifiedDiffHunk(patch)) {
return null;
}
@@ -1364,13 +1414,16 @@ const getDiffPatchEntries = (
return entries;
}
return [
{
id: 'diff-0',
title: 'Diff',
patch: fallbackDiff,
},
];
const splitEntries = splitUnifiedDiffPatch(fallbackDiff).map((entry) => ({
...entry,
title: getRelativePath(entry.title, currentDirectory),
}));
if (splitEntries.length > 0) {
return splitEntries;
}
return [];
};
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {