Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve IndexedDB persistence handling #139

Closed
wants to merge 9 commits into from
13 changes: 11 additions & 2 deletions app/components/chat/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ interface BaseChatProps {
messages?: Message[];
enhancingPrompt?: boolean;
promptEnhanced?: boolean;
fromCache?: boolean;
input?: string;
model: string;
setModel: (model: string) => void;
Expand All @@ -96,6 +97,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
isStreaming = false,
enhancingPrompt = false,
promptEnhanced = false,
fromCache = false,
messages,
input = '',
model,
Expand Down Expand Up @@ -224,8 +226,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</>
) : (
<>
<div className="i-bolt:stars text-xl"></div>
{promptEnhanced && <div className="ml-1.5">Prompt enhanced</div>}
<div className={classNames("text-xl", {
"i-bolt:stars": !fromCache,
"i-heroicons:clock": fromCache && promptEnhanced
})}></div>
{promptEnhanced && (
<div className="ml-1.5">
{fromCache ? "From cache" : "Prompt enhanced"}
</div>
)}
</>
)}
</IconButton>
Expand Down
55 changes: 14 additions & 41 deletions app/components/chat/Chat.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Message } from 'ai';
import { useChat } from 'ai/react';
import { useAnimate } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { cssTransition, ToastContainer } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
import { useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat';
Expand All @@ -28,9 +28,10 @@ export function Chat() {

const { ready, initialMessages, storeMessageHistory } = useChatHistory();

return (
// Only show the chat component if we're ready
return ready ? (
<>
{ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
<ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />
<ToastContainer
closeButton={({ closeToast }) => {
return (
Expand All @@ -40,9 +41,6 @@ export function Chat() {
);
}}
icon={({ type }) => {
/**
* @todo Handle more types if we need them. This may require extra color palettes.
*/
switch (type) {
case 'success': {
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
Expand All @@ -51,15 +49,16 @@ export function Chat() {
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
}
}

return undefined;
}}
position="bottom-right"
pauseOnFocusLoss
transition={toastAnimation}
hideProgressBar
autoClose={false}
/>
</>
);
) : null;
}

interface ChatProps {
Expand All @@ -83,15 +82,14 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
api: '/api/chat',
onError: (error) => {
logger.error('Request failed\n\n', error);
toast.error('There was an error processing your request');
},
onFinish: () => {
logger.debug('Finished streaming');
},
initialMessages,
});

const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
const { enhancingPrompt, promptEnhanced, fromCache, enhancePrompt, resetEnhancer } = usePromptEnhancer();
const { parsedMessages, parseMessages } = useMessageParser();

const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
Expand All @@ -104,13 +102,14 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
parseMessages(messages, isLoading);

if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => toast.error(error.message));
storeMessageHistory(messages).catch((error) => {
logger.error('Failed to store message history:', error);
});
}
}, [messages, isLoading, parseMessages]);
}, [messages, isLoading, parseMessages, storeMessageHistory, initialMessages.length]);

const scrollTextArea = () => {
const textarea = textareaRef.current;

if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
Expand All @@ -124,16 +123,13 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp

useEffect(() => {
const textarea = textareaRef.current;

if (textarea) {
textarea.style.height = 'auto';

const scrollHeight = textarea.scrollHeight;

textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
}
}, [input, textareaRef]);
}, [input, TEXTAREA_MAX_HEIGHT]);

const runAnimation = async () => {
if (chatStarted) {
Expand All @@ -146,7 +142,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
]);

chatStore.setKey('started', true);

setChatStarted(true);
};

Expand All @@ -157,13 +152,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
return;
}

/**
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
* many unsaved files. In that case we need to block user input and show an indicator
* of some kind so the user is aware that something is happening. But I consider the
* happy case to be no unsaved files and I would expect users to save their changes
* before they send another message.
*/
await workbenchStore.saveAllFiles();

const fileModifications = workbenchStore.getFileModifcations();
Expand All @@ -174,29 +162,14 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp

if (fileModifications !== undefined) {
const diff = fileModificationsToHTML(fileModifications);

/**
* If we have file modifications we append a new user message manually since we have to prefix
* the user input with the file modifications and we don't want the new user input to appear
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
* manually reset the input and we'd have to manually pass in file attachments. However, those
* aren't relevant here.
*/
append({ role: 'user', content: `[Model: ${model}]\n\n${diff}\n\n${_input}` });

/**
* After sending a new message we reset all modifications since the model
* should now be aware of all the changes.
*/
workbenchStore.resetAllFileModifications();
} else {
append({ role: 'user', content: `[Model: ${model}]\n\n${_input}` });
}

setInput('');

resetEnhancer();

textareaRef.current?.blur();
};

Expand All @@ -212,6 +185,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
isStreaming={isLoading}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
fromCache={fromCache}
sendMessage={sendMessage}
model={model}
setModel={setModel}
Expand All @@ -223,7 +197,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
if (message.role === 'user') {
return message;
}

return {
...message,
content: parsedMessages[i] || '',
Expand Down
69 changes: 69 additions & 0 deletions app/components/editor/VersionHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useStore } from '@nanostores/react';
import { useState } from 'react';
import { versionHistoryStore } from '~/lib/stores/version-history';

interface VersionHistoryProps {
filePath: string;
}

export function VersionHistory({ filePath }: VersionHistoryProps) {
const [isReverting, setIsReverting] = useState(false);
const versions = versionHistoryStore.getVersions(filePath);
const currentVersion = versionHistoryStore.getCurrentVersion(filePath);

if (!versions.length) {
return null;
}

const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};

const handleRevert = async (versionIndex: number) => {
try {
setIsReverting(true);
await versionHistoryStore.revertToVersion(filePath, versionIndex);
} catch (error) {
console.error('Failed to revert file:', error);
} finally {
setIsReverting(false);
}
};

return (
<div className="version-history p-4 bg-bolt-elements-background-depth-1">
<h3 className="text-lg font-semibold mb-4">Version History</h3>
<div className="version-list space-y-3 max-h-[300px] overflow-y-auto">
{versions.map((version, index) => (
<div
key={version.timestamp}
className={`version-item p-3 rounded-lg ${
currentVersion && currentVersion.timestamp === version.timestamp
? 'bg-bolt-elements-background-depth-3 border-l-2 border-bolt-elements-borderColor-active'
: 'bg-bolt-elements-background-depth-2'
}`}
>
<div className="version-info space-y-1">
<div className="flex justify-between items-center">
<span className="font-medium">Version {versions.length - index}</span>
<span className="text-sm text-bolt-elements-textSecondary">
{formatDate(version.timestamp)}
</span>
</div>
<p className="text-sm text-bolt-elements-textSecondary">{version.description}</p>
</div>
{currentVersion && currentVersion.timestamp !== version.timestamp && (
<button
onClick={() => handleRevert(index)}
className="mt-2 w-full px-3 py-1.5 text-sm bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary rounded hover:bg-bolt-elements-background-depth-4 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={isReverting}
>
{isReverting ? 'Reverting...' : 'Revert to this version'}
</button>
)}
</div>
))}
</div>
</div>
);
}
36 changes: 20 additions & 16 deletions app/components/workbench/EditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type OnSaveCallback as OnEditorSave,
type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor';
import { VersionHistory } from '~/components/editor/VersionHistory';
import { IconButton } from '~/components/ui/IconButton';
import { PanelHeader } from '~/components/ui/PanelHeader';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
Expand Down Expand Up @@ -76,10 +77,6 @@ export const EditorPanel = memo(
return editorDocument.filePath.split('/');
}, [editorDocument]);

const activeFileUnsaved = useMemo(() => {
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
}, [editorDocument, unsavedFiles]);

useEffect(() => {
const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
terminalToggledByShortcut.current = true;
Expand Down Expand Up @@ -149,7 +146,7 @@ export const EditorPanel = memo(
{activeFileSegments?.length && (
<div className="flex items-center flex-1 text-sm">
<FileBreadcrumb pathSegments={activeFileSegments} files={files} onFileSelect={onFileSelect} />
{activeFileUnsaved && (
{editorDocument && (
<div className="flex gap-1 ml-auto -mr-1.5">
<PanelHeaderButton onClick={onFileSave}>
<div className="i-ph:floppy-disk-duotone" />
Expand All @@ -164,17 +161,24 @@ export const EditorPanel = memo(
</div>
)}
</PanelHeader>
<div className="h-full flex-1 overflow-hidden">
<CodeMirrorEditor
theme={theme}
editable={!isStreaming && editorDocument !== undefined}
settings={editorSettings}
doc={editorDocument}
autoFocusOnDocumentChange={!isMobile()}
onScroll={onEditorScroll}
onChange={onEditorChange}
onSave={onFileSave}
/>
<div className="flex flex-col h-full flex-1 overflow-hidden">
<div className="flex-1">
<CodeMirrorEditor
theme={theme}
editable={!isStreaming && editorDocument !== undefined}
settings={editorSettings}
doc={editorDocument}
autoFocusOnDocumentChange={!isMobile()}
onScroll={onEditorScroll}
onChange={onEditorChange}
onSave={onFileSave}
/>
</div>
{editorDocument && (
<div className="border-t border-bolt-elements-borderColor">
<VersionHistory filePath={editorDocument.filePath} />
</div>
)}
</div>
</Panel>
</PanelGroup>
Expand Down
18 changes: 18 additions & 0 deletions app/components/workbench/Workbench.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,23 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
workbenchStore.setDocuments(files);
}, [files]);

// Force workbench to show when a file is selected or modified
useEffect(() => {
if (selectedFile || (currentDocument && unsavedFiles.has(currentDocument.filePath))) {
workbenchStore.showWorkbench.set(true);
workbenchStore.currentView.set('code');
}
}, [selectedFile, currentDocument, unsavedFiles]);

// Show version history for files modified through chat
useEffect(() => {
const currentFile = currentDocument?.filePath;
if (currentFile && files[currentFile]) {
workbenchStore.setShowWorkbench(true);
workbenchStore.currentView.set('code');
}
}, [files, currentDocument]);

const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
Expand Down Expand Up @@ -230,6 +247,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
)
);
});

interface ViewProps extends HTMLMotionProps<'div'> {
children: JSX.Element;
}
Expand Down
Loading