refactor: track additions and deletions in file history

- Add additions and deletions fields to FileHistory interface
- Calculate diff stats when creating/updating file history
- Use pre-calculated stats in UI instead of recalculating
- Add fallback for contextFiles to continue with the llm request in event of an error.
- Remove FileModifiedDropdown to its own component
- Add refreshAllPreviews class in previews store for refreshing previews on file change
- Add auto switch to preview once isStreaming goes from true to false
- Add actionType to files to now show whether a file is being created or updated and modified message-parser and artifact.tsx accordingly
This commit is contained in:
KevIsDev 2025-06-23 13:10:45 +01:00
parent e7910790af
commit 335f7c6077
11 changed files with 282 additions and 233 deletions

View File

@ -229,7 +229,7 @@ const ActionList = memo(({ actions }: ActionListProps) => {
<div className="i-ph:x"></div>
) : null}
</div>
{type === 'file' ? (
{type === 'file' && action.actionType === 'create' ? (
<div>
Create{' '}
<code
@ -239,6 +239,16 @@ const ActionList = memo(({ actions }: ActionListProps) => {
{action.filePath}
</code>
</div>
) : type === 'file' && action.actionType === 'update' ? (
<div>
Update{' '}
<code
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
onClick={() => openArtifactInWorkbench(action.filePath)}
>
{action.filePath}
</code>
</div>
) : type === 'shell' ? (
<div className="flex items-center w-full min-h-[28px]">
<span className="flex-1">Run command</span>

View File

@ -98,9 +98,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
message: 'Analysing Request',
} satisfies ProgressAnnotation);
// Create a summary of the chat
console.log(`Messages count: ${messages.length}`);
summary = await createSummary({
messages: [...messages],
env: context.cloudflare?.env,
@ -142,7 +139,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
} satisfies ProgressAnnotation);
// Select context files
console.log(`Messages count: ${messages.length}`);
filteredFiles = await selectContext({
messages: [...messages],
env: context.cloudflare?.env,

View File

@ -180,7 +180,11 @@ export async function selectContext(props: {
const updateContextBuffer = response.match(/<updateContextBuffer>([\s\S]*?)<\/updateContextBuffer>/);
if (!updateContextBuffer) {
throw new Error('Invalid response. Please follow the response format');
logger.error('Invalid response format. Response:', response);
logger.warn('Falling back to current context files');
// Return current context instead of throwing
return contextFiles;
}
const includeFiles =

View File

@ -161,7 +161,12 @@ The year is 2025.
Action Types:
- shell: Running commands (use --yes for npx/npm create, && for sequences, NEVER re-run dev servers)
- start: Starting project (use ONLY for project startup, LAST action)
- file: Creating/updating files (add filePath and contentType attributes)
- file:
- Two actionTypes are available for file actions:
- Create for when creating a new file - Example: <boltAction type="file" actionType="create" filePath="/home/project/src/App.tsx">
- Update for when updating an existing file - Example: <boltAction type="file" actionType="update" filePath="/home/project/src/App.tsx">
- Creating/updating files (add filePath and contentType attributes)
- If updating a file it should have an update actionType attribute
File Action Rules:
- Only include new/modified files

View File

@ -357,12 +357,14 @@ export class StreamingMessageParser {
}
} else if (actionType === 'file') {
const filePath = this.#extractAttribute(actionTag, 'filePath') as string;
const actionType = this.#extractAttribute(actionTag, 'actionType') as 'create' | 'update';
if (!filePath) {
logger.debug('File path not specified');
}
(actionAttributes as FileAction).filePath = filePath;
(actionAttributes as FileAction).actionType = actionType || 'create'; // default to 'create' if not specified
} else if (!['shell', 'start'].includes(actionType)) {
logger.warn(`Unknown action type '${actionType}'`);
}

View File

@ -9,6 +9,7 @@ export interface BaseAction {
export interface FileAction extends BaseAction {
type: 'file';
filePath: string;
actionType: 'create' | 'update';
}
export interface ShellAction extends BaseAction {
@ -70,6 +71,8 @@ export interface FileHistory {
timestamp: number;
content: string;
}[];
additions?: number;
deletions?: number;
// Novo campo para rastrear a origem das mudanças
changeSource?: 'user' | 'auto-save' | 'external';

View File

@ -1,11 +1,8 @@
import { useStore } from '@nanostores/react';
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useCallback, useEffect, useState, useMemo } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { Popover, Transition } from '@headlessui/react';
import { diffLines, type Change } from 'diff';
import { getLanguageFromExtension } from '~/shared/utils/getLanguageFromExtension';
import type { FileHistory } from '~/shared/types/actions';
import { DiffView } from './ui/DiffView';
import {
@ -26,6 +23,7 @@ import { PushToGitHubDialog } from '~/settings/tabs/connections/components/PushT
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { chatStore } from '~/chat/stores/chat';
import type { ElementInfo } from './ui/Inspector';
import { FileModifiedDropdown } from './ui/FileModifiedDropdown';
interface WorkspaceProps {
chatStarted?: boolean;
@ -71,212 +69,6 @@ const workbenchVariants = {
},
} satisfies Variants;
const FileModifiedDropdown = memo(
({
fileHistory,
onSelectFile,
}: {
fileHistory: Record<string, FileHistory>;
onSelectFile: (filePath: string) => void;
}) => {
const modifiedFiles = Object.entries(fileHistory);
const hasChanges = modifiedFiles.length > 0;
const [searchQuery, setSearchQuery] = useState('');
const filteredFiles = useMemo(() => {
return modifiedFiles.filter(([filePath]) => filePath.toLowerCase().includes(searchQuery.toLowerCase()));
}, [modifiedFiles, searchQuery]);
return (
<div className="flex items-center gap-2">
<Popover className="relative">
{({ open }: { open: boolean }) => (
<>
<Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-item-contentDefault">
<span>File Changes</span>
{hasChanges && (
<span className="w-5 h-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30">
{modifiedFiles.length}
</span>
)}
</Popover.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-bolt-elements-background-depth-2 shadow-xl border border-bolt-elements-borderColor">
<div className="p-2">
<div className="relative mx-2 mb-2">
<input
type="text"
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary">
<div className="i-ph:magnifying-glass" />
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{filteredFiles.length > 0 ? (
filteredFiles.map(([filePath, history]) => {
const extension = filePath.split('.').pop() || '';
const language = getLanguageFromExtension(extension);
return (
<button
key={filePath}
onClick={() => onSelectFile(filePath)}
className="w-full px-3 py-2 text-left rounded-md hover:bg-bolt-elements-background-depth-1 transition-colors group bg-transparent"
>
<div className="flex items-center gap-2">
<div className="shrink-0 w-5 h-5 text-bolt-elements-textTertiary">
{['typescript', 'javascript', 'jsx', 'tsx'].includes(language) && (
<div className="i-ph:file-js" />
)}
{['css', 'scss', 'less'].includes(language) && <div className="i-ph:paint-brush" />}
{language === 'html' && <div className="i-ph:code" />}
{language === 'json' && <div className="i-ph:brackets-curly" />}
{language === 'python' && <div className="i-ph:file-text" />}
{language === 'markdown' && <div className="i-ph:article" />}
{['yaml', 'yml'].includes(language) && <div className="i-ph:file-text" />}
{language === 'sql' && <div className="i-ph:database" />}
{language === 'dockerfile' && <div className="i-ph:cube" />}
{language === 'shell' && <div className="i-ph:terminal" />}
{![
'typescript',
'javascript',
'css',
'html',
'json',
'python',
'markdown',
'yaml',
'yml',
'sql',
'dockerfile',
'shell',
'jsx',
'tsx',
'scss',
'less',
].includes(language) && <div className="i-ph:file-text" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col min-w-0">
<span className="truncate text-sm font-medium text-bolt-elements-textPrimary">
{filePath.split('/').pop()}
</span>
<span className="truncate text-xs text-bolt-elements-textTertiary">
{filePath}
</span>
</div>
{(() => {
// Calculate diff stats
const { additions, deletions } = (() => {
if (!history.originalContent) {
return { additions: 0, deletions: 0 };
}
const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
const normalizedCurrent =
history.versions[history.versions.length - 1]?.content.replace(
/\r\n/g,
'\n',
) || '';
if (normalizedOriginal === normalizedCurrent) {
return { additions: 0, deletions: 0 };
}
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
newlineIsToken: false,
ignoreWhitespace: true,
ignoreCase: false,
});
return changes.reduce(
(acc: { additions: number; deletions: number }, change: Change) => {
if (change.added) {
acc.additions += change.value.split('\n').length;
}
if (change.removed) {
acc.deletions += change.value.split('\n').length;
}
return acc;
},
{ additions: 0, deletions: 0 },
);
})();
const showStats = additions > 0 || deletions > 0;
return (
showStats && (
<div className="flex items-center gap-1 text-xs shrink-0">
{additions > 0 && <span className="text-green-500">+{additions}</span>}
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
</div>
)
);
})()}
</div>
</div>
</div>
</button>
);
})
) : (
<div className="flex flex-col items-center justify-center p-4 text-center">
<div className="w-12 h-12 mb-2 text-bolt-elements-textTertiary">
<div className="i-ph:file-dashed" />
</div>
<p className="text-sm font-medium text-bolt-elements-textPrimary">
{searchQuery ? 'No matching files' : 'No modified files'}
</p>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
{searchQuery ? 'Try another search' : 'Changes will appear here as you edit'}
</p>
</div>
)}
</div>
</div>
{hasChanges && (
<div className="border-t border-bolt-elements-borderColor p-2">
<button
onClick={() => {
navigator.clipboard.writeText(filteredFiles.map(([filePath]) => filePath).join('\n'));
toast('File list copied to clipboard', {
icon: <div className="i-ph:check-circle text-accent-500" />,
});
}}
className="w-full flex items-center justify-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary"
>
Copy File List
</button>
</div>
)}
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
},
);
export const Workbench = memo(
({ chatStarted, isStreaming, metadata, updateChatMestaData, setSelectedElement }: WorkspaceProps) => {
renderLogger.trace('Workbench');
@ -296,6 +88,7 @@ export const Workbench = memo(
const selectedView = useStore(workbenchStore.currentView);
const { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
const [wasStreaming, setWasStreaming] = useState(false);
const isSmallViewport = useViewport(1024);
@ -303,6 +96,17 @@ export const Workbench = memo(
workbenchStore.currentView.set(view);
};
// Track streaming state changes
useEffect(() => {
if (isStreaming) {
setWasStreaming(true);
} else if (wasStreaming && hasPreview) {
// Streaming just stopped and we have a preview
setSelectedView('preview');
setWasStreaming(false);
}
}, [isStreaming, wasStreaming, hasPreview]);
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');

View File

@ -1,6 +1,7 @@
import { memo, useMemo, useState, useEffect, useCallback } from 'react';
import { useStore } from '@nanostores/react';
import { workbenchStore } from '~/workbench/stores/workbench';
import { debounce } from '~/shared/utils/debounce'; // Import debounce
import type { FileMap } from '~/workbench/stores/files';
import type { EditorDocument } from '~/workbench/components/editor/codemirror/CodeMirrorEditor';
import { diffLines, type Change } from 'diff';
@ -671,6 +672,13 @@ export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) =>
const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const debouncedSetFileHistory = useCallback(
debounce((newHistoryEntry: FileHistory) => {
setFileHistory((prev) => ({ ...prev, [selectedFile!]: newHistoryEntry }));
}, 300), // 300ms debounce
[setFileHistory, selectedFile],
);
useEffect(() => {
if (selectedFile && currentDocument) {
const file = files[selectedFile];
@ -692,21 +700,34 @@ export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) =>
if (!existingHistory) {
if (normalizedCurrentContent !== normalizedOriginalContent) {
const newChanges = diffLines(file.content, currentContent);
setFileHistory((prev) => ({
...prev,
[selectedFile]: {
originalContent: file.content,
lastModified: Date.now(),
changes: newChanges,
versions: [
{
timestamp: Date.now(),
content: currentContent,
},
],
changeSource: 'auto-save',
const stats = newChanges.reduce(
(acc, change) => {
if (change.added) {
acc.additions += change.count || 0;
}
if (change.removed) {
acc.deletions += change.count || 0;
}
return acc;
},
}));
{ additions: 0, deletions: 0 },
);
debouncedSetFileHistory({
originalContent: file.content,
lastModified: Date.now(),
changes: newChanges,
versions: [
{
timestamp: Date.now(),
content: currentContent,
},
],
additions: stats.additions,
deletions: stats.deletions,
changeSource: 'auto-save',
});
}
return;
@ -733,6 +754,22 @@ export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) =>
);
if (hasSignificantChanges) {
const overallChanges = diffLines(existingHistory.originalContent, currentContent);
const stats = overallChanges.reduce(
(acc, change) => {
if (change.added) {
acc.additions += change.count || 0;
}
if (change.removed) {
acc.deletions += change.count || 0;
}
return acc;
},
{ additions: 0, deletions: 0 },
);
const newHistory: FileHistory = {
originalContent: existingHistory.originalContent,
lastModified: Date.now(),
@ -744,14 +781,16 @@ export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) =>
content: currentContent,
},
].slice(-10), // Manter apenas as 10 últimas versões
additions: stats.additions,
deletions: stats.deletions,
changeSource: 'auto-save',
};
setFileHistory((prev) => ({ ...prev, [selectedFile]: newHistory }));
debouncedSetFileHistory(newHistory);
}
}
}
}, [selectedFile, currentDocument?.value, files, setFileHistory, unsavedFiles]);
}, [selectedFile, currentDocument?.value, files, debouncedSetFileHistory, unsavedFiles]); // Added debouncedSetFileHistory to deps
if (!selectedFile || !currentDocument) {
return (

View File

@ -0,0 +1,172 @@
import { memo, useState, useMemo } from 'react';
import { Popover, Transition } from '@headlessui/react';
import { getLanguageFromExtension } from '~/shared/utils/getLanguageFromExtension';
import type { FileHistory } from '~/shared/types/actions';
import { toast } from '~/shared/components/ui/use-toast';
export const FileModifiedDropdown = memo(
({
fileHistory,
onSelectFile,
}: {
fileHistory: Record<string, FileHistory>;
onSelectFile: (filePath: string) => void;
}) => {
const modifiedFiles = Object.entries(fileHistory);
const hasChanges = modifiedFiles.length > 0;
const [searchQuery, setSearchQuery] = useState('');
const filteredFiles = useMemo(() => {
return modifiedFiles.filter(([filePath]) => filePath.toLowerCase().includes(searchQuery.toLowerCase()));
}, [modifiedFiles, searchQuery]);
return (
<div className="flex items-center gap-2">
<Popover className="relative">
{({ open }: { open: boolean }) => (
<>
<Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-item-contentDefault">
<span>File Changes</span>
{hasChanges && (
<span className="w-5 h-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30">
{modifiedFiles.length}
</span>
)}
</Popover.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-bolt-elements-background-depth-2 shadow-xl border border-bolt-elements-borderColor">
<div className="p-2">
<div className="relative mx-2 mb-2">
<input
type="text"
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary">
<div className="i-ph:magnifying-glass" />
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{filteredFiles.length > 0 ? (
filteredFiles.map(([filePath, history]) => {
const extension = filePath.split('.').pop() || '';
const language = getLanguageFromExtension(extension);
return (
<button
key={filePath}
onClick={() => onSelectFile(filePath)}
className="w-full px-3 py-2 text-left rounded-md hover:bg-bolt-elements-background-depth-1 transition-colors group bg-transparent"
>
<div className="flex items-center gap-2">
<div className="shrink-0 w-5 h-5 text-bolt-elements-textTertiary">
{['typescript', 'javascript', 'jsx', 'tsx'].includes(language) && (
<div className="i-ph:file-js" />
)}
{['css', 'scss', 'less'].includes(language) && <div className="i-ph:paint-brush" />}
{language === 'html' && <div className="i-ph:code" />}
{language === 'json' && <div className="i-ph:brackets-curly" />}
{language === 'python' && <div className="i-ph:file-text" />}
{language === 'markdown' && <div className="i-ph:article" />}
{['yaml', 'yml'].includes(language) && <div className="i-ph:file-text" />}
{language === 'sql' && <div className="i-ph:database" />}
{language === 'dockerfile' && <div className="i-ph:cube" />}
{language === 'shell' && <div className="i-ph:terminal" />}
{![
'typescript',
'javascript',
'css',
'html',
'json',
'python',
'markdown',
'yaml',
'yml',
'sql',
'dockerfile',
'shell',
'jsx',
'tsx',
'scss',
'less',
].includes(language) && <div className="i-ph:file-text" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col min-w-0">
<span className="truncate text-sm font-medium text-bolt-elements-textPrimary">
{filePath.split('/').pop()}
</span>
<span className="truncate text-xs text-bolt-elements-textTertiary">
{filePath}
</span>
</div>
{/* Use pre-calculated stats from history object */}
{(history.additions !== undefined || history.deletions !== undefined) &&
(history.additions! > 0 || history.deletions! > 0) ? (
<div className="flex items-center gap-1 text-xs shrink-0">
{history.additions! > 0 && (
<span className="text-green-500">+{history.additions}</span>
)}
{history.deletions! > 0 && (
<span className="text-red-500">-{history.deletions}</span>
)}
</div>
) : null}
</div>
</div>
</div>
</button>
);
})
) : (
<div className="flex flex-col items-center justify-center p-4 text-center">
<div className="w-12 h-12 mb-2 text-bolt-elements-textTertiary">
<div className="i-ph:file-dashed" />
</div>
<p className="text-sm font-medium text-bolt-elements-textPrimary">
{searchQuery ? 'No matching files' : 'No modified files'}
</p>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
{searchQuery ? 'Try another search' : 'Changes will appear here as you edit'}
</p>
</div>
)}
</div>
</div>
{hasChanges && (
<div className="border-t border-bolt-elements-borderColor p-2">
<button
onClick={() => {
navigator.clipboard.writeText(filteredFiles.map(([filePath]) => filePath).join('\n'));
toast('File list copied to clipboard', {
icon: <div className="i-ph:check-circle text-accent-500" />,
});
}}
className="w-full flex items-center justify-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary"
>
Copy File List
</button>
</div>
)}
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
},
);

View File

@ -68,6 +68,18 @@ export class PreviewsStore {
return match ? match[1] : null;
}
refreshAllPreviews() {
const previews = this.previews.get();
previews.forEach((preview) => {
const previewId = this.getPreviewId(preview.baseUrl);
if (previewId) {
this.refreshPreview(previewId);
}
});
}
// Method to refresh a specific preview
refreshPreview(previewId: string) {
// Clear any pending refresh for this preview

View File

@ -238,6 +238,7 @@ export class WorkbenchStore {
newUnsavedFiles.delete(filePath);
this.unsavedFiles.set(newUnsavedFiles);
this.#previewsStore.refreshAllPreviews();
}
async saveCurrentDocument() {
@ -581,6 +582,7 @@ export class WorkbenchStore {
if (!isStreaming) {
await artifact.runner.runAction(data);
await this.saveFile(fullPath);
this.resetAllFileModifications();
}
} else {