feat: improve sidebar layout (#112)
* improve diff box styling * separate logs and diffs tabs * improve sidebar layout * fix tsc errors
This commit is contained in:
committed by
GitHub
parent
ed7c2a31ce
commit
e4188ed949
476
frontend/src/components/tasks/DiffCard.tsx
Normal file
476
frontend/src/components/tasks/DiffCard.tsx
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ChevronDown, ChevronUp, Trash2, GitCompare } from 'lucide-react';
|
||||||
|
import type { WorktreeDiff, DiffChunkType, DiffChunk } from 'shared/types';
|
||||||
|
|
||||||
|
interface ProcessedLine {
|
||||||
|
content: string;
|
||||||
|
chunkType: DiffChunkType;
|
||||||
|
oldLineNumber?: number;
|
||||||
|
newLineNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessedSection {
|
||||||
|
type: 'context' | 'change' | 'expanded';
|
||||||
|
lines: ProcessedLine[];
|
||||||
|
expandKey?: string;
|
||||||
|
expandedAbove?: boolean;
|
||||||
|
expandedBelow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffCardProps {
|
||||||
|
diff: WorktreeDiff | null;
|
||||||
|
isBackgroundRefreshing?: boolean;
|
||||||
|
onDeleteFile?: (filePath: string) => void;
|
||||||
|
deletingFiles?: Set<string>;
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffCard({
|
||||||
|
diff,
|
||||||
|
isBackgroundRefreshing = false,
|
||||||
|
onDeleteFile,
|
||||||
|
deletingFiles = new Set(),
|
||||||
|
compact = false,
|
||||||
|
className = '',
|
||||||
|
}: DiffCardProps) {
|
||||||
|
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Diff processing functions
|
||||||
|
const getChunkClassName = (chunkType: DiffChunkType) => {
|
||||||
|
const baseClass = 'font-mono text-sm whitespace-pre flex w-full';
|
||||||
|
|
||||||
|
switch (chunkType) {
|
||||||
|
case 'Insert':
|
||||||
|
return `${baseClass} bg-green-50 dark:bg-green-900/20 text-green-900 dark:text-green-100`;
|
||||||
|
case 'Delete':
|
||||||
|
return `${baseClass} bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-100`;
|
||||||
|
case 'Equal':
|
||||||
|
default:
|
||||||
|
return `${baseClass} text-muted-foreground`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLineNumberClassName = (chunkType: DiffChunkType) => {
|
||||||
|
const baseClass =
|
||||||
|
'flex-shrink-0 w-12 px-1.5 text-xs border-r select-none min-h-[1.25rem] flex items-center';
|
||||||
|
|
||||||
|
switch (chunkType) {
|
||||||
|
case 'Insert':
|
||||||
|
return `${baseClass} text-green-800 dark:text-green-200 bg-green-100 dark:bg-green-900/40 border-green-300 dark:border-green-600`;
|
||||||
|
case 'Delete':
|
||||||
|
return `${baseClass} text-red-800 dark:text-red-200 bg-red-100 dark:bg-red-900/40 border-red-300 dark:border-red-600`;
|
||||||
|
case 'Equal':
|
||||||
|
default:
|
||||||
|
return `${baseClass} text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChunkPrefix = (chunkType: DiffChunkType) => {
|
||||||
|
switch (chunkType) {
|
||||||
|
case 'Insert':
|
||||||
|
return '+';
|
||||||
|
case 'Delete':
|
||||||
|
return '-';
|
||||||
|
case 'Equal':
|
||||||
|
default:
|
||||||
|
return ' ';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processFileChunks = (chunks: DiffChunk[], fileIndex: number) => {
|
||||||
|
const CONTEXT_LINES = compact ? 2 : 3;
|
||||||
|
const lines: ProcessedLine[] = [];
|
||||||
|
let oldLineNumber = 1;
|
||||||
|
let newLineNumber = 1;
|
||||||
|
|
||||||
|
// Convert chunks to lines with line numbers
|
||||||
|
chunks.forEach((chunk) => {
|
||||||
|
const chunkLines = chunk.content.split('\n');
|
||||||
|
chunkLines.forEach((line, index) => {
|
||||||
|
if (index < chunkLines.length - 1 || line !== '') {
|
||||||
|
const processedLine: ProcessedLine = {
|
||||||
|
content: line,
|
||||||
|
chunkType: chunk.chunk_type,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (chunk.chunk_type) {
|
||||||
|
case 'Equal':
|
||||||
|
processedLine.oldLineNumber = oldLineNumber++;
|
||||||
|
processedLine.newLineNumber = newLineNumber++;
|
||||||
|
break;
|
||||||
|
case 'Delete':
|
||||||
|
processedLine.oldLineNumber = oldLineNumber++;
|
||||||
|
break;
|
||||||
|
case 'Insert':
|
||||||
|
processedLine.newLineNumber = newLineNumber++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(processedLine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections: ProcessedSection[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (line.chunkType === 'Equal') {
|
||||||
|
let nextChangeIndex = i + 1;
|
||||||
|
while (
|
||||||
|
nextChangeIndex < lines.length &&
|
||||||
|
lines[nextChangeIndex].chunkType === 'Equal'
|
||||||
|
) {
|
||||||
|
nextChangeIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextLength = nextChangeIndex - i;
|
||||||
|
const hasNextChange = nextChangeIndex < lines.length;
|
||||||
|
const hasPrevChange =
|
||||||
|
sections.length > 0 &&
|
||||||
|
sections[sections.length - 1].type === 'change';
|
||||||
|
|
||||||
|
if (
|
||||||
|
contextLength <= CONTEXT_LINES * 2 ||
|
||||||
|
(!hasPrevChange && !hasNextChange)
|
||||||
|
) {
|
||||||
|
sections.push({
|
||||||
|
type: 'context',
|
||||||
|
lines: lines.slice(i, nextChangeIndex),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (hasPrevChange) {
|
||||||
|
sections.push({
|
||||||
|
type: 'context',
|
||||||
|
lines: lines.slice(i, i + CONTEXT_LINES),
|
||||||
|
});
|
||||||
|
i += CONTEXT_LINES;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNextChange) {
|
||||||
|
const expandStart = hasPrevChange ? i : i + CONTEXT_LINES;
|
||||||
|
const expandEnd = nextChangeIndex - CONTEXT_LINES;
|
||||||
|
|
||||||
|
if (expandEnd > expandStart) {
|
||||||
|
const expandKey = `${fileIndex}-${expandStart}-${expandEnd}`;
|
||||||
|
const isExpanded = expandedSections.has(expandKey);
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
sections.push({
|
||||||
|
type: 'expanded',
|
||||||
|
lines: lines.slice(expandStart, expandEnd),
|
||||||
|
expandKey,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sections.push({
|
||||||
|
type: 'context',
|
||||||
|
lines: [],
|
||||||
|
expandKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
type: 'context',
|
||||||
|
lines: lines.slice(
|
||||||
|
nextChangeIndex - CONTEXT_LINES,
|
||||||
|
nextChangeIndex
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if (!hasPrevChange) {
|
||||||
|
sections.push({
|
||||||
|
type: 'context',
|
||||||
|
lines: lines.slice(i, i + CONTEXT_LINES),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i = nextChangeIndex;
|
||||||
|
} else {
|
||||||
|
const changeStart = i;
|
||||||
|
while (i < lines.length && lines[i].chunkType !== 'Equal') {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
type: 'change',
|
||||||
|
lines: lines.slice(changeStart, i),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpandSection = (expandKey: string) => {
|
||||||
|
setExpandedSections((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(expandKey)) {
|
||||||
|
newSet.delete(expandKey);
|
||||||
|
} else {
|
||||||
|
newSet.add(expandKey);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFileCollapse = (filePath: string) => {
|
||||||
|
setCollapsedFiles((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(filePath)) {
|
||||||
|
newSet.delete(filePath);
|
||||||
|
} else {
|
||||||
|
newSet.add(filePath);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapseAllFiles = () => {
|
||||||
|
if (diff) {
|
||||||
|
setCollapsedFiles(new Set(diff.files.map((file) => file.path)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandAllFiles = () => {
|
||||||
|
setCollapsedFiles(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!diff || diff.files.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-muted/30 border border-muted rounded-lg p-4 ${className}`}
|
||||||
|
>
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
<GitCompare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">No changes detected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-background border border-border rounded-lg overflow-hidden shadow-sm flex flex-col ${className}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-muted/50 px-3 py-2 border-b flex items-center justify-between flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GitCompare className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{diff.files.length} file{diff.files.length !== 1 ? 's' : ''} changed
|
||||||
|
</div>
|
||||||
|
{isBackgroundRefreshing && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="animate-spin h-3 w-3 border border-blue-500 border-t-transparent rounded-full"></div>
|
||||||
|
<span className="text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
Updating...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!compact && diff.files.length > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={expandAllFiles}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
disabled={collapsedFiles.size === 0}
|
||||||
|
>
|
||||||
|
Expand All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={collapseAllFiles}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
disabled={collapsedFiles.size === diff.files.length}
|
||||||
|
>
|
||||||
|
Collapse All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
<div
|
||||||
|
className={`${compact ? 'max-h-80' : 'flex-1 min-h-0'} overflow-y-auto`}
|
||||||
|
>
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{diff.files.map((file, fileIndex) => (
|
||||||
|
<div
|
||||||
|
key={fileIndex}
|
||||||
|
className={`border rounded-lg overflow-hidden ${
|
||||||
|
collapsedFiles.has(file.path) ? 'border-muted' : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`bg-muted px-3 py-1.5 flex items-center justify-between ${
|
||||||
|
!collapsedFiles.has(file.path) ? 'border-b' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleFileCollapse(file.path)}
|
||||||
|
className="h-5 w-5 p-0 hover:bg-muted-foreground/10"
|
||||||
|
title={
|
||||||
|
collapsedFiles.has(file.path)
|
||||||
|
? 'Expand diff'
|
||||||
|
: 'Collapse diff'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{collapsedFiles.has(file.path) ? (
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground font-mono">
|
||||||
|
{file.path}
|
||||||
|
</p>
|
||||||
|
{collapsedFiles.has(file.path) && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground ml-2">
|
||||||
|
<span className="bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 px-1 py-0.5 rounded text-xs">
|
||||||
|
+
|
||||||
|
{file.chunks
|
||||||
|
.filter((c) => c.chunk_type === 'Insert')
|
||||||
|
.reduce(
|
||||||
|
(acc, c) => acc + c.content.split('\n').length - 1,
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 px-1 py-0.5 rounded text-xs">
|
||||||
|
-
|
||||||
|
{file.chunks
|
||||||
|
.filter((c) => c.chunk_type === 'Delete')
|
||||||
|
.reduce(
|
||||||
|
(acc, c) => acc + c.content.split('\n').length - 1,
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onDeleteFile && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDeleteFile(file.path)}
|
||||||
|
disabled={deletingFiles.has(file.path)}
|
||||||
|
className="text-red-600 hover:text-red-800 hover:bg-red-50 h-6 px-2 gap-1"
|
||||||
|
title={`Delete ${file.path}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
{!compact && (
|
||||||
|
<span className="text-xs">
|
||||||
|
{deletingFiles.has(file.path)
|
||||||
|
? 'Deleting...'
|
||||||
|
: 'Delete'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!collapsedFiles.has(file.path) && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="inline-block min-w-full">
|
||||||
|
{processFileChunks(file.chunks, fileIndex).map(
|
||||||
|
(section, sectionIndex) => {
|
||||||
|
if (
|
||||||
|
section.type === 'context' &&
|
||||||
|
section.lines.length === 0 &&
|
||||||
|
section.expandKey
|
||||||
|
) {
|
||||||
|
const lineCount =
|
||||||
|
parseInt(section.expandKey.split('-')[2]) -
|
||||||
|
parseInt(section.expandKey.split('-')[1]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`expand-${section.expandKey}`}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
toggleExpandSection(section.expandKey!)
|
||||||
|
}
|
||||||
|
className="w-full h-5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/50 border-t border-b border-gray-200 dark:border-gray-700 rounded-none justify-start"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3 mr-1" />
|
||||||
|
Show {lineCount} more lines
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`section-${sectionIndex}`}>
|
||||||
|
{section.type === 'expanded' &&
|
||||||
|
section.expandKey && (
|
||||||
|
<div className="w-full">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
toggleExpandSection(section.expandKey!)
|
||||||
|
}
|
||||||
|
className="w-full h-5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/50 border-t border-b border-gray-200 dark:border-gray-700 rounded-none justify-start"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3 w-3 mr-1" />
|
||||||
|
Hide expanded lines
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{section.lines.map((line, lineIndex) => (
|
||||||
|
<div
|
||||||
|
key={`${sectionIndex}-${lineIndex}`}
|
||||||
|
className={getChunkClassName(line.chunkType)}
|
||||||
|
style={{ minWidth: 'max-content' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={getLineNumberClassName(
|
||||||
|
line.chunkType
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-block w-4 text-right text-xs">
|
||||||
|
{line.oldLineNumber || ''}
|
||||||
|
</span>
|
||||||
|
<span className="inline-block w-4 text-right ml-1 text-xs">
|
||||||
|
{line.newLineNumber || ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 px-2 min-h-[1rem] flex items-center">
|
||||||
|
<span className="inline-block w-3 text-xs">
|
||||||
|
{getChunkPrefix(line.chunkType)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{line.content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,18 +20,24 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { makeRequest } from '@/lib/api';
|
import { makeRequest } from '@/lib/api';
|
||||||
import { MarkdownRenderer } from '@/components/ui/markdown-renderer';
|
import { MarkdownRenderer } from '@/components/ui/markdown-renderer';
|
||||||
|
import { DiffCard } from './DiffCard';
|
||||||
import type {
|
import type {
|
||||||
NormalizedConversation,
|
NormalizedConversation,
|
||||||
NormalizedEntry,
|
NormalizedEntry,
|
||||||
NormalizedEntryType,
|
NormalizedEntryType,
|
||||||
ExecutionProcess,
|
ExecutionProcess,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
WorktreeDiff,
|
||||||
} from 'shared/types';
|
} from 'shared/types';
|
||||||
|
|
||||||
interface NormalizedConversationViewerProps {
|
interface NormalizedConversationViewerProps {
|
||||||
executionProcess: ExecutionProcess;
|
executionProcess: ExecutionProcess;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onConversationUpdate?: () => void;
|
onConversationUpdate?: () => void;
|
||||||
|
diff?: WorktreeDiff | null;
|
||||||
|
isBackgroundRefreshing?: boolean;
|
||||||
|
onDeleteFile?: (filePath: string) => void;
|
||||||
|
deletingFiles?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEntryIcon = (entryType: NormalizedEntryType) => {
|
const getEntryIcon = (entryType: NormalizedEntryType) => {
|
||||||
@@ -207,6 +213,116 @@ const clusterGeminiMessages = (
|
|||||||
return clustered;
|
return clustered;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to determine if a tool call modifies files
|
||||||
|
const isFileModificationToolCall = (
|
||||||
|
entryType: NormalizedEntryType
|
||||||
|
): boolean => {
|
||||||
|
if (entryType.type !== 'tool_use') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for direct file write action
|
||||||
|
if (entryType.action_type.action === 'file_write') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for "other" actions that are file modification tools
|
||||||
|
if (entryType.action_type.action === 'other') {
|
||||||
|
const fileModificationTools = [
|
||||||
|
'edit',
|
||||||
|
'write',
|
||||||
|
'create_file',
|
||||||
|
'multiedit',
|
||||||
|
'edit_file',
|
||||||
|
];
|
||||||
|
return fileModificationTools.includes(
|
||||||
|
entryType.tool_name?.toLowerCase() || ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract file path from tool call
|
||||||
|
const extractFilePathFromToolCall = (entry: NormalizedEntry): string | null => {
|
||||||
|
if (entry.entry_type.type !== 'tool_use') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action_type, tool_name } = entry.entry_type;
|
||||||
|
|
||||||
|
// Direct path extraction from action_type
|
||||||
|
if (action_type.action === 'file_write') {
|
||||||
|
return action_type.path || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For "other" actions, check if it's a known file modification tool
|
||||||
|
if (action_type.action === 'other') {
|
||||||
|
const fileModificationTools = [
|
||||||
|
'edit',
|
||||||
|
'write',
|
||||||
|
'create_file',
|
||||||
|
'multiedit',
|
||||||
|
'edit_file',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (fileModificationTools.includes(tool_name.toLowerCase())) {
|
||||||
|
// Parse file path from content field
|
||||||
|
return parseFilePathFromContent(entry.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse file path from content (handles various formats)
|
||||||
|
const parseFilePathFromContent = (content: string): string | null => {
|
||||||
|
// Try to extract path from backticks: `path/to/file.ext`
|
||||||
|
const backtickMatch = content.match(/`([^`]+)`/);
|
||||||
|
if (backtickMatch) {
|
||||||
|
return backtickMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from common patterns like "Edit file: path" or "Write file: path"
|
||||||
|
const actionMatch = content.match(
|
||||||
|
/(?:Edit|Write|Create)\s+file:\s*([^\s\n]+)/i
|
||||||
|
);
|
||||||
|
if (actionMatch) {
|
||||||
|
return actionMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create filtered diff showing only specific files
|
||||||
|
const createIncrementalDiff = (
|
||||||
|
fullDiff: WorktreeDiff | null,
|
||||||
|
targetFilePaths: string[]
|
||||||
|
): WorktreeDiff | null => {
|
||||||
|
if (!fullDiff || targetFilePaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter files to only include the target file paths
|
||||||
|
const filteredFiles = fullDiff.files.filter((file) =>
|
||||||
|
targetFilePaths.some(
|
||||||
|
(targetPath) =>
|
||||||
|
file.path === targetPath ||
|
||||||
|
file.path.endsWith('/' + targetPath) ||
|
||||||
|
targetPath.endsWith('/' + file.path)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filteredFiles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fullDiff,
|
||||||
|
files: filteredFiles,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Helper function to determine if content should be rendered as markdown
|
// Helper function to determine if content should be rendered as markdown
|
||||||
const shouldRenderMarkdown = (entryType: NormalizedEntryType) => {
|
const shouldRenderMarkdown = (entryType: NormalizedEntryType) => {
|
||||||
// Render markdown for assistant messages and tool outputs that contain backticks
|
// Render markdown for assistant messages and tool outputs that contain backticks
|
||||||
@@ -242,6 +358,10 @@ export function NormalizedConversationViewer({
|
|||||||
executionProcess,
|
executionProcess,
|
||||||
projectId,
|
projectId,
|
||||||
onConversationUpdate,
|
onConversationUpdate,
|
||||||
|
diff,
|
||||||
|
isBackgroundRefreshing = false,
|
||||||
|
onDeleteFile,
|
||||||
|
deletingFiles = new Set(),
|
||||||
}: NormalizedConversationViewerProps) {
|
}: NormalizedConversationViewerProps) {
|
||||||
const [conversation, setConversation] =
|
const [conversation, setConversation] =
|
||||||
useState<NormalizedConversation | null>(null);
|
useState<NormalizedConversation | null>(null);
|
||||||
@@ -430,70 +550,105 @@ export function NormalizedConversationViewer({
|
|||||||
const isExpanded = expandedErrors.has(index);
|
const isExpanded = expandedErrors.has(index);
|
||||||
const hasMultipleLines =
|
const hasMultipleLines =
|
||||||
isErrorMessage && entry.content.includes('\n');
|
isErrorMessage && entry.content.includes('\n');
|
||||||
|
const isFileModification = isFileModificationToolCall(
|
||||||
|
entry.entry_type
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract file path from this specific tool call
|
||||||
|
const modifiedFilePath = isFileModification
|
||||||
|
? extractFilePathFromToolCall(entry)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Create incremental diff showing only the files modified by this specific tool call
|
||||||
|
const incrementalDiff =
|
||||||
|
modifiedFilePath && diff
|
||||||
|
? createIncrementalDiff(diff, [modifiedFilePath])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Show incremental diff for this specific file modification
|
||||||
|
const shouldShowDiff =
|
||||||
|
isFileModification &&
|
||||||
|
incrementalDiff &&
|
||||||
|
incrementalDiff.files.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-start gap-3">
|
<div key={index}>
|
||||||
<div className="flex-shrink-0 mt-1">
|
<div className="flex items-start gap-3">
|
||||||
{isErrorMessage && hasMultipleLines ? (
|
<div className="flex-shrink-0 mt-1">
|
||||||
<button
|
{isErrorMessage && hasMultipleLines ? (
|
||||||
onClick={() => toggleErrorExpansion(index)}
|
<button
|
||||||
className="transition-colors hover:opacity-70"
|
onClick={() => toggleErrorExpansion(index)}
|
||||||
>
|
className="transition-colors hover:opacity-70"
|
||||||
{getEntryIcon(entry.entry_type)}
|
>
|
||||||
</button>
|
{getEntryIcon(entry.entry_type)}
|
||||||
) : (
|
</button>
|
||||||
getEntryIcon(entry.entry_type)
|
) : (
|
||||||
)}
|
getEntryIcon(entry.entry_type)
|
||||||
</div>
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
</div>
|
||||||
{isErrorMessage && hasMultipleLines ? (
|
<div className="flex-1 min-w-0">
|
||||||
<div className={isExpanded ? 'space-y-2' : ''}>
|
{isErrorMessage && hasMultipleLines ? (
|
||||||
<div className={getContentClassName(entry.entry_type)}>
|
<div className={isExpanded ? 'space-y-2' : ''}>
|
||||||
{isExpanded ? (
|
<div className={getContentClassName(entry.entry_type)}>
|
||||||
shouldRenderMarkdown(entry.entry_type) ? (
|
{isExpanded ? (
|
||||||
<MarkdownRenderer
|
shouldRenderMarkdown(entry.entry_type) ? (
|
||||||
content={entry.content}
|
<MarkdownRenderer
|
||||||
className="whitespace-pre-wrap break-words"
|
content={entry.content}
|
||||||
/>
|
className="whitespace-pre-wrap break-words"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
entry.content
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
entry.content
|
<>
|
||||||
)
|
{entry.content.split('\n')[0]}
|
||||||
) : (
|
<button
|
||||||
<>
|
onClick={() => toggleErrorExpansion(index)}
|
||||||
{entry.content.split('\n')[0]}
|
className="ml-2 inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||||
<button
|
>
|
||||||
onClick={() => toggleErrorExpansion(index)}
|
<ChevronRight className="h-3 w-3" />
|
||||||
className="ml-2 inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
Show more
|
||||||
>
|
</button>
|
||||||
<ChevronRight className="h-3 w-3" />
|
</>
|
||||||
Show more
|
)}
|
||||||
</button>
|
</div>
|
||||||
</>
|
{isExpanded && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleErrorExpansion(index)}
|
||||||
|
className="flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
Show less
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isExpanded && (
|
) : (
|
||||||
<button
|
<div className={getContentClassName(entry.entry_type)}>
|
||||||
onClick={() => toggleErrorExpansion(index)}
|
{shouldRenderMarkdown(entry.entry_type) ? (
|
||||||
className="flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
<MarkdownRenderer
|
||||||
>
|
content={entry.content}
|
||||||
<ChevronUp className="h-3 w-3" />
|
className="whitespace-pre-wrap break-words"
|
||||||
Show less
|
/>
|
||||||
</button>
|
) : (
|
||||||
)}
|
entry.content
|
||||||
</div>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<div className={getContentClassName(entry.entry_type)}>
|
)}
|
||||||
{shouldRenderMarkdown(entry.entry_type) ? (
|
</div>
|
||||||
<MarkdownRenderer
|
|
||||||
content={entry.content}
|
|
||||||
className="whitespace-pre-wrap break-words"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
entry.content
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Render incremental diff card inline after file modification entries */}
|
||||||
|
{shouldShowDiff && incrementalDiff && (
|
||||||
|
<div className="mt-4 mb-2">
|
||||||
|
<DiffCard
|
||||||
|
diff={incrementalDiff}
|
||||||
|
isBackgroundRefreshing={isBackgroundRefreshing}
|
||||||
|
onDeleteFile={onDeleteFile}
|
||||||
|
deletingFiles={deletingFiles}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user